mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-06 01:24:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5bf3d9b572 | ||
|
|
6d94aa1f9f | ||
|
|
0e6a3db9a6 |
No files matched your search
@@ -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
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ test-results/
|
||||
.DS_Store
|
||||
|
||||
# Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs
|
||||
data/
|
||||
# Тома docker-стенда (postgres, redis, caddy, media) — только в корне репозитория.
|
||||
# Без якоря правило ловило и demo_seed/data: русские манифесты в индексе уже
|
||||
# лежали, а новые файлы туда молча не попадали.
|
||||
/data/
|
||||
.env.bak-*
|
||||
**/staticfiles/
|
||||
|
||||
|
||||
@@ -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 требует явного согласования до правок.
|
||||
@@ -51,9 +55,16 @@ Engineering rules / обязательные практики:
|
||||
- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести.
|
||||
- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя.
|
||||
- На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте.
|
||||
- Текст, уходящий наружу — клиенту в мессенджер, письмо, кнопку виджета, — берёт язык не запроса, а организации: `t(..., language=customer_language(organization))`. Язык оператора, нажавшего кнопку, к речи компании с её клиентом отношения не имеет, и половина такого текста вообще рождается в воркере, где запроса нет.
|
||||
- Системный промпт агента не переводится: его читает модель. Язык ответа задаёт отдельная директива по полю `AIAgent.answer_language` — по умолчанию «как у клиента».
|
||||
- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык.
|
||||
|
||||
Definition of Done:
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации: изменил
|
||||
presence — гоняешь тесты присутствия, изменил тексты — гоняешь каталог i18n.
|
||||
Полный прогон всех тестов выполнять ТОЛЬКО по явному указанию владельца.
|
||||
Использовать `--reuse-db`; `--create-db` — лишняя минута на переигрывание
|
||||
миграций, она нужна только когда схема действительно поменялась.
|
||||
- Измененные файлы должны быть проверены на NO GOD violations.
|
||||
- Новый компонент не должен владеть несвязанными ответственностями.
|
||||
- Не должно быть придуманных текстов, иконок, layout-решений или состояний вне design/docs.
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
import surfaces
|
||||
}
|
||||
|
||||
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
|
||||
import /config/sites/*.caddy
|
||||
|
||||
# Любой хост, которому ask-эндпоинт разрешил сертификат.
|
||||
https:// {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -32,7 +32,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)
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
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.
|
||||
|
||||
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 +58,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.
|
||||
|
||||
@@ -101,7 +101,7 @@ Everything else is done in **Settings**.
|
||||
|---|---|
|
||||
| **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. |
|
||||
| **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 +116,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 +133,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
|
||||
@@ -190,7 +193,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 +245,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>
|
||||
@@ -269,7 +271,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 +313,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
|
||||
|
||||
+19
-17
@@ -32,7 +32,7 @@
|
||||
- [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска)
|
||||
- [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе)
|
||||
- [Виджет на сайте](#виджет-на-сайте)
|
||||
- [Звонки через relay (опционально)](#звонки-через-relay-опционально)
|
||||
- [Звонки](#звонки)
|
||||
- [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально)
|
||||
- [Обновление](#обновление)
|
||||
- [Функции](#функции)
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет.
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +58,7 @@ Chatballs берёт на себя первую линию общения с к
|
||||
| **Сервер** | Linux, x86_64 |
|
||||
| **ПО** | Docker и плагин Docker Compose |
|
||||
| **Порты** | 80 и 443 открыты |
|
||||
| **Relay для звонков (опционально)** | Выделенный публичный IP, порт 3478 и диапазон UDP 49160–49999 |
|
||||
| **Порты для звонков** | 3478 (UDP и TCP) и диапазон UDP 49160–49999 |
|
||||
|
||||
Домен на старте не нужен. Установка открывается по IP-адресу сервера, домен задаётся позже в настройках.
|
||||
|
||||
@@ -101,7 +101,7 @@ docker compose up -d --wait
|
||||
|---|---|
|
||||
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
|
||||
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
|
||||
|
||||
На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников.
|
||||
@@ -116,15 +116,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 +133,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -190,7 +193,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Свой сервер и своя ИИ-модель
|
||||
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову.
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель.
|
||||
|
||||
### Защита данных клиентов
|
||||
|
||||
@@ -242,8 +245,7 @@ docker compose logs gateway
|
||||
1. Агент в статусе **Активен**, а не **Черновик**.
|
||||
2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен.
|
||||
3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус.
|
||||
4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ.
|
||||
5. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
4. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -269,7 +271,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 +313,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
|
||||
|
||||
@@ -19,6 +19,7 @@ from chatballs.ai.agent_knowledge import (
|
||||
from chatballs.ai.knowledge_policy import readable_knowledge
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.models import PortalArticle
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -37,17 +38,17 @@ def normalized_ids(raw_ids: Iterable[int], field: str) -> list[int]:
|
||||
normalized: list[int] = []
|
||||
for value in raw_ids:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValidationError({field: "Positive integer ID required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
if not normalized:
|
||||
raise ValidationError({field: "At least one ID is required"})
|
||||
raise ValidationError({field: t("api.at_least_one_id")})
|
||||
return normalized
|
||||
|
||||
|
||||
def _locked_agent(*, context: TenantContext, agent: AIAgent) -> tuple[AIAgent, Channel]:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -99,7 +100,7 @@ def link_knowledge_to_agent(
|
||||
)
|
||||
found_ids = set(readable.values_list("id", flat=True))
|
||||
if len(found_ids) != len(requested_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")})
|
||||
available_ids = set(
|
||||
knowledge_available_to_channel(
|
||||
Knowledge.objects.filter(id__in=found_ids),
|
||||
@@ -130,7 +131,7 @@ def link_portal_articles_to_agent(
|
||||
)
|
||||
found_ids = set(articles.values_list("id", flat=True))
|
||||
if len(found_ids) != len(requested_ids):
|
||||
raise ValidationError({"articleIds": "Unknown portal article"})
|
||||
raise ValidationError({"articleIds": t("ai.unknown_portal_article")})
|
||||
available_ids = set(
|
||||
portal_articles_available_to_channel(
|
||||
PortalArticle.objects.filter(id__in=found_ids),
|
||||
|
||||
@@ -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
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage
|
||||
from chatballs.ai.serializers import agent_portal_article_payload
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.services import (
|
||||
@@ -22,7 +22,7 @@ from chatballs.channels.services import (
|
||||
update_channel,
|
||||
)
|
||||
from chatballs.conversations.models import LifecycleState
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n import normalize_language, t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
"model": agent.model,
|
||||
"providerIntegrationId": agent.provider_integration_id,
|
||||
"modelParams": agent.model_params,
|
||||
"limits": agent.limits,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"persona": agent.persona,
|
||||
"tone": agent.tone,
|
||||
"instructions": agent.instructions,
|
||||
@@ -201,7 +201,7 @@ def create_agent_card(
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.services import _clean_name, _group_for_channel
|
||||
|
||||
authorization.require_organization_manage(context, operation="Создание агента")
|
||||
authorization.require_organization_manage(context, operation="channels.operation_agent_create")
|
||||
clean_name = _clean_name(name)
|
||||
group = _group_for_channel(context=context, group_id=group_id)
|
||||
channel = Channel.objects.create(
|
||||
@@ -231,10 +231,10 @@ def update_agent_card(
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"modelParams",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answerLanguage",
|
||||
"knowledgeIds",
|
||||
}
|
||||
if ai_fields & set(body):
|
||||
@@ -245,20 +245,17 @@ def update_agent_card(
|
||||
not isinstance(knowledge_ids, list)
|
||||
or not all(isinstance(item, int) for item in knowledge_ids)
|
||||
):
|
||||
raise ValidationError({"knowledgeIds": "List of ids required"})
|
||||
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": "Object required"})
|
||||
if not isinstance(limits, dict):
|
||||
raise ValidationError({"limits": "Object required"})
|
||||
raise ValidationError({"modelParams": t("api.object_required")})
|
||||
provider_integration_id = body.get(
|
||||
"providerIntegrationId", agent.provider_integration_id
|
||||
)
|
||||
if provider_integration_id is not None and not isinstance(
|
||||
provider_integration_id, int
|
||||
):
|
||||
raise ValidationError({"providerIntegrationId": "Integer id required"})
|
||||
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
|
||||
update_agent(
|
||||
context=context,
|
||||
agent=agent,
|
||||
@@ -268,10 +265,12 @@ def update_agent_card(
|
||||
provider_integration_id=provider_integration_id,
|
||||
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)
|
||||
),
|
||||
knowledge_ids=knowledge_ids,
|
||||
),
|
||||
)
|
||||
@@ -281,6 +280,18 @@ def update_agent_card(
|
||||
return agent_card_for_context(context=context, agent_id=channel.id)
|
||||
|
||||
|
||||
def _clean_answer_language(value: object) -> str:
|
||||
"""Режим ответа агента: MIRROR, ORGANIZATION или код поддерживаемого языка."""
|
||||
|
||||
raw = str(value or "").strip()
|
||||
if raw in AnswerLanguage.values:
|
||||
return raw
|
||||
code = normalize_language(raw)
|
||||
if code:
|
||||
return code
|
||||
raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")})
|
||||
|
||||
|
||||
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
|
||||
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
|
||||
counts = (
|
||||
@@ -296,7 +307,7 @@ def delete_agent_card(*, context: TenantContext, channel: Channel) -> None:
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.services import ChannelHasReferences
|
||||
|
||||
authorization.require_organization_manage(context, operation="Удаление агента")
|
||||
authorization.require_organization_manage(context, operation="channels.operation_agent_delete")
|
||||
blockers = agent_deletion_blockers(channel)
|
||||
if blockers:
|
||||
raise ChannelHasReferences(blockers)
|
||||
|
||||
@@ -110,7 +110,7 @@ class AgentCardListView(APIView):
|
||||
try:
|
||||
cards = cards.filter(group_id=int(group))
|
||||
except ValueError:
|
||||
return Response({"detail": "group must be an id or none"}, status=400)
|
||||
return Response({"detail": t("ai.group_id_or_none")}, status=400)
|
||||
from chatballs.ai.agent_card import (
|
||||
ensure_channel_agent,
|
||||
knowledge_total_for_organization,
|
||||
@@ -135,7 +135,7 @@ class AgentCardListView(APIView):
|
||||
if group_id is not None and (
|
||||
isinstance(group_id, bool) or not isinstance(group_id, int)
|
||||
):
|
||||
return Response({"detail": "groupId must be an integer or null"}, status=400)
|
||||
return Response({"detail": t("ai.group_id_integer_or_null")}, status=400)
|
||||
try:
|
||||
channel = create_agent_card(
|
||||
context=request.tenant_context,
|
||||
@@ -245,11 +245,11 @@ class AgentCardTestChatView(APIView):
|
||||
return Response({"detail": t("ai.empty_message")}, status=400)
|
||||
history = request.data.get("history") or []
|
||||
if not isinstance(history, list):
|
||||
return Response({"detail": "history must be a list"}, status=400)
|
||||
return Response({"detail": t("ai.history_must_be_list")}, status=400)
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=message, history=history)
|
||||
except ProviderError as error:
|
||||
return Response({"detail": f"Ошибка провайдера: {error}"}, status=502)
|
||||
return Response({"detail": t("ai.provider_error", error=error)}, status=502)
|
||||
return Response(
|
||||
{
|
||||
"reply": result.text,
|
||||
@@ -272,7 +272,7 @@ class AgentCardConnectionsView(APIView):
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
integration_id = data.get("integrationId")
|
||||
if isinstance(integration_id, bool) or not isinstance(integration_id, int):
|
||||
return Response({"detail": "integrationId must be an integer"}, status=400)
|
||||
return Response({"detail": t("ai.integration_id_integer")}, status=400)
|
||||
try:
|
||||
integration, previous_channel_id = channel_services.bind_connection(
|
||||
context=request.tenant_context,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,19 +16,20 @@ from chatballs.ai.knowledge_bulk import (
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.ai.selectors import agent_for_employee
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _positive_id(value: object, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValidationError({field: "Positive integer ID required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
return value
|
||||
|
||||
|
||||
def _knowledge_ids(body: dict[str, object]) -> list[int]:
|
||||
raw_ids = body.get("knowledgeIds")
|
||||
if not isinstance(raw_ids, list):
|
||||
raise ValidationError({"knowledgeIds": "List of knowledge IDs required"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.list_of_knowledge_ids")})
|
||||
return [_positive_id(item, "knowledgeIds") for item in raw_ids]
|
||||
|
||||
|
||||
@@ -68,7 +69,7 @@ class KnowledgeBulkMoveView(_KnowledgeBulkView):
|
||||
def _attach_action(body: dict[str, object]) -> bool:
|
||||
action = str(body.get("action", "attach"))
|
||||
if action not in {"attach", "detach"}:
|
||||
raise ValidationError({"action": "Expected attach or detach"})
|
||||
raise ValidationError({"action": t("ai.expected_attach_or_detach")})
|
||||
return action == "attach"
|
||||
|
||||
|
||||
@@ -91,7 +92,7 @@ class _AgentLinkView(APIView):
|
||||
agent_id = _positive_id(request.data.get("agentId"), "agentId")
|
||||
raw_ids = request.data.get(self.id_field)
|
||||
if not isinstance(raw_ids, list):
|
||||
raise ValidationError({self.id_field: "List of IDs required"})
|
||||
raise ValidationError({self.id_field: t("ai.list_of_ids")})
|
||||
ids = [_positive_id(item, self.id_field) for item in raw_ids]
|
||||
attach = _attach_action(request.data)
|
||||
except ValidationError as error:
|
||||
@@ -103,7 +104,7 @@ class _AgentLinkView(APIView):
|
||||
capability="ai.manage",
|
||||
)
|
||||
except AIAgent.DoesNotExist:
|
||||
return Response({"detail": "Agent not found"}, status=404)
|
||||
return Response({"detail": t("ai.agent_not_found")}, status=404)
|
||||
try:
|
||||
result = self.link(request, agent, ids, attach)
|
||||
except ValidationError as error:
|
||||
@@ -171,7 +172,7 @@ class AgentCategoryKnowledgeSelectView(APIView):
|
||||
capability="ai.manage",
|
||||
)
|
||||
except AIAgent.DoesNotExist:
|
||||
return Response({"detail": "Agent not found"}, status=404)
|
||||
return Response({"detail": t("ai.agent_not_found")}, status=404)
|
||||
try:
|
||||
category_id = _positive_id(request.data.get("categoryId"), "categoryId")
|
||||
result = add_category_knowledge_to_agent(
|
||||
|
||||
@@ -14,16 +14,17 @@ from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.ai.selectors import category_tree_for_employee
|
||||
from chatballs.ai.serializers import category_payload
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _integer(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Integer required"})
|
||||
raise ValidationError({field: t("api.integer_required")})
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer required"}) from error
|
||||
raise ValidationError({field: t("api.integer_required")}) from error
|
||||
|
||||
|
||||
def _category(request: Request, category_id: int) -> KnowledgeCategory:
|
||||
@@ -42,7 +43,7 @@ def _parent(
|
||||
try:
|
||||
return _category(request, parent_id)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"parentId": "Parent category not found"}) from error
|
||||
raise ValidationError({"parentId": t("ai.parent_category_not_found")}) from error
|
||||
|
||||
|
||||
def _audit(
|
||||
@@ -110,7 +111,7 @@ class KnowledgeCategoryDetailView(APIView):
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
return Response({"detail": t("ai.category_not_found")}, status=404)
|
||||
try:
|
||||
parent = (
|
||||
_parent(request, request.data.get("parentId"), field_present=True)
|
||||
@@ -136,7 +137,7 @@ class KnowledgeCategoryDetailView(APIView):
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
return Response({"detail": t("ai.category_not_found")}, status=404)
|
||||
try:
|
||||
deleted_id = category.id
|
||||
delete_category(context=request.tenant_context, category=category)
|
||||
|
||||
@@ -2,7 +2,6 @@ import time
|
||||
|
||||
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
|
||||
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
|
||||
_breaker = CircuitBreaker()
|
||||
|
||||
|
||||
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=model,
|
||||
status=LlmInvocationStatus.BLOCKED,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
@@ -52,18 +39,7 @@ 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
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
|
||||
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
|
||||
started = time.monotonic()
|
||||
@@ -96,8 +72,6 @@ def invoke_chat(
|
||||
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),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
used_fragment_ids=used_fragment_ids or [],
|
||||
@@ -128,7 +102,6 @@ def embed_texts(
|
||||
model=model,
|
||||
prompt_tokens=tokens,
|
||||
total_tokens=tokens,
|
||||
cost_micros=pricing.cost_micros(model, tokens, 0),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
return results
|
||||
@@ -4,17 +4,18 @@ from rest_framework.request import Request
|
||||
from chatballs.ai.knowledge_services import KnowledgeInput
|
||||
from chatballs.ai.models import Knowledge
|
||||
from chatballs.ai.selectors import KnowledgeFilters
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def _positive_id(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer id required"}) from error
|
||||
raise ValidationError({field: t("api.integer_id_required")}) from error
|
||||
if parsed <= 0:
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
return parsed
|
||||
|
||||
|
||||
@@ -38,7 +39,7 @@ def knowledge_input(
|
||||
) -> KnowledgeInput:
|
||||
raw_enabled = body.get("isEnabled", current.is_enabled if current else True)
|
||||
if not isinstance(raw_enabled, bool):
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
raise ValidationError({"isEnabled": t("api.boolean_required")})
|
||||
return KnowledgeInput(
|
||||
title=str(body.get("title", current.title if current else "")),
|
||||
description=str(
|
||||
@@ -59,7 +60,7 @@ def knowledge_filters(request: Request) -> KnowledgeFilters:
|
||||
elif raw_enabled.lower() == "false":
|
||||
is_enabled = False
|
||||
else:
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
raise ValidationError({"isEnabled": t("api.boolean_required")})
|
||||
agents = tuple(
|
||||
int(value) for value in request.query_params.getlist("agent") if value.isdigit()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from chatballs.ai.models import (
|
||||
)
|
||||
from chatballs.ai.services import knowledge_for_agent_ids
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -21,11 +22,11 @@ def _normalized_knowledge_ids(knowledge_ids: Iterable[int]) -> list[int]:
|
||||
normalized: list[int] = []
|
||||
for knowledge_id in knowledge_ids:
|
||||
if isinstance(knowledge_id, bool) or not isinstance(knowledge_id, int):
|
||||
raise ValidationError({"knowledgeIds": "Knowledge IDs must be integers"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.knowledge_ids_integers")})
|
||||
if knowledge_id not in normalized:
|
||||
normalized.append(knowledge_id)
|
||||
if not normalized:
|
||||
raise ValidationError({"knowledgeIds": "At least one knowledge ID is required"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.at_least_one_knowledge_id")})
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ def _locked_knowledge(*, context: TenantContext, knowledge_ids: Iterable[int]) -
|
||||
.order_by("id")
|
||||
)
|
||||
if len(items) != len(normalized_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")})
|
||||
return items
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ def bulk_move_knowledge(
|
||||
organization_id=context.organization_id,
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryId": "Category not found"}) from error
|
||||
raise ValidationError({"categoryId": t("ai.category_not_found")}) from error
|
||||
items = _locked_knowledge(context=context, knowledge_ids=knowledge_ids)
|
||||
_require_bulk_write(context=context, items=items)
|
||||
now = timezone.now()
|
||||
@@ -87,7 +88,7 @@ def add_category_knowledge_to_agent(
|
||||
*, context: TenantContext, agent: AIAgent, category_id: int
|
||||
) -> CategorySelectionResult:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -96,7 +97,7 @@ def add_category_knowledge_to_agent(
|
||||
id=category_id,
|
||||
organization_id=context.organization_id,
|
||||
).exists():
|
||||
raise ValidationError({"categoryId": "Category not found"})
|
||||
raise ValidationError({"categoryId": t("ai.category_not_found")})
|
||||
|
||||
current_ids = set(agent.knowledge_items.values_list("id", flat=True))
|
||||
category_ids = set(
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
|
||||
from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -24,14 +25,14 @@ def _validate_category_context(
|
||||
*, context: TenantContext, category: KnowledgeCategory
|
||||
) -> None:
|
||||
if category.organization_id != context.organization_id:
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
raise ValidationError({"category": t("ai.category_other_organization")})
|
||||
|
||||
|
||||
def _validate_parent(
|
||||
*, context: TenantContext, parent: KnowledgeCategory | None
|
||||
) -> None:
|
||||
if parent is not None and parent.organization_id != context.organization_id:
|
||||
raise ValidationError({"parent": "Parent category belongs to another organization"})
|
||||
raise ValidationError({"parent": t("ai.parent_category_other_organization")})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -57,7 +58,7 @@ def rename_category(
|
||||
) -> KnowledgeCategory:
|
||||
_validate_category_context(context=context, category=category)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked = KnowledgeCategory.objects.select_for_update().get(pk=category.pk)
|
||||
locked.name = name
|
||||
locked.save(update_fields=["name"])
|
||||
@@ -75,7 +76,7 @@ def move_category(
|
||||
_validate_category_context(context=context, category=category)
|
||||
_validate_parent(context=context, parent=parent)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked_categories = {
|
||||
item.pk: item
|
||||
for item in KnowledgeCategory.objects.select_for_update().filter(
|
||||
@@ -104,7 +105,7 @@ def update_category(
|
||||
_validate_category_context(context=context, category=category)
|
||||
_validate_parent(context=context, parent=parent)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked_categories = {
|
||||
item.pk: item
|
||||
for item in KnowledgeCategory.objects.select_for_update().filter(
|
||||
|
||||
@@ -13,6 +13,7 @@ from chatballs.ai.knowledge_services import (
|
||||
update_knowledge,
|
||||
)
|
||||
from chatballs.ai.models import Knowledge, KnowledgeCategory
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -35,14 +36,14 @@ class KnowledgeImportResult:
|
||||
def _title(document: dict[str, object]) -> str:
|
||||
value = document.get("title")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _content(document: dict[str, object]) -> str:
|
||||
value = document.get("content")
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"content": "Content string is required"})
|
||||
raise ValidationError({"content": t("ai.content_required")})
|
||||
return value
|
||||
|
||||
|
||||
@@ -53,26 +54,23 @@ def _description(document: dict[str, object], *, default: str) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"description": "Description must be a string"})
|
||||
raise ValidationError({"description": t("ai.description_string")})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _category_for_path(*, context: TenantContext, raw_path: object) -> KnowledgeCategory:
|
||||
if not isinstance(raw_path, list) or not raw_path:
|
||||
raise ValidationError({"categoryPath": "Non-empty category path required"})
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_required")})
|
||||
parent_id = None
|
||||
category = None
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": "Category names must be non-empty 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": "Category path not found"}) from error
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
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
|
||||
@@ -153,7 +151,7 @@ def import_knowledge_documents(
|
||||
title = raw_title.strip() if isinstance(raw_title, str) else ""
|
||||
try:
|
||||
if not isinstance(raw_document, dict):
|
||||
raise ValidationError({"document": "Document must be an object"})
|
||||
raise ValidationError({"document": t("ai.document_object_required")})
|
||||
outcome = _import_document(context=context, document=raw_document)
|
||||
except ValidationError as error:
|
||||
result.failed.append({"title": title, "detail": _validation_detail(error)})
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
@@ -42,23 +43,23 @@ class KnowledgeCategory(TenantRelationModel):
|
||||
super().clean()
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Category name is required"})
|
||||
raise ValidationError({"name": t("ai.category_name_required")})
|
||||
persisted = None
|
||||
if self.pk is not None:
|
||||
persisted = type(self).objects.filter(pk=self.pk).values("is_system").first()
|
||||
if persisted and persisted["is_system"] and not self.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
if self.is_system and self.name != UNCATEGORIZED_CATEGORY_NAME:
|
||||
raise ValidationError({"name": "System category name is immutable"})
|
||||
raise ValidationError({"name": t("ai.system_category_name_immutable")})
|
||||
|
||||
ancestor = self.parent
|
||||
visited: set[int] = set()
|
||||
while ancestor is not None:
|
||||
if self.pk is not None and ancestor.pk == self.pk:
|
||||
raise ValidationError({"parent": "Category cycle is not allowed"})
|
||||
raise ValidationError({"parent": t("ai.category_cycle")})
|
||||
if ancestor.pk is not None:
|
||||
if ancestor.pk in visited:
|
||||
raise ValidationError({"parent": "Category cycle is not allowed"})
|
||||
raise ValidationError({"parent": t("ai.category_cycle")})
|
||||
visited.add(ancestor.pk)
|
||||
ancestor = ancestor.parent
|
||||
|
||||
@@ -68,11 +69,11 @@ class KnowledgeCategory(TenantRelationModel):
|
||||
|
||||
def delete(self, *args: object, **kwargs: object):
|
||||
if self.is_system:
|
||||
raise ValidationError({"category": "System category cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.system_category_undeletable")})
|
||||
if self.children.exists():
|
||||
raise ValidationError({"category": "Category with children cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.category_with_children")})
|
||||
if self.knowledge_items.exists():
|
||||
raise ValidationError({"category": "Category with knowledge cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.category_with_knowledge")})
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -12,6 +12,7 @@ from chatballs.ai.models import (
|
||||
KnowledgeAttachment,
|
||||
KnowledgeCategory,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
from chatballs.tenancy.storage_quota import (
|
||||
@@ -39,13 +40,13 @@ def _knowledge_category(*, context: TenantContext, category_id: int | None) -> K
|
||||
id=category_id,
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"category": "Category not found"}) from error
|
||||
raise ValidationError({"category": t("ai.category_not_found")}) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
knowledge = Knowledge.objects.create(
|
||||
organization=context.organization,
|
||||
category=_knowledge_category(context=context, category_id=data.category_id),
|
||||
@@ -63,9 +64,9 @@ def update_knowledge(
|
||||
*, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput
|
||||
) -> Knowledge:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk)
|
||||
content_changed = locked.content != data.content
|
||||
locked.title = data.title.strip()
|
||||
@@ -94,7 +95,7 @@ def update_knowledge(
|
||||
|
||||
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
attachments = list(knowledge.attachments.all())
|
||||
released_bytes = sum(attachment.size for attachment in attachments)
|
||||
for attachment in attachments:
|
||||
@@ -112,12 +113,12 @@ def add_attachment(
|
||||
*, context: TenantContext, knowledge: Knowledge, upload: UploadedFile
|
||||
) -> KnowledgeAttachment:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
original_name = (upload.name or "").strip()
|
||||
if not original_name:
|
||||
raise ValidationError({"file": "File name is required"})
|
||||
raise ValidationError({"file": t("ai.file_name_required")})
|
||||
if upload.size and upload.size > _MAX_ATTACHMENT_BYTES:
|
||||
raise ValidationError({"file": "File is too large (max 25 MB)"})
|
||||
raise ValidationError({"file": t("ai.file_too_large_25")})
|
||||
existing = knowledge.attachments.filter(original_name=original_name).first()
|
||||
existing_size = existing.size if existing is not None else 0
|
||||
data = upload.read()
|
||||
@@ -153,7 +154,7 @@ def add_attachment(
|
||||
|
||||
def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None:
|
||||
if attachment.knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"attachment": "Attachment belongs to another organization"})
|
||||
raise ValidationError({"attachment": t("ai.attachment_other_organization")})
|
||||
knowledge = attachment.knowledge
|
||||
released_bytes = attachment.size
|
||||
attachment.file.delete(save=False)
|
||||
|
||||
@@ -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,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Язык ответов агента.
|
||||
|
||||
Существующие агенты получают MIRROR — ответ на языке обращения. Для
|
||||
одноязычной установки это ничего не меняет: клиенты пишут на её языке, и
|
||||
агент отвечает так же. Для двуязычной — сразу перестаёт отвечать не на том
|
||||
языке, на котором спросили.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_remove_credential_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aiagent",
|
||||
name="answer_language",
|
||||
field=models.CharField(default="MIRROR", max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,13 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
|
||||
class AnswerLanguage(models.TextChoices):
|
||||
"""Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков."""
|
||||
|
||||
MIRROR = "MIRROR", "Как у клиента"
|
||||
ORGANIZATION = "ORGANIZATION", "Язык организации"
|
||||
|
||||
|
||||
class AIAgentStatus(models.TextChoices):
|
||||
|
||||
DRAFT = "DRAFT", "Draft"
|
||||
@@ -369,6 +376,19 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
model_params = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
|
||||
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
|
||||
# настройки. База знаний при этом остаётся одноязычной — поиск ведёт
|
||||
# семантическая ветка (ai.retrieval), а эмбеддинги кроссязычные.
|
||||
#
|
||||
# Хранится либо режим (MIRROR, ORGANIZATION), либо код языка из
|
||||
# chatballs.i18n.LANGUAGE_CODES — для тех, кому нужен ровно один язык
|
||||
# независимо от того, на каком языке пришло сообщение.
|
||||
answer_language = models.CharField(
|
||||
max_length=16,
|
||||
default=AnswerLanguage.MIRROR,
|
||||
)
|
||||
|
||||
# Инструкции из трёх частей; системный промпт собирается в этом порядке.
|
||||
|
||||
persona = models.TextField(blank=True) # кто он и что он
|
||||
@@ -397,10 +417,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)
|
||||
@@ -443,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
|
||||
|
||||
ERROR = "ERROR", "Ошибка"
|
||||
|
||||
BLOCKED = "BLOCKED", "Заблокировано лимитом"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -469,10 +483,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"])
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatMessage:
|
||||
@@ -16,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:
|
||||
@@ -48,4 +47,4 @@ class LLMProvider(abc.ABC):
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
"""Расшифровка аудио (дизайн-базлайн v2). Реализуется OpenAI-совместимыми
|
||||
адаптерами (POST /audio/transcriptions); остальные явно отказывают."""
|
||||
raise ProviderError(f"Провайдер {self.name} не поддерживает расшифровку аудио")
|
||||
raise ProviderError(t("ai.provider_no_transcription", provider=self.name))
|
||||
@@ -27,27 +27,63 @@ from chatballs.i18n import t
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
HANDOFF_TOKEN = "<<HANDOFF>>"
|
||||
HUMAN_REQUEST_WORDS = ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат")
|
||||
# Язык демо-агента определяется по письму клиента — тем же правилом, что и у
|
||||
# настоящего агента (AnswerLanguage.MIRROR). Здесь оно грубое, по алфавиту:
|
||||
# провайдер детерминированный и без сети, распознавать язык ему нечем.
|
||||
CYRILLIC = re.compile(r"[а-яё]", re.IGNORECASE)
|
||||
|
||||
# Слова, по которым видно, что клиент просит живого человека.
|
||||
HUMAN_REQUEST_WORDS = {
|
||||
"ru": ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат"),
|
||||
"en": ("human", "operator", "agent", "manager", "person", "complain", "refund", "return"),
|
||||
}
|
||||
_WORD = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
|
||||
_HEADER = re.compile(r"^\s*\[[^\]]{1,120}\]\s*")
|
||||
_STOP = {
|
||||
"и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от",
|
||||
"за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас",
|
||||
"есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день", "the",
|
||||
"ru": {
|
||||
"и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от",
|
||||
"за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас",
|
||||
"есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день",
|
||||
},
|
||||
"en": {
|
||||
"the", "and", "for", "you", "your", "with", "from", "that", "this", "are", "was", "can",
|
||||
"could", "would", "have", "has", "not", "but", "our", "what", "how", "when", "where",
|
||||
"please", "hello", "hi", "there", "want", "need", "tell", "about", "will", "its",
|
||||
},
|
||||
}
|
||||
|
||||
# Грубая морфология: смысл в том, чтобы «доставка» ≈ «доставку», а
|
||||
# «deliveries» ≈ «delivery». Настоящий стеммер тут был бы зависимостью ради
|
||||
# демо-стенда.
|
||||
_SUFFIXES = {
|
||||
"ru": (
|
||||
"ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам",
|
||||
"ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и",
|
||||
"у", "ю", "е", "о",
|
||||
),
|
||||
"en": ("ing", "ies", "ied", "ers", "es", "ed", "er", "ly", "s"),
|
||||
}
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
def _language_of(text: str) -> str:
|
||||
"""Язык письма клиента: кириллица — русский, иначе английский."""
|
||||
|
||||
return "ru" if CYRILLIC.search(text) else "en"
|
||||
|
||||
|
||||
def _tokens(text: str, language: str) -> set[str]:
|
||||
stop = _STOP[language]
|
||||
return {
|
||||
_stem(word.lower())
|
||||
for word in _WORD.findall(text)
|
||||
if len(word) > 2 and word.lower() not in _STOP
|
||||
if len(word) > 2 and word.lower() not in stop
|
||||
}
|
||||
|
||||
|
||||
def _stem(word: str) -> str:
|
||||
# Грубая морфология: обрезаем частые русские окончания, чтобы «доставка» ≈ «доставку».
|
||||
for suffix in ("ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам", "ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и", "у", "ю", "е", "о"):
|
||||
# Слово может быть на другом языке, чем письмо (знания одноязычные), поэтому
|
||||
# окончания режем по алфавиту самого слова, а не по языку диалога.
|
||||
for suffix in _SUFFIXES[_language_of(word)]:
|
||||
if len(word) > 4 and word.endswith(suffix):
|
||||
return word[: -len(suffix)]
|
||||
return word
|
||||
@@ -86,12 +122,13 @@ def _knowledge_sentences(messages: list[ChatMessage]) -> list[str]:
|
||||
def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]:
|
||||
"""Ответ по знаниям и признак передачи оператору."""
|
||||
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
language = _language_of(question)
|
||||
lowered = question.lower()
|
||||
wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS)
|
||||
query = _tokens(question)
|
||||
wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS[language])
|
||||
query = _tokens(question, language)
|
||||
scored = []
|
||||
for index, sentence in enumerate(_knowledge_sentences(messages)):
|
||||
overlap = len(query & _tokens(sentence))
|
||||
overlap = len(query & _tokens(sentence, language))
|
||||
if overlap:
|
||||
scored.append((-overlap, index, sentence))
|
||||
scored.sort()
|
||||
@@ -99,13 +136,8 @@ def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]:
|
||||
if best and not wants_human:
|
||||
return " ".join(best), False
|
||||
if best:
|
||||
return (
|
||||
" ".join(best)
|
||||
+ " Передаю диалог сотруднику — он поможет дальше."
|
||||
), True
|
||||
return (
|
||||
"Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время."
|
||||
), True
|
||||
return " ".join(best) + t("ai.demo_handover_suffix", language=language), True
|
||||
return t("ai.demo_handover", language=language), True
|
||||
|
||||
|
||||
class DemoProvider(LLMProvider):
|
||||
|
||||
@@ -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.
|
||||
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
@@ -115,21 +116,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 = {
|
||||
|
||||
@@ -141,10 +130,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)
|
||||
@@ -155,12 +140,10 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
except (KeyError, IndexError, TypeError) as error:
|
||||
|
||||
raise ProviderError(f"Unexpected provider response: {error}") from error
|
||||
raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
cost = usage.get("cost")
|
||||
|
||||
return ChatResult(
|
||||
|
||||
text=text,
|
||||
@@ -171,8 +154,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,
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -195,7 +176,7 @@ def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
|
||||
|
||||
except (KeyError, TypeError) as error:
|
||||
|
||||
raise ProviderError(f"Unexpected provider response: {error}") from error
|
||||
raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
|
||||
@@ -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]:
|
||||
@@ -66,9 +65,9 @@ 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(f"Расшифровка не удалась: HTTP {error.code} {detail}") from error
|
||||
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
||||
raise ProviderError(f"Расшифровка не удалась: {error}") from error
|
||||
raise ProviderError(t("ai.transcription_failed", error=error)) from error
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ProviderError(t("ai.empty_transcript"))
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.ai.provider.base import LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.custom import CustomProvider
|
||||
from chatballs.ai.provider.demo import DemoProvider
|
||||
from chatballs.ai.provider.openrouter import OpenRouterProvider
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
|
||||
|
||||
@@ -79,7 +80,7 @@ def _channel_integration(channel) -> Integration:
|
||||
integration = getattr(channel, "provider_integration", None)
|
||||
if integration is None or not integration.secret:
|
||||
raise IntegrationNotConfigured(
|
||||
"Агент не привязан к LLM-интеграции BYOK; выберите провайдера в настройках агента"
|
||||
t("ai.agent_without_llm_integration")
|
||||
)
|
||||
return integration
|
||||
|
||||
@@ -104,5 +105,5 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
|
||||
proxy_url=integration.config.get("proxy_url", ""),
|
||||
)
|
||||
raise IntegrationNotConfigured(
|
||||
f"Интеграция «{integration.provider}» не является LLM-провайдером BYOK"
|
||||
t("ai.integration_is_not_llm", provider=integration.provider)
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
@@ -35,15 +36,23 @@ def configure_agent_provider(
|
||||
id=integration_id,
|
||||
organization_id=context.organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
provider__in=[IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM],
|
||||
# DEMO — полноценный провайдер агента, а не заглушка настроек:
|
||||
# на нём работает демо-стенд сразу после установки, без ключей.
|
||||
# Без него любое сохранение агента демо-организации падало на
|
||||
# «Неизвестная интеграция», хотя менялись инструкции, а не провайдер.
|
||||
provider__in=[
|
||||
IntegrationProvider.OPENROUTER,
|
||||
IntegrationProvider.CUSTOM,
|
||||
IntegrationProvider.DEMO,
|
||||
],
|
||||
)
|
||||
except (Integration.DoesNotExist, TypeError, ValueError) as error:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": "Unknown OpenRouter or Custom integration"}
|
||||
{"providerIntegrationId": t("ai.unknown_provider_integration")}
|
||||
) from error
|
||||
model = str(integration.config.get("default_model", "")).strip()
|
||||
if not model:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": "Integration default model is required"}
|
||||
{"providerIntegrationId": t("ai.integration_model_required")}
|
||||
)
|
||||
return ProviderSelection(model, integration)
|
||||
@@ -5,11 +5,15 @@ from chatballs.ai.agent_knowledge import (
|
||||
runtime_portal_articles_for_agent,
|
||||
)
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import AIAgent, KnowledgeFragment
|
||||
from chatballs.ai.models import AIAgent, AnswerLanguage, KnowledgeFragment
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult
|
||||
from chatballs.ai.retrieval import KnowledgeRetriever
|
||||
from chatballs.i18n import LANGUAGES, customer_language, normalize_language
|
||||
from chatballs.support_portals.addressing import article_public_url
|
||||
|
||||
# Название языка — на нём самом: модели так однозначнее, чем «английский».
|
||||
LANGUAGE_LABELS = dict(LANGUAGES)
|
||||
|
||||
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
|
||||
# того, что написано в авторских инструкциях.
|
||||
MESSENGER_STYLE_GUARD = (
|
||||
@@ -31,6 +35,36 @@ HANDOFF_PROTOCOL = (
|
||||
)
|
||||
|
||||
|
||||
# Язык ответа. Директива стоит отдельной строкой и последней среди системных:
|
||||
# промпт написан по-русски и сам по себе тянет ответ в русский язык, а явное
|
||||
# указание это перебивает. Переводить сам промпт не нужно — его читает модель,
|
||||
# а не человек.
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE = (
|
||||
"Отвечай на том языке, на котором написано последнее сообщение клиента. "
|
||||
"Если язык определить не удалось, отвечай на языке предыдущей переписки."
|
||||
)
|
||||
ANSWER_IN_FIXED_LANGUAGE = (
|
||||
"Отвечай всегда на языке «{language}», независимо от того, на каком языке "
|
||||
"написал клиент."
|
||||
)
|
||||
|
||||
|
||||
def answer_language_directive(agent: AIAgent) -> str:
|
||||
"""Строка системного промпта, задающая язык ответа агента."""
|
||||
|
||||
if agent.answer_language == AnswerLanguage.MIRROR:
|
||||
return ANSWER_IN_CUSTOMER_LANGUAGE
|
||||
if agent.answer_language == AnswerLanguage.ORGANIZATION:
|
||||
code = customer_language(agent.channel.organization)
|
||||
else:
|
||||
code = normalize_language(agent.answer_language)
|
||||
if not code:
|
||||
# Код испортили руками или язык убрали из сборки: зеркало клиента
|
||||
# безопаснее молчания — ответ всё равно попадёт в язык обращения.
|
||||
return ANSWER_IN_CUSTOMER_LANGUAGE
|
||||
return ANSWER_IN_FIXED_LANGUAGE.format(language=LANGUAGE_LABELS[code])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTurnResult:
|
||||
result: ChatResult
|
||||
@@ -102,6 +136,7 @@ def run_agent_turn(
|
||||
messages.append(
|
||||
ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL)
|
||||
)
|
||||
messages.append(ChatMessage(role="system", content=answer_language_directive(agent)))
|
||||
catalog = knowledge_catalog(agent)
|
||||
if catalog:
|
||||
messages.append(ChatMessage(role="system", content=catalog))
|
||||
|
||||
@@ -21,10 +21,10 @@ class AgentInput:
|
||||
provider_integration_id: int | None
|
||||
model_params: dict
|
||||
allowed_tools: list
|
||||
limits: dict
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
answer_language: str
|
||||
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
|
||||
|
||||
|
||||
@@ -38,20 +38,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]:
|
||||
@@ -72,7 +58,7 @@ def knowledge_for_agent_ids(
|
||||
.order_by("id")
|
||||
)
|
||||
if len(items) != len(requested_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown or unavailable knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.knowledge_unknown_or_unavailable")})
|
||||
return items
|
||||
|
||||
|
||||
@@ -80,16 +66,16 @@ def knowledge_for_agent_ids(
|
||||
def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
organization = context.organization
|
||||
if not data.channel_code:
|
||||
raise ValidationError({"channel": "Channel is required"})
|
||||
raise ValidationError({"channel": t("ai.channel_required")})
|
||||
try:
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
organization=organization,
|
||||
code=data.channel_code,
|
||||
)
|
||||
except Channel.DoesNotExist as error:
|
||||
raise ValidationError({"channel": "Channel not found"}) from error
|
||||
raise ValidationError({"channel": t("channels.not_found")}) from error
|
||||
if AIAgent.objects.filter(channel=channel).exists():
|
||||
raise ValidationError({"channel": "Channel already has an AI agent"})
|
||||
raise ValidationError({"channel": t("channels.agent_exists")})
|
||||
|
||||
knowledge_items = knowledge_for_agent_ids(
|
||||
context=context,
|
||||
@@ -117,7 +103,7 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
@transaction.atomic
|
||||
def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> AIAgent:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -145,10 +131,10 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
locked.provider_integration = selection.integration
|
||||
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.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
@@ -156,10 +142,10 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
"provider_integration",
|
||||
"model_params",
|
||||
"allowed_tools",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answer_language",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
@@ -173,7 +159,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
|
||||
"""Смена статуса AI без тарифных слотов (ADR-CHATBALLS-0042 §2): количество
|
||||
активных агентов не ограничено; активация требует настроенного провайдера."""
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
locked = AIAgent.objects.select_for_update().get(
|
||||
pk=agent.id, organization_id=context.organization_id
|
||||
)
|
||||
@@ -181,7 +167,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
|
||||
if locked.status == target_status:
|
||||
return locked
|
||||
if locked.status == AIAgentStatus.ARCHIVED:
|
||||
raise ValidationError({"agent": "Archived AI agent cannot change state"})
|
||||
raise ValidationError({"agent": t("ai.archived_agent_state")})
|
||||
if is_active and locked.provider_integration_id is None:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": t("ai.pick_provider_first")}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Язык ответов агента: директива системного промпта и её режимы.
|
||||
|
||||
Язык ответа задаётся не переводом промпта, а отдельной строкой: промпт читает
|
||||
модель, и переводить его незачем, — но написан он по-русски и сам по себе
|
||||
тянет ответ в русский язык. Явное указание это перебивает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.ai.models import AIAgent, AnswerLanguage
|
||||
from chatballs.ai.runtime import (
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
answer_language_directive,
|
||||
)
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
|
||||
class AnswerLanguageDirectiveTests(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="main", name="Main"
|
||||
)
|
||||
|
||||
def _agent(self, answer_language: str) -> AIAgent:
|
||||
return AIAgent(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
name="Agent",
|
||||
answer_language=answer_language,
|
||||
)
|
||||
|
||||
def test_default_is_the_language_of_the_client(self) -> None:
|
||||
self.assertEqual(AIAgent().answer_language, AnswerLanguage.MIRROR)
|
||||
self.assertEqual(
|
||||
answer_language_directive(self._agent(AnswerLanguage.MIRROR)),
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
)
|
||||
|
||||
def test_fixed_language_names_it_in_the_language_itself(self) -> None:
|
||||
# Название языка на нём самом («English», а не «английский»): модели так
|
||||
# однозначнее, и перевода названия не требуется.
|
||||
directive = answer_language_directive(self._agent("en"))
|
||||
self.assertIn("English", directive)
|
||||
self.assertNotEqual(directive, ANSWER_IN_CUSTOMER_LANGUAGE)
|
||||
|
||||
def test_organization_mode_follows_the_organization(self) -> None:
|
||||
self.organization.language = "en"
|
||||
self.organization.save(update_fields=["language"])
|
||||
directive = answer_language_directive(self._agent(AnswerLanguage.ORGANIZATION))
|
||||
self.assertIn("English", directive)
|
||||
|
||||
def test_unknown_value_falls_back_to_the_client_language(self) -> None:
|
||||
# Значение испортили руками или язык убрали из сборки: зеркало клиента
|
||||
# безопаснее молчания — ответ всё равно попадёт в язык обращения.
|
||||
self.assertEqual(
|
||||
answer_language_directive(self._agent("klingon")),
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
)
|
||||
@@ -89,3 +89,45 @@ class DemoIntegrationTests(TestCase):
|
||||
checked = run_integration_test(context=self.context, integration=integration)
|
||||
self.assertEqual(checked.status, IntegrationStatus.OK)
|
||||
self.assertIsInstance(_provider_from_integration(Integration.objects.get(pk=integration.pk)), DemoProvider)
|
||||
|
||||
|
||||
ENGLISH_KNOWLEDGE = """Отвечай только на основе этих знаний:
|
||||
Making takes 5-7 working days after payment. Delivery across the country is DPD, \
|
||||
3-5 working days. The London courier calls an hour ahead.
|
||||
Ready-made items can be returned within 14 days."""
|
||||
|
||||
|
||||
class DemoProviderLanguageTests(TestCase):
|
||||
"""Демо-агент отвечает на языке обращения — как настоящий в режиме MIRROR."""
|
||||
|
||||
def test_english_question_is_answered_from_english_knowledge(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=ENGLISH_KNOWLEDGE),
|
||||
ChatMessage(role="user", content="How long does delivery take?"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn("DPD", result.text)
|
||||
self.assertNotIn(HANDOFF_TOKEN, result.text)
|
||||
|
||||
def test_english_request_for_human_hands_off_in_english(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=ENGLISH_KNOWLEDGE),
|
||||
ChatMessage(role="user", content="I want to speak to a human"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn(HANDOFF_TOKEN, result.text)
|
||||
self.assertNotRegex(result.text, r"[А-Яа-яЁё]")
|
||||
|
||||
def test_russian_question_still_answers_in_russian(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=KNOWLEDGE),
|
||||
ChatMessage(role="user", content="Сколько идёт доставка?"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn("СДЭК", result.text)
|
||||
@@ -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())
|
||||
@@ -159,6 +159,28 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
configure_agent_provider(context=self.context, integration_id=999999)
|
||||
|
||||
def test_demo_provider_is_a_valid_agent_provider(self) -> None:
|
||||
"""Регрессия: демо-стенд нельзя было редактировать.
|
||||
|
||||
Демо ставит агентов на встроенный DEMO-провайдер, а выбор провайдера
|
||||
его не принимал. Поле providerIntegrationId уходит с каждым PATCH
|
||||
карточки, поэтому падало любое сохранение агента — даже правка
|
||||
инструкций, где провайдер не меняли.
|
||||
"""
|
||||
|
||||
demo = create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.DEMO,
|
||||
name="Демо-провайдер",
|
||||
secret="",
|
||||
config={},
|
||||
),
|
||||
)
|
||||
selection = configure_agent_provider(context=self.context, integration_id=demo.id)
|
||||
self.assertEqual(selection.integration, demo)
|
||||
self.assertEqual(selection.model, "demo")
|
||||
|
||||
def test_selection_never_writes_to_the_channel(self) -> None:
|
||||
integration = self._integration()
|
||||
selection = configure_agent_provider(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@ from chatballs.ai.selectors import (
|
||||
from chatballs.ai.serializers import attachment_payload, knowledge_payload
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -122,7 +123,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._read_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
payload = knowledge_payload(knowledge)
|
||||
created_event = (
|
||||
AuditEvent.objects.filter(
|
||||
@@ -162,7 +163,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
try:
|
||||
data = knowledge_input(request.data, current=knowledge)
|
||||
if not employee_can_write_knowledge(
|
||||
@@ -185,7 +186,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
self._audit(request, "deleted", knowledge)
|
||||
delete_knowledge(context=request.tenant_context, knowledge=knowledge)
|
||||
return Response(status=204)
|
||||
@@ -195,7 +196,7 @@ class KnowledgeImportView(_KnowledgeBaseView):
|
||||
def post(self, request: Request) -> Response:
|
||||
documents = request.data.get("documents")
|
||||
if not isinstance(documents, list) or not documents:
|
||||
return Response({"detail": "documents must be a non-empty list"}, status=400)
|
||||
return Response({"detail": t("ai.documents_non_empty")}, status=400)
|
||||
result = import_knowledge_documents(
|
||||
context=request.tenant_context,
|
||||
documents=documents,
|
||||
@@ -222,7 +223,7 @@ class KnowledgeReindexView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
reindex_knowledge(knowledge)
|
||||
self._audit(request, "reindexed", knowledge)
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
@@ -236,10 +237,10 @@ class KnowledgeAttachmentUploadView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "file is required (multipart/form-data)"}, status=400)
|
||||
return Response({"detail": t("ai.file_required")}, status=400)
|
||||
try:
|
||||
attachment = add_attachment(
|
||||
context=request.tenant_context, knowledge=knowledge, upload=upload
|
||||
@@ -255,10 +256,10 @@ class KnowledgeAttachmentDeleteView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
attachment = knowledge.attachments.filter(id=attachment_id).first()
|
||||
if attachment is None:
|
||||
return Response({"detail": "Attachment not found"}, status=404)
|
||||
return Response({"detail": t("ai.attachment_not_found")}, status=404)
|
||||
delete_attachment(context=request.tenant_context, attachment=attachment)
|
||||
self._audit(request, "attachment_deleted", knowledge)
|
||||
return Response(status=204)
|
||||
@@ -43,9 +43,9 @@ def _bounded_int(params, name: str, default: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(f"{name}: ожидается целое число") from None
|
||||
raise ValidationError(t("api.expected_integer", name=name)) from None
|
||||
if value < 1:
|
||||
raise ValidationError(f"{name}: ожидается число больше нуля")
|
||||
raise ValidationError(t("api.expected_positive", name=name))
|
||||
return min(value, maximum)
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ def cursor_id(params, name: str = "cursor") -> int | None:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(f"{name}: ожидается идентификатор записи") from None
|
||||
raise ValidationError(t("api.expected_record_id", name=name)) from None
|
||||
if value < 1:
|
||||
raise ValidationError(f"{name}: ожидается идентификатор записи")
|
||||
raise ValidationError(t("api.expected_record_id", name=name))
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from chatballs.calls.services import CALL_INVITE_SEND
|
||||
from chatballs.calls.tokens import issue_invite_token
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -49,8 +50,11 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non
|
||||
token, token_hash = issue_invite_token()
|
||||
invite.token_hash = token_hash
|
||||
invite.save(update_fields=["token_hash"])
|
||||
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
|
||||
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
|
||||
# Приглашение читает клиент, а не оператор, и рождается оно в воркере,
|
||||
# где запроса нет: язык берём у организации.
|
||||
language = customer_language(call.conversation.organization)
|
||||
call_label = t("calls.kind_audio" if call.kind == "AUDIO" else "calls.kind_video", language=language)
|
||||
invite_text = t("calls.invite_text", kind=call_label, language=language)
|
||||
url = f"{public_base_url()}/calls/{token}?kind={call.kind}"
|
||||
sent = transports.send_call_invite(
|
||||
call.delivery_connection,
|
||||
|
||||
@@ -105,7 +105,7 @@ def transition_call(
|
||||
)
|
||||
normalized_failure_code = failure_code.strip()
|
||||
if target_status == CallStatus.FAILED and not FAILURE_CODE_PATTERN.fullmatch(normalized_failure_code):
|
||||
raise CallInvalidTransition("Для FAILED требуется нормализованный failure_code")
|
||||
raise CallInvalidTransition(t("calls.failure_code_required"))
|
||||
|
||||
now = timezone.now()
|
||||
update_fields = ["status", "updated_at"]
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ TERMINAL_CALL_STATUSES = (
|
||||
class CallEndedBy(models.TextChoices):
|
||||
STAFF = "STAFF", "Сотрудник"
|
||||
CUSTOMER = "CUSTOMER", "Клиент"
|
||||
SYSTEM = "SYSTEM", "Система"
|
||||
SYSTEM = "SYSTEM", t("admin.actor_system")
|
||||
TIMEOUT = "TIMEOUT", "Таймаут"
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -131,7 +131,7 @@ def create_call_request(
|
||||
ensure_conversation_call_access(user=context.membership, conversation=conversation)
|
||||
if not call_allowed(conversation.connection, kind):
|
||||
raise CallAccessDenied(
|
||||
"Видеозвонки отключены для этой точки входа" if kind == CallKind.VIDEO else "Звонки отключены для этой точки входа"
|
||||
t("calls.video_off_entry_point") if kind == CallKind.VIDEO else t("calls.calls_off_entry_point")
|
||||
)
|
||||
identity = _conversation_identity(conversation)
|
||||
_check_call_creation_conflicts(conversation=conversation, initiator=initiator)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from chatballs.calls.models import (
|
||||
CallStatus,
|
||||
InviteDeliveryStatus,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.services import (
|
||||
decline_call_for_identity,
|
||||
open_call_for_identity,
|
||||
@@ -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(), [])
|
||||
@@ -33,8 +33,10 @@ def has_organization_capability(context: TenantContext, capability: str) -> bool
|
||||
|
||||
|
||||
def require_organization_manage(context: TenantContext, *, operation: str) -> None:
|
||||
"""``operation`` — ключ каталога, а не готовый текст: отказ читает человек."""
|
||||
|
||||
if not has_organization_capability(context, CHANNELS_MANAGE):
|
||||
raise PermissionDenied(f"{operation} требует channels.manage")
|
||||
raise PermissionDenied(t("channels.operation_needs_manage", operation=t(operation)))
|
||||
|
||||
|
||||
def require_channel_manage(context: TenantContext) -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from chatballs.ai.provider.base import ChatResult, ProviderError
|
||||
from chatballs.ai.runtime import run_agent_turn
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
|
||||
@@ -11,5 +12,5 @@ def run_channel_turn(*, channel, message: str, history: list[dict] | None = None
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
if agent is None or not agent.is_active:
|
||||
raise ProviderError("Channel has no active AI agent")
|
||||
raise ProviderError(t("channels.no_active_agent"))
|
||||
return run_agent_turn(agent=agent, message=message, history=history, style_guard=True).result
|
||||
@@ -47,7 +47,7 @@ class ConnectionAlreadyBound(Exception):
|
||||
def __init__(self, *, integration: Integration) -> None:
|
||||
self.integration = integration
|
||||
super().__init__(
|
||||
"Подключение уже привязано к другому каналу: перенос требует force"
|
||||
t("channels.binding_conflict")
|
||||
)
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
@@ -74,9 +74,9 @@ class ChannelUpdate:
|
||||
def _clean_name(raw: object) -> str:
|
||||
name = str(raw or "").strip()
|
||||
if not name:
|
||||
raise ValidationError({"name": "Название канала не может быть пустым"})
|
||||
raise ValidationError({"name": t("channels.name_required")})
|
||||
if len(name) > NAME_MAX_LENGTH:
|
||||
raise ValidationError({"name": "Название канала длиннее 255 символов"})
|
||||
raise ValidationError({"name": t("channels.name_too_long")})
|
||||
return name
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def _group_for_channel(
|
||||
organization_id=context.organization_id,
|
||||
)
|
||||
except EmployeeGroup.DoesNotExist as error:
|
||||
raise ValidationError({"groupId": "Unknown group"}) from error
|
||||
raise ValidationError({"groupId": t("channels.unknown_group")}) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -114,11 +114,11 @@ def update_channel(
|
||||
authorization.require_channel_manage(context)
|
||||
if update.is_active is not UNSET and update.is_active != locked.is_active:
|
||||
authorization.require_organization_manage(
|
||||
context, operation="Изменение статуса канала"
|
||||
context, operation="channels.operation_status_change"
|
||||
)
|
||||
if update.policy:
|
||||
authorization.require_organization_manage(
|
||||
context, operation="Изменение политики канала"
|
||||
context, operation="channels.operation_policy_change"
|
||||
)
|
||||
|
||||
changed: list[str] = []
|
||||
@@ -149,10 +149,10 @@ def _messenger_integration(*, context: TenantContext, integration_id: int) -> In
|
||||
id=integration_id, organization_id=context.organization_id
|
||||
)
|
||||
except Integration.DoesNotExist as error:
|
||||
raise ValidationError({"integrationId": "Подключение не найдено"}) from error
|
||||
raise ValidationError({"integrationId": t("channels.connection_not_found")}) from error
|
||||
if integration.kind != IntegrationKind.MESSENGER:
|
||||
raise ValidationError(
|
||||
{"integrationId": "LLM-провайдер не является подключением канала"}
|
||||
{"integrationId": t("channels.llm_is_not_a_connection")}
|
||||
)
|
||||
return integration
|
||||
|
||||
@@ -169,7 +169,7 @@ def bind_connection(
|
||||
authorization.require_connections_manage(context)
|
||||
if not channel.is_active:
|
||||
raise ValidationError(
|
||||
{"channelId": "Нельзя привязать подключение к архивному каналу"}
|
||||
{"channelId": t("channels.archived_channel_binding")}
|
||||
)
|
||||
integration = _messenger_integration(context=context, integration_id=integration_id)
|
||||
previous_channel_id = integration.channel_id
|
||||
@@ -189,7 +189,7 @@ def unbind_connection(
|
||||
authorization.require_connections_manage(context)
|
||||
integration = _messenger_integration(context=context, integration_id=integration_id)
|
||||
if integration.channel_id != channel.id:
|
||||
raise ValidationError({"integrationId": "Подключение не привязано к каналу"})
|
||||
raise ValidationError({"integrationId": t("channels.connection_not_bound")})
|
||||
# Диалоги не затрагиваются: Conversation.connection объявлен PROTECT.
|
||||
integration.channel = None
|
||||
integration.save(update_fields=["channel", "updated_at"])
|
||||
|
||||
@@ -39,12 +39,12 @@ INLINE_TYPES = ("image/jpeg", "image/png", "image/gif", "image/webp", "applicati
|
||||
def validate_upload(upload) -> str:
|
||||
"""Причина отказа или пустая строка."""
|
||||
if upload.size > MAX_FILE_BYTES:
|
||||
return "Файл больше 20 МБ"
|
||||
return t("conversations.attachment_too_large")
|
||||
if not upload.size:
|
||||
return "Пустой файл"
|
||||
return t("conversations.attachment_empty")
|
||||
name = safe_filename(upload.name or "")
|
||||
if name.lower().endswith(BLOCKED_SUFFIXES):
|
||||
return "Такой тип файла отправить нельзя"
|
||||
return t("conversations.attachment_type_blocked")
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,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,6 +31,7 @@ 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]:
|
||||
@@ -93,7 +95,7 @@ class ConversationContactView(ConversationViewBase):
|
||||
continue
|
||||
value = str(request.data.get(field) or "").strip()
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
@@ -152,7 +154,7 @@ class ConversationLabelsView(ConversationViewBase):
|
||||
if not isinstance(label_ids, list) or not all(
|
||||
isinstance(item, int) for item in label_ids
|
||||
):
|
||||
return Response({"detail": "labelIds must be a list of ids"}, status=400)
|
||||
return Response({"detail": t("conversations.label_ids_list")}, status=400)
|
||||
labels = list(
|
||||
ConversationLabel.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
@@ -186,7 +188,7 @@ class ConversationArchiveView(ConversationViewBase):
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
archived = request.data.get("archived")
|
||||
if not isinstance(archived, bool):
|
||||
return Response({"detail": "archived must be a boolean"}, status=400)
|
||||
return Response({"detail": t("conversations.archived_boolean")}, status=400)
|
||||
if not archived and not can_administer_access(request.tenant_context.membership):
|
||||
return Response(
|
||||
{"detail": t("conversations.restore_admin_only")}, status=403
|
||||
@@ -258,10 +260,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 +314,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 +338,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,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
)
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit_catalog import (
|
||||
audit_action_label,
|
||||
@@ -161,7 +162,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
|
||||
@@ -186,7 +187,12 @@ def client_row(contact: Contact) -> dict:
|
||||
|
||||
|
||||
def _dialog_status(conversation: Conversation) -> str:
|
||||
return {"closed": "Закрыт", "operator": "Оператор", "ai": "AI", "wait": "Ждёт оператора"}[_mode(conversation)]
|
||||
return {
|
||||
"closed": t("conversations.dialog_status_closed"),
|
||||
"operator": t("conversations.dialog_status_operator"),
|
||||
"ai": "AI",
|
||||
"wait": t("conversations.dialog_status_wait"),
|
||||
}[_mode(conversation)]
|
||||
|
||||
|
||||
def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
@@ -257,9 +263,9 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# Активность из жизненного цикла диалогов (created/closed) — реальные события.
|
||||
activity: list[dict] = []
|
||||
for conversation in conversations:
|
||||
activity.append({"type": "created", "title": f"Диалог · {conversation.channel.name}", "at": conversation.created_at.isoformat()})
|
||||
activity.append({"type": "created", "title": t("conversations.activity_started", channel=conversation.channel.name), "at": conversation.created_at.isoformat()})
|
||||
if conversation.lifecycle == LifecycleState.CLOSED:
|
||||
activity.append({"type": "closed", "title": f"Диалог закрыт · {conversation.channel.name}", "at": conversation.last_activity_at.isoformat()})
|
||||
activity.append({"type": "closed", "title": t("conversations.activity_closed", channel=conversation.channel.name), "at": conversation.last_activity_at.isoformat()})
|
||||
activity.sort(key=lambda item: item["at"], reverse=True)
|
||||
|
||||
conversation_ids = [str(conversation.id) for conversation in conversations]
|
||||
@@ -282,7 +288,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# тогда показываем код — как в журнале.
|
||||
"action": audit_action_label(event.action) or event.action,
|
||||
"object": audit_object_label(event.object_type, event.object_id),
|
||||
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else "Система",
|
||||
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"),
|
||||
"result": audit_result_label(event.result),
|
||||
}
|
||||
)
|
||||
@@ -296,7 +302,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,
|
||||
@@ -365,7 +371,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
|
||||
@@ -13,11 +13,11 @@ 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.contact_avatars import refresh_contact_avatar
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
@@ -31,6 +31,7 @@ from chatballs.conversations.models import (
|
||||
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
|
||||
@@ -177,13 +178,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 +239,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 +266,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 +300,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 +315,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 +349,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],
|
||||
@@ -354,15 +379,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
ai_input = transcribe_voice_message(channel, message)
|
||||
if (is_voice and not ai_input) or 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")},
|
||||
@@ -382,15 +407,13 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
|
||||
except (ProviderError, LimitExceeded) as error:
|
||||
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
|
||||
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
|
||||
# отвечаем клиенту понятным fallback.
|
||||
except ProviderError 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
|
||||
enter_queue(conversation)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
@@ -401,8 +424,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
|
||||
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")},
|
||||
@@ -415,7 +439,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
)
|
||||
notify_management(
|
||||
context=context,
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
@@ -437,11 +461,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
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
|
||||
enter_queue(conversation)
|
||||
else:
|
||||
conversation.expected_responder = ExpectedResponder.CUSTOMER
|
||||
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(
|
||||
@@ -452,8 +475,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"AI передал диалог · {contact.name or 'Гость'}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -4,12 +4,21 @@ from django.contrib.postgres.search import SearchVector
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
|
||||
# разделено на независимые оси; перехват оператором — атомарный.
|
||||
|
||||
|
||||
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)
|
||||
@@ -21,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="")
|
||||
@@ -165,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(
|
||||
@@ -176,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
|
||||
@@ -218,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 = [
|
||||
# Диалог всегда принадлежит контакту.
|
||||
@@ -254,7 +284,7 @@ class MessageAuthor(models.TextChoices):
|
||||
CONTACT = "CONTACT", "Клиент"
|
||||
AI = "AI", "AI"
|
||||
OPERATOR = "OPERATOR", "Оператор"
|
||||
SYSTEM = "SYSTEM", "Система"
|
||||
SYSTEM = "SYSTEM", t("admin.actor_system")
|
||||
|
||||
|
||||
class SystemEvent(models.TextChoices):
|
||||
@@ -273,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", "Клиент отклонил приглашение"
|
||||
@@ -355,6 +387,13 @@ class Message(TenantRelationModel):
|
||||
ordering = ["created_at"]
|
||||
indexes = [
|
||||
# Полнотекстовый поиск по сообщениям (поиск в списке диалогов).
|
||||
#
|
||||
# Конфигурация «russian» покрывает обе переписки, и второй индекс
|
||||
# под английский был бы тратой места: в ней asciiword отдан
|
||||
# english_stem, а word — russian_stem, поэтому английские слова
|
||||
# стеммятся английским стеммером, а русские русским. Обратное
|
||||
# неверно: «english» оставляет кириллицу без основы. Проверено
|
||||
# тестом conversations/test_search_language.py.
|
||||
GinIndex(
|
||||
SearchVector("text", config="russian"),
|
||||
name="conv_message_text_fts",
|
||||
@@ -392,3 +431,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"
|
||||
|
||||
@@ -56,7 +80,7 @@ class ClientDetailView(ConversationViewBase):
|
||||
continue
|
||||
value = str(request.data.get(field) or "").strip()
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
@@ -228,7 +229,9 @@ 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,
|
||||
@@ -257,6 +260,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,18 +13,19 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
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
|
||||
|
||||
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
|
||||
|
||||
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ClaimError(Exception):
|
||||
|
||||
@@ -44,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
|
||||
|
||||
@@ -108,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(
|
||||
|
||||
@@ -116,7 +119,11 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
|
||||
text=f"Оператор {_operator_label(operator)} перехватил диалог",
|
||||
system_event=SystemEvent.OPERATOR_TOOK,
|
||||
|
||||
system_params={"operator": operator_label(operator)},
|
||||
|
||||
text=f"Оператор {operator_label(operator)} перехватил диалог",
|
||||
|
||||
)
|
||||
|
||||
@@ -150,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,
|
||||
@@ -179,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,
|
||||
@@ -270,7 +278,9 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me
|
||||
|
||||
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
|
||||
|
||||
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
|
||||
key = "conversations.contact_request_web" if is_web else "conversations.contact_request"
|
||||
|
||||
text = t(key, language=customer_language(conversation.organization))
|
||||
|
||||
message = Message.objects.create(
|
||||
|
||||
@@ -338,6 +348,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -349,6 +361,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -384,6 +397,8 @@ def mark_conversation_as_spam(
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -395,6 +410,7 @@ def mark_conversation_as_spam(
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -402,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,8 @@ 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)
|
||||
_WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
@@ -63,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(),
|
||||
@@ -104,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")))
|
||||
@@ -126,7 +129,7 @@ def sales_overview_stats(context, period: str) -> dict:
|
||||
):
|
||||
minutes = int((now - conversation.last_activity_at).total_seconds() // 60)
|
||||
meta = conversation.channel.name
|
||||
title = (conversation.contact.name if conversation.contact_id else "") or "Гость"
|
||||
title = (conversation.contact.name if conversation.contact_id else "") or t("conversations.guest")
|
||||
problems.append(
|
||||
{
|
||||
"conversationId": conversation.id,
|
||||
|
||||
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
|
||||
class LaunchChecklistTests(TestCase):
|
||||
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 +397,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,6 +15,7 @@ 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
|
||||
@@ -359,11 +360,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")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,172 @@
|
||||
"""Очередь к оператору: с какого момента идёт ожидание и в каком порядке разбирают.
|
||||
|
||||
Регрессия, ради которой заведён 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 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.ingest.run_channel_turn"),
|
||||
mock.patch("chatballs.conversations.ingest.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 mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
return_value=mock.Mock(text="Здравствуйте!"),
|
||||
), mock.patch("chatballs.conversations.ingest.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,59 @@
|
||||
"""Поиск по переписке на обоих языках идёт одной конфигурацией — «russian».
|
||||
|
||||
Выглядит как недосмотр: англоязычная установка ищет по своим письмам русской
|
||||
конфигурацией. На деле «russian» в PostgreSQL — не «русский стеммер на всё», а
|
||||
карта по типу токена: латиница уходит в english_stem, кириллица — в
|
||||
russian_stem. То есть английские слова уже стеммятся английским стеммером.
|
||||
|
||||
Обратное неверно, и это здесь же проверяется: «english» отдаёт кириллицу
|
||||
english_stem и оставляет её без основы. Поэтому второй индекс под английский не
|
||||
нужен, а замена конфигурации на «english» была бы регрессом для русской
|
||||
переписки. Тест стоит затем, чтобы это не «починили» ещё раз.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.conversations.models import Message
|
||||
|
||||
|
||||
class SearchConfigTests(TestCase):
|
||||
def _matches(self, config: str, text: str, query: str) -> bool:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT to_tsvector(%s, %s) @@ websearch_to_tsquery(%s, %s)",
|
||||
[config, text, config, query],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def test_russian_config_stems_english_words(self) -> None:
|
||||
self.assertTrue(self._matches("russian", "the deliveries arrived", "delivery"))
|
||||
self.assertTrue(self._matches("russian", "we are shipping orders", "shipped"))
|
||||
|
||||
def test_russian_config_stems_russian_words(self) -> None:
|
||||
self.assertTrue(self._matches("russian", "заказы доставлены", "заказ"))
|
||||
|
||||
def test_english_config_leaves_russian_words_unstemmed(self) -> None:
|
||||
# Ровно причина, по которой конфигурация не переключается по языку
|
||||
# организации: на английской конфигурации «заказы» не находит «заказ».
|
||||
self.assertFalse(self._matches("english", "заказы доставлены", "заказ"))
|
||||
|
||||
def test_messages_are_indexed_with_the_same_configuration(self) -> None:
|
||||
# Конфигурация входит в выражение индекса: разойдись она с запросом —
|
||||
# поиск ушёл бы в полный скан. Читаем определение из самой базы, а не
|
||||
# из питоновского объекта: в базе лежит то, что реально применится.
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT indexdef FROM pg_indexes WHERE indexname = %s",
|
||||
["conv_message_text_fts"],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
self.assertIsNotNone(row, "индекс полнотекстового поиска не создан")
|
||||
self.assertIn("'russian'", row[0])
|
||||
self.assertEqual(
|
||||
[i.name for i in Message._meta.indexes if "fts" in i.name],
|
||||
["conv_message_text_fts"],
|
||||
"второй индекс под язык не нужен: «russian» уже покрывает латиницу",
|
||||
)
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -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,13 +61,13 @@ 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"),
|
||||
side_effect=ProviderError("provider is down"),
|
||||
),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
|
||||
):
|
||||
@@ -170,6 +168,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-ход не запускается,
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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.errors import PollFailed
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
_POLL = {
|
||||
@@ -50,7 +53,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:
|
||||
@@ -114,7 +132,7 @@ 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)
|
||||
raise ValueError("File download is not supported for this provider")
|
||||
raise ValueError(t("conversations.file_download_unsupported"))
|
||||
|
||||
|
||||
def supports_file_send(integration) -> bool:
|
||||
@@ -136,6 +154,36 @@ 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
|
||||
return None
|
||||
|
||||
|
||||
def download_voice(integration, inbound) -> tuple[bytes, str]:
|
||||
if inbound.voice_content:
|
||||
return inbound.voice_content, inbound.voice_mime or "audio/webm"
|
||||
@@ -143,7 +191,7 @@ 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)
|
||||
raise ValueError("Voice download is not supported for this provider")
|
||||
raise ValueError(t("conversations.voice_download_unsupported"))
|
||||
|
||||
|
||||
def supports_voice_send(integration) -> bool:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Пауза между попытками опроса подключения после сбоя.
|
||||
|
||||
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
|
||||
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
|
||||
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
|
||||
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
|
||||
подключение пропускается с растущей паузой, а в журнал попадают только
|
||||
изменения состояния: первый сбой, выход на максимальную паузу и
|
||||
восстановление.
|
||||
|
||||
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
|
||||
попытка всё равно нужна.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 900.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 %.0f min)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
|
||||
)
|
||||
|
||||
|
||||
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,8 @@ 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__)
|
||||
|
||||
@@ -134,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")
|
||||
@@ -161,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()
|
||||
@@ -194,7 +194,8 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments
|
||||
outgoing["From"] = _address(integration)
|
||||
outgoing["To"] = target
|
||||
subject = str(meta.get("subject", "")).strip()
|
||||
outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else (subject or "Re: Ваше обращение")
|
||||
default_subject = t("conversations.email_default_subject", language=customer_language(integration.organization))
|
||||
outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else f"Re: {subject or default_subject}"
|
||||
last_message_id = str(meta.get("last_message_id", "")).strip()
|
||||
if last_message_id:
|
||||
outgoing["In-Reply-To"] = last_message_id
|
||||
@@ -223,8 +224,10 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments
|
||||
def send_voice(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool:
|
||||
"""Голосовое оператора уходит письмом с аудио-вложением."""
|
||||
suffix = "ogg" if "ogg" in content_type else (content_type.rsplit("/", 1)[-1] or "webm").split(";")[0]
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text="Голосовое сообщение", attachments=[(content, f"voice.{suffix}", content_type)])
|
||||
text = t("conversations.voice_message", language=customer_language(integration.organization))
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, f"voice.{suffix}", content_type)])
|
||||
|
||||
|
||||
def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool:
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=caption or f"Файл: {filename}", attachments=[(content, filename, content_type)])
|
||||
text = caption or t("conversations.file_caption", name=filename, language=customer_language(integration.organization))
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, filename, content_type)])
|
||||
@@ -0,0 +1,6 @@
|
||||
class PollFailed(Exception):
|
||||
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
|
||||
|
||||
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
|
||||
записать, принимает слой пауз между попытками (``transports.backoff``).
|
||||
"""
|
||||
@@ -27,6 +27,8 @@ 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
|
||||
|
||||
@@ -107,9 +109,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),
|
||||
@@ -123,6 +126,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,
|
||||
)
|
||||
|
||||
@@ -219,15 +223,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]:
|
||||
@@ -241,10 +321,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))
|
||||
|
||||
@@ -271,11 +359,17 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text})
|
||||
|
||||
|
||||
def _caption(integration, key: str) -> str:
|
||||
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
|
||||
|
||||
return t(key, language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
# Inline-клавиатура с кнопкой request_contact (Bot API MAX/TamTam).
|
||||
keyboard = {
|
||||
"type": "inline_keyboard",
|
||||
"payload": {"buttons": [[{"type": "request_contact", "text": "Поделиться контактом"}]]},
|
||||
"payload": {"buttons": [[{"type": "request_contact", "text": _caption(integration, "conversations.button_share_contact")}]]},
|
||||
}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]})
|
||||
|
||||
@@ -284,7 +378,7 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
|
||||
# Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/<token>.
|
||||
keyboard = {
|
||||
"type": "inline_keyboard",
|
||||
"payload": {"buttons": [[{"type": "link", "text": "Перейти к звонку", "url": url}]]},
|
||||
"payload": {"buttons": [[{"type": "link", "text": _caption(integration, "conversations.button_join_call"), "url": url}]]},
|
||||
}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]})
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ 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
|
||||
|
||||
@@ -123,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 []
|
||||
@@ -152,10 +153,16 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text})
|
||||
|
||||
|
||||
def _caption(integration, key: str) -> str:
|
||||
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
|
||||
|
||||
return t(key, language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
# Reply-клавиатура с request_contact: телефон бот получает только так.
|
||||
keyboard = {
|
||||
"keyboard": [[{"text": "Поделиться контактом", "request_contact": True}]],
|
||||
"keyboard": [[{"text": _caption(integration, "conversations.button_share_contact"), "request_contact": True}]],
|
||||
"one_time_keyboard": True,
|
||||
"resize_keyboard": True,
|
||||
}
|
||||
@@ -169,10 +176,37 @@ def send_contact_ack(integration, *, chat_id: str, user_id: str, text: str) -> b
|
||||
|
||||
def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool:
|
||||
# Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/<token>.
|
||||
keyboard = {"inline_keyboard": [[{"text": "Перейти к звонку", "url": url}]]}
|
||||
keyboard = {"inline_keyboard": [[{"text": _caption(integration, "conversations.button_join_call"), "url": 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
|
||||
@@ -182,7 +216,7 @@ def download_file(integration, file_id: str) -> tuple[bytes, str]:
|
||||
)
|
||||
file_path = ((data.get("result") or {}).get("file_path") or "")
|
||||
if not data.get("ok") or not file_path:
|
||||
raise ValueError("Telegram getFile failed")
|
||||
raise ValueError(t("conversations.telegram_getfile_failed"))
|
||||
content = download_bytes(
|
||||
f"{_base(integration)}/file/bot{token}/{file_path}",
|
||||
proxy_url=_proxy(integration),
|
||||
@@ -226,7 +260,7 @@ def download_voice(integration, file_id: str) -> tuple[bytes, str]:
|
||||
)
|
||||
file_path = ((data.get("result") or {}).get("file_path") or "")
|
||||
if not data.get("ok") or not file_path:
|
||||
raise ValueError("Telegram getFile failed")
|
||||
raise ValueError(t("conversations.telegram_getfile_failed"))
|
||||
content = download_bytes(
|
||||
f"{_base(integration)}/file/bot{token}/{file_path}",
|
||||
proxy_url=_proxy(integration),
|
||||
|
||||
@@ -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,13 +89,32 @@ 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
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
||||
# Имена — по подстроке; тексты сообщений — полнотекстовым поиском
|
||||
# (russian-конфиг, GIN-индекс conv_message_text_fts).
|
||||
# (russian-конфиг, GIN-индекс conv_message_text_fts). Конфигурация
|
||||
# одна на оба языка и от языка организации не зависит: в «russian»
|
||||
# латиница отдана english_stem, а кириллица — russian_stem, так что
|
||||
# английская переписка стеммится английским стеммером. Менять её на
|
||||
# «english» нельзя — та оставит без основы русские слова.
|
||||
search = SearchQuery(query, config="russian", search_type="websearch")
|
||||
message_match = (
|
||||
Message.objects.filter(conversation=OuterRef("pk"))
|
||||
@@ -167,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"
|
||||
@@ -465,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(
|
||||
{
|
||||
|
||||
@@ -6,21 +6,26 @@ from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.calls.maintenance import expire_stale_calls
|
||||
from chatballs.conversations.escalation import sweep_waiting_conversations
|
||||
from chatballs.conversations.maintenance import close_stale_conversations
|
||||
from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, mark_retry
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import iter_organizations
|
||||
from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles
|
||||
MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs)
|
||||
CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect)
|
||||
# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом:
|
||||
# проверка дешёвая, а повтор гасится dedup-ключом уровня.
|
||||
QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -28,7 +33,7 @@ class Command(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _tenant_contexts():
|
||||
for organization in Organization.objects.order_by("id").iterator():
|
||||
for organization in iter_organizations():
|
||||
yield TenantContext.for_resource(
|
||||
organization, actor_kind=TenantActorKind.SYSTEM
|
||||
)
|
||||
@@ -38,6 +43,7 @@ class Command(BaseCommand):
|
||||
last_poll = 0.0
|
||||
last_maintenance = 0.0
|
||||
last_call_sweep = 0.0
|
||||
last_queue_sweep = 0.0
|
||||
while True:
|
||||
try:
|
||||
event = claim_next_outbox_event()
|
||||
@@ -90,8 +96,22 @@ class Command(BaseCommand):
|
||||
expire_stale_calls(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
|
||||
last_queue_sweep = now
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
sweep_waiting_conversations(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Waiting queue sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
try:
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.utils import timezone
|
||||
|
||||
from chatballs.events.context import get_correlation_id
|
||||
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
|
||||
return None
|
||||
if event.organization_id is None:
|
||||
raise ValueError("Tenant event has no organization")
|
||||
organization = Organization.objects.get(pk=event.organization_id)
|
||||
organization = load_organization(event.organization_id)
|
||||
if organization is None:
|
||||
raise ValueError("Tenant event organization does not exist")
|
||||
try:
|
||||
actor_kind = TenantActorKind(event.actor_kind)
|
||||
except ValueError as error:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Язык интерфейса: выбор языка запроса и каталог сообщений."""
|
||||
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.i18n.catalog import current_language, t, tn
|
||||
from chatballs.i18n.languages import (
|
||||
DEFAULT_LANGUAGE,
|
||||
@@ -22,6 +23,7 @@ __all__ = [
|
||||
"LANGUAGE_CODES",
|
||||
"RUSSIAN",
|
||||
"current_language",
|
||||
"customer_language",
|
||||
"first_chosen",
|
||||
"normalize_language",
|
||||
"parse_accept_language",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Язык текста, который уходит наружу — клиенту, а не сотруднику.
|
||||
|
||||
Язык запроса здесь не годится. Приглашение на звонок, просьба поделиться
|
||||
контактом, тема исходящего письма — это речь организации, обращённая к её
|
||||
клиенту, и она не должна менять язык от того, кто из операторов нажал кнопку.
|
||||
Часть такого текста вообще рождается в воркере, где запроса нет.
|
||||
|
||||
Поэтому берём язык организации, а если она его не задавала — язык установки.
|
||||
Язык самого клиента нам неизвестен: в Telegram, MAX и почте его сообщить
|
||||
нечему, а в веб-виджете он выбирается на стороне страницы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n.languages import INHERIT, first_chosen, normalize_language
|
||||
|
||||
|
||||
def customer_language(organization: object) -> str:
|
||||
"""Язык обращения организации к клиенту. Пусто — язык по умолчанию."""
|
||||
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
|
||||
organization_language = normalize_language(getattr(organization, "language", INHERIT))
|
||||
return first_chosen(organization_language, default_language())
|
||||
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user