Compare commits

...
65 Commits
Author SHA1 Message Date
Andrey bd3b9309bf 🔖 release: 1.9.1 2026-09-15 08:10:11 +03:00
AndreyandClaude Opus 5 e35133b159 🐛 fix(integrations): продукт представляется своим именем, отказ объясняется словами
Исходящие запросы уходили с умолчанием urllib — «Python-urllib/3.12». Защита перед чужим API такой запрос банит раньше, чем его увидит сам провайдер: на боевом стенде Groq через рабочий прокси отвечал 403 «error code: 1010», а с обычным User-Agent тот же запрос и тот же прокси проходят. Теперь opener представляется как «Chatballs/<версия>», свой User-Agent конкретного запроса он не перебивает.

Проверка подключения больше не показывает голый код. Причину, если провайдер её назвал, видно сразу («Invalid API Key»), HTML-страница защиты сжимается в строку, молчаливый 401/403 отправляет смотреть ключ и доступность провайдера из сети. Ответ целиком уходит в журнал.

Проверено на боевом стенде: без User-Agent — 403 «error code: 1010», с любым User-Agent — ответ самого Groq. Пять новых тестов, тесты интеграций, транспортов, провайдеров и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:07:33 +03:00
Andrey e9b64b6afc 🔖 release: 1.9.0 2026-09-15 07:33:57 +03:00
AndreyandClaude Opus 5 65f7de6bc3 ✨ feat(ai): расшифровка голосовых отдельным провайдером
Модель, которой агент отвечает, не обязана уметь речь в текст: у Anthropic и Yandex Foundation Models эндпоинта /audio/transcriptions нет вовсе, и голосовые у такого агента расшифровать было нечем. На карточке агента появился выбор «Расшифровка голосовых»: по умолчанию «Как у ответов», иначе любая другая интеграция организации — модель берётся из её поля «Модель расшифровки голосовых».

Ошибка расшифровки больше не вываливает оператору сырой ответ чужого API. В ленте — фраза о следствии и о том, где чинить: отказ в доступе отправляет к ключу и модели, отсутствующий эндпоинт — к выбору интеграции для расшифровки. Сам ответ провайдера уходит в журнал.

Проверено: пять тестов маршрутизации и текста ошибки, тесты карточки агента, голосовых и провайдеров, ruff и проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:33:11 +03:00
Andrey 290b4ccea9 🔖 release: 1.8.1 2026-09-15 07:14:10 +03:00
AndreyandClaude Opus 5 ca1b892e11 🐛 fix(calls): отбой клиента закрывает окно звонка у оператора
Клиент кладёт трубку, сервер переводит звонок в терминал, а peer connection у оператора узнаёт об этом позже — или не узнаёт вовсе. Проверка терминального статуса в аудиозвонке стояла последней, после состояния соединения, поэтому у оператора оставался экран активного разговора с кнопкой «Завершить», которой нечего было завершать: она молча выходила по той же проверке. Теперь терминальный статус решает первым (как в видеозвонке), а «Завершить» на уже завершённом звонке просто закрывает окно.

Проверено: четыре теста разбора фазы аудиозвонка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:13:59 +03:00
AndreyandClaude Opus 5 e5d3e98ce3 🎨 style: порядок импортов по конфигу репозитория
Ruff в CI читает pyproject.toml из корня и сортирует импорты; локальный прогон
из каталога backend этого правила не видел.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:56:38 +03:00
Andrey 129e4a40ec 🔖 release: 1.8.0 2026-09-15 06:51:37 +03:00
AndreyandClaude Opus 5 9b54c544af ✅ test(cli): relay больше не профиль и не требует второго адреса
Проверка «веб и TURN на разных адресах» ушла вместе с самим правилом, проверка запуска стека теперь ждёт coturn среди обычных сервисов. doctor спрашивает про relay только на поднятом стеке: его запускают и до первого деплоя. Из исключения по bind-mount убран coturn — сертификатов он больше не монтирует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:51:16 +03:00
AndreyandClaude Opus 5 e8155db4f9 🐛 fix(max): достаём сообщение, когда MAX присылает событие без тела
По журналу с боевого: на голосовое MAX присылает message_created, в котором `message` отсутствует целиком — разбирать нечего, и реплика клиента пропадала. Теперь на такое событие транспорт забирает последние сообщения тех чатов, где что-то происходило рядом с ним, и прогоняет их через обычный разбор. Повторы безвредны: входящие отсеиваются по идентификатору сообщения.

Проверено: два новых теста транспорта (с телом — лишних запросов нет, без тела — сообщение доезжает), тесты разбора и backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:41:44 +03:00
AndreyandClaude Opus 5 d9bc540bc3 ✨ feat(calls): relay работает сразу после установки, без настройки
Звонок через симметричный NAT или VPN без relay не соединяется — значит relay должен стоять из коробки, а не быть отдельным профилем с выделенным IP и сертификатом. Coturn поднимается вместе со стеком на том же адресе: порт 3478 не спорит с 80 и 443, TURN-over-TLS выключен, потому что он и требовал второго адреса.

Адреса relay и STUN считаются от адреса установки и появляются в настройках сами; вписанное владельцем по-прежнему побеждает. Deploy и doctor больше не требуют второго публичного адреса, README обоих языков переписан: вместо «опционально, выделенный IP» — какие порты открыть.

Проверено: тесты TURN (включая четыре новых на автонастройку), тесты API звонков и адреса установки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:38:47 +03:00
AndreyandClaude Opus 5 1de6239eb4 🐛 fix(chat): подсказка вкладок больше не наезжает на сами вкладки
Подсказка про «Общую очередь» и «На мне» использовала заметку ленты переписки, а у той отрицательный отступ сверху — текст затягивало на строку вкладок, и он ложился поверх названий. У подсказки списка теперь своя строка под вкладками с отступами и разделителем.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:24:25 +03:00
AndreyandClaude Opus 5 73ed0ae6cd 🐛 fix(chat): бейдж «Чат» считает только видимые живые диалоги
Число на пункте «Чат» — это очередь к оператору. Считалось оно по всей организации и вместе с архивными диалогами: сотрудник видел в бейдже то, чего нет в его списках, и до нуля счётчик не опускался. Теперь граница та же, что у счётчиков вкладок списка: видимость сотрудника и без архивных.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:47:16 +03:00
AndreyandClaude Opus 5 8d334de3f3 🐛 fix: завершение звонка, фото контактов и голосовые из MAX
Кнопка «Завершить» у оператора обязана заканчивать звонок из любой живой фазы. Отмена умела только REQUESTED/RINGING, и после того как клиент принял вызов, а соединение не установилось (обычное дело за NAT), оператор получал 409 и звонок висел. Фазу выбирает finish_call — тот же код, что и на стороне клиента; в интерфейсе кнопка больше не молчит при ошибке, а заканчивает звонок вторым путём.

Фото контакта из Telegram и MAX скачивается и хранится у нас, а отдаётся своим адресом: страница рабочего места живёт под CSP «img-src 'self'», и ссылка на CDN мессенджера до экрана не доезжала — оператор видел инициалы. Источник запоминается, поэтому фото качается один раз; у Telegram оно спрашивается отдельным запросом, которого в апдейте нет.

Голосовое из MAX с незнакомой формой вложения больше не пропадает: раньше такое сообщение уходило в никуда, теперь оператор видит его заглушкой, а в журнал попадает сам payload. В журнал же пишется причина, по которой диалог сразу уходит в очередь: у канала нет активного AI-агента.

Проверено: 83 теста звонков, 6 новых тестов фото контакта, тесты ingest, вложений и голосовых, typecheck рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:44:35 +03:00
AndreyandClaude Opus 5 733b44f186 ✨ feat: удаление диалога, дерево знаний и перетаскиваемый онбординг
«Удалить диалог» удаляет: переписку, вложения, звонки и уведомления о нём. Право только у владельца и администратора, писать в удалённый диалог некуда, лента виджета у клиента обнуляется по признаку reset в поллинге.

База знаний: категории слева сворачиваются и разворачиваются (свёрнутое помнит браузер), в подвале появился выбор «На странице» 20/50/100 — общий для всех списков компонент.

Окно «Знания» карточки агента разложено по категориям и порталам со счётчиками и «Выбрать все»; набор материалов грузится целиком, а не первой страницей.

Пилюля онбординга перетаскивается и помнит место. Голосовое в веб-чате нарисовано плеером оператора без расшифровки, рисунок волны вынесен в общий пакет. Убрана горизонтальная полоса прокрутки в меню меток.

Проверено: typecheck internal-ui и web-chat, backend-тесты диалогов, виджета и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:59:28 +03:00
Andrey 5dbc062b1e 🔖 release: 1.7.4
Автоматическое создание категорий при импорте знаний и исправление добавления меток диалога.
2026-09-14 22:37:02 +03:00
Andrey ee8367e205 🐛 fix: создавать категории при импорте и восстановить добавление меток
Создавать недостающие уровни categoryPath в транзакции документа и повторно использовать существующие категории. Разрешить импорт новых путей в предпросмотре, обновить переводы и baseline.

Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов.
2026-09-14 22:36:35 +03:00
Andrey 839a1fe815 🔖 release: 1.7.3 2026-09-14 17:34:03 +03:00
Andrey c62cb903ab 🐛 fix(gateway): подключать дополнительные сайты из постоянного тома 2026-09-14 17:34:03 +03:00
AndreyandClaude Fable 5.1 0bd76042d2 🔖 release: 1.7.2
Логотип организации в сайдбаре без обводки, крупные загрузчики системы и
портала, загрузчик портала сразу по центру, подпись «Работает на Chatballs»
в футере порталов.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:32:00 +03:00
AndreyandClaude Fable 5.1 c286ba7903 💄 fix(ui): логотип без обводки, крупные загрузчики, подпись «Работает на Chatballs»
Логотип организации в сайдбаре рисовался с рамкой и подложкой — снято,
знак стоит на прозрачном фоне.

Загрузчик портала до прихода манифеста и темы появлялся в левом верхнем
углу и только потом прыгал в центр: его стиль лежал в CSS портала, а этот
чанк грузится лениво, и первый кадр шёл без стилей. Стиль перенесён в
общий state.css, слой фиксирован на весь экран и центрирован независимо
от отступов документа. Загрузчик системы на экране входа в сессию и
загрузчик портала увеличены.

В футере всех шаблонов портала — подпись «Работает на Chatballs» со
ссылкой на chatballs.ru.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:31:50 +03:00
AndreyandClaude Opus 5 234a3e43da 📝 fix(i18n): убрать упоминания лимитов из текстов интерфейса
Тарифные лимиты сняты (ADR-CHATBALLS-0042), дневного бюджета в коде нет,
но два текста продолжали о них говорить.

Уведомление AI_STOPPED шлётся только при ошибке провайдера — подпись
«или лимитом» вводила в заблуждение. Подсказка первого шага онбординга
обещала учёт лимитов, которого не существует: расходы считает провайдер
по своему тарифу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:34:03 +03:00
AndreyandClaude Opus 5 17b0c9387f 🔖 release: 1.7.1
Ход обновления виден в интерфейсе: шаги установки, время, итог с кнопкой
перезагрузки. Перезагрузка страницы во время установки больше не теряет ход.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:26 +03:00
AndreyandClaude Opus 5 27091c0234 ✨ feat(updates): ход обновления виден в интерфейсе
Установка перезапускает сам бэкенд, который о ней и рассказывает, — и до сих
пор человек об этом не узнавал: признак «идёт установка» жил в useRef и
исчезал при перезагрузке страницы, прогресса не было вовсе (одна строка
текста), а готовый результат карточка «Обновления» не показывала совсем.
Запустив обновление, человек оставался перед экраном без единого признака
того, что что-то происходит.

Теперь ход установки — отдельный блок: шаги (запуск, файл релиза, образы,
перезапуск) с отметками пройденного, текущий шаг с пульсом, номер шага и
время с начала. Молчание бэкенда — это и есть перезапуск, так он и подписан.
Метка установки живёт в sessionStorage, поэтому перезагрузка страницы
(в том числе во время лежащих сервисов) ход не теряет, а время считается от
момента запроса, а не от перезагрузки. Когда версия встала, карточка и полоса
показывают итог с кнопкой «Обновить страницу» — и молчат, если страница уже
открыта в новой версии. Прогресс появляется по нажатию кнопки, а не по ответу
сервера. Полоса наверху молчит, пока карточка «Обновления» на экране.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:17 +03:00
AndreyandClaude Opus 5 3c01486aa3 🐛 fix(presence): isinstance через union — на этом падал ruff в CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:47:09 +03:00
Andrey 15ff9bf2c3 🔖 release: 1.7.0
Очередь к оператору с честным временем ожидания, эскалация забытых диалогов,
уведомления в браузер, присутствие операторов и визард «Начало работы».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	VERSION
2026-09-14 02:46:07 +03:00
AndreyandClaude Opus 5 5d8362e8f6 ✨ feat(queue): очередь к оператору, эскалация и уведомления по макету
Очередь помнит, с какого момента ждёт клиент (waiting_since), и «дольше всех
ждущий» больше не считается по последнему сообщению: клиент, напомнивший о
себе, уезжал в конец очереди. Постановка в очередь сведена в одно место
(conversations.queue) вместо шести копий одного правила.

Уведомление видно тем же, кому виден диалог: раньше оно фильтровалось только
правом, и оператор чужой группы получал оклик с именем клиента и куском
переписки по диалогу, который не может открыть. Получатели считаются одним
запросом вместо запроса на каждого сотрудника.

Уведомления доезжают событием по сокету, а не следующим опросом: сокет поднят
на уровень оболочки и работает на любом экране. Появились уведомления самого
браузера — без service worker и Web Push, чтобы не тащить на self-hosted
зависимость от чужого push-сервиса.

Настройка «о чём звать» стала одна на все транспорты (NotificationPreference),
набор событий приведён к макету: «новый диалог» и «клиент запросил оператора»
разделены, добавлены «назначили на меня» и «долго ждёт человека».

Забытый диалог больше не тонет в тишине: свип напоминает группе, потом всем,
потом руководству; сроки — настройка организации в новом разделе «Когда звать
на помощь». Назначение стало осмысленным актом — назначенного зовут лично, у
него есть срок, и не взял — диалог возвращается всем.

Присутствие берётся из открытого сокета и подсказывает двум местам: очереди —
что напоминать некому, и выбору ответственного — кто сейчас за рабочим местом.

Макет: design/baseline/Очередь и уведомления.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:49:52 +03:00
Andrey eeed0ed51a 📝 docs(readme): убрать упоминания бюджета и учёта расхода
Хвост рефакторинга 09fb0177: возможности больше нет, а описания её обещали.
2026-09-14 01:49:33 +03:00
AndreyandClaude Opus 5 42a468ec97 🐛 fix(portals): базовый домен порталов — адрес установки, а не localhost
Суффикс приходил из переменной окружения с умолчанием localhost, а задать её
в коробке негде: у продукта нет .env, и production-манифест её не передаёт.
На живой установке диалог создания портала предлагал help.localhost — адрес,
который не резолвится и не получает сертификат. Источник теперь тот же, что у
IPv4 для A-записи: адрес из мастера первого запуска. Переменная осталась
переопределением для контуров с собственной конфигурацией.

Пока установку открывают по IP, домена нет вовсе: hosted-адрес не собирается,
диалог говорит об этом и не даёт создать портал вместо выдачи мёртвой ссылки.
Порталы, успевшие осесть на .localhost, миграция переводит на домен установки —
такие ссылки всё равно никуда не вели.

Заодно поле адреса в диалоге: без color и background оно красилось системным
белым и светилось в тёмной теме. Скин по базлайну — 36px, текст --n-1, суффикс
на --n-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:11:06 +03:00
AndreyandClaude Opus 5 44718142b3 ✅ test(updates): запуск собственного образа обязан подменять входную точку
Ошибка прожила три релиза именно потому, что её нечем было поймать: сценарий
выглядел правильно, а Docker молча передавал имя команды аргументом в цикл
ожидания. Тест разбирает оба сценария updater'а и требует --entrypoint у
каждого запуска $SELF_IMAGE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:10:59 +03:00
Andrey 4cba376c0e 🔖 release: 1.6.2 2026-09-13 09:42:26 +03:00
AndreyandClaude Opus 5 a4c6b960a8 ✅ test(updates): запуск собственного образа обязан подменять входную точку
Ошибка прожила три релиза именно потому, что её нечем было поймать: сценарий
выглядел правильно, а Docker молча передавал имя команды аргументом в цикл
ожидания. Тест разбирает оба сценария updater'а и требует --entrypoint у
каждого запуска $SELF_IMAGE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:41:02 +03:00
AndreyandClaude Opus 5 0098e147ac 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

Обе команды теперь подменяют входную точку. Плюс страховка в цикле: если
помощник исчез, не дописав результат, установка помечается неудачной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:37:04 +03:00
AndreyandClaude Opus 5 4ab26a1bcd 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

Обе команды теперь подменяют входную точку. Плюс страховка в цикле: если
помощник исчез, не дописав результат, установка помечается неудачной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:16:19 +03:00
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 8580b480e7 💄 fix(organization): язык интерфейса селектом, одна вёрстка у обеих форм
После удаления валюты в ряду полей осталась пустая половина. Теперь ряд
занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину
карточки, стал таким же селектом, как соседнее поле. Подпись под ним
убрана вместе с ключом словаря.

Форма создания организации и «Настройки → Организация» — одна форма по
смыслу, поэтому у них теперь одна ширина колонки и одни отступы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:12:22 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»
Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 d8420e4379 🔖 release: 1.6.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:30:31 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 132c684630 🔖 release: 1.3.1
Исправления после установки на чистый хост: «Ошибка загрузки» сразу после
мастера первого запуска (кэш адреса установки в соседних процессах) и
пустое состояние «Базы знаний», прижатое к левому краю.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 cd50cce5a8 ⬆️ ci(actions): версии actions под Node.js 24
GitHub снимает Node.js 20 с раннеров и уже принудительно запускает старые
версии actions на Node.js 24, о чём предупреждает в каждом прогоне. Все семь
actions подняты до мажорных версий, чей action.yml объявляет node24:
checkout v7, setup-python v7, upload-artifact v7, build-push-action v7,
login-action v4, setup-buildx-action v4, action-gh-release v3. Входы и выходы
шагов не менялись.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:55:51 +03:00
AndreyandClaude Fable 5.1 a4ca7e9373 🔖 release: 1.3.0
Несколько организаций на одной установке: переключатель организаций,
приглашения существующих учётных записей и владельца из провижининга,
администратор установки, минимальные привилегии процессов и три тома
секретов. Миграции tenancy 0031–0033, identity 0036–0037; настройки
установки переехали на /api/v1/instance/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 16:19:01 +03:00
AndreyandClaude Fable 5.1 145c44a17d ✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все
организации человека, текущая отмечена, выбор другой пересобирает Shell по
ключу организации. Вход без организации в адресе открывает последнюю
открытую или первую по списку вместо экрана «нет доступа»; предпочтение
переехало в localStorage.

Разделы «Платформа» и «Хранилище файлов» видны только администратору
установки, relay для звонков у остальных менеджеров только на чтение;
настройки установки запрашиваются с /api/v1/instance/.

Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую
организацию, гость без учётной записи задаёт имя и пароль полями мастера
первого запуска. Ожидающие приглашения показаны в списке сотрудников
строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же
элементами, что строка сотрудника.

e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают
язык установки — сценарии перестали зависеть от языка машины; добавлены
сценарии переключателя, строки приглашения и регистрации гостя.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:52 +03:00
AndreyandClaude Fable 5.1 ac824e192c 🔒 feat(deploy): три тома секретов и разрешение upstream в nginx на каждом запросе
Том chatballs-secrets целиком монтировался во все сервисы: публичный
backend-app читал пароли платформенной роли и роли миграций, которая обходит
RLS. Теперь томов три — общий, платформенной роли и роли миграций вместе с
паролем владельца кластера — и каждый процесс монтирует только свои.
Генератор переносит файлы существующих установок: копия, побайтная сверка,
только потом удаление из общего тома; повторный запуск ничего не трогает,
пароли не меняются. doctor на работающем стеке проверяет, что backend-app
паролей не видит. Воркер получает алиас platform флагом
CHATBALLS_DB_PLATFORM_ALIAS. README перечисляет три тома в бэкапе.

nginx (dev-шлюз и production-фронтенд) резолвил имя сервиса один раз при
старте: после пересоздания контейнеров старый адрес backend-app достался
backend-platform, и туда уходил WebSocket приложения. Upstream задан
переменной с resolver на DNS Docker. ADR-CHATBALLS-0048 §3, §5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:38 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00
AndreyandClaude Fable 5.1 2e52bdaea0 👥 feat(identity): администратор установки и настройки инсталляции на /api/v1/instance/
Настройки установки — адрес, почта, TURN, хранилище файлов, язык по
умолчанию — общие для всех организаций, а редактировал их любой владелец
любой организации через путь под организацией. Теперь у учётной записи есть
глобальный признак is_instance_admin: первым его получает владелец из
мастера первого запуска, существующим установкам — те, у кого был
is_superuser. Эндпоинты переехали на /api/v1/instance/ вне tenant
middleware: читает менеджер любой организации (ради карточки relay),
меняет только администратор установки. Аудит и событие переноса файлов —
уровня установки, без организации. Передача признака — командой
set_instance_admin и полем в Django admin; последнего активного
администратора отозвать нельзя.

Сессия отдаёт isInstanceAdmin. Старые пути под организацией отвечают 404.
ADR-CHATBALLS-0046.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:36:51 +03:00
AndreyandClaude Opus 5 5bf3d9b572 🔖 release: 1.2.1
Тег на зелёном коммите: в v1.2.0 проверки CI упали на порядке импортов.
Кода релиз не меняет — правка уже в main отдельным коммитом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:36:56 +03:00
AndreyandClaude Opus 5 6d94aa1f9f 🚨 fix(lint): порядок импортов по конфигурации из корня репозитория
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:20:50 +03:00
AndreyandClaude Opus 5 0e6a3db9a6 🌐 feat(i18n): язык ответов агента, английское демо и язык в мастере
Язык ответов агента. Поле AIAgent.answer_language: «как у клиента»
(по умолчанию), «язык организации» или конкретный язык. Системный промпт при
этом не переводится — его читает модель, а не человек; язык задаёт отдельная
директива, потому что русский промпт сам по себе тянет ответ в русский язык.
База знаний остаётся одноязычной: поиск ведёт семантическая ветка, а
эмбеддинги кроссязычные.

Язык уходящего наружу текста. Приглашение на звонок, просьба поделиться
контактом, подписи кнопок мессенджеров, тема письма, приветствие виджета и
ответы бота уведомлений берут язык организации (i18n.customer_language), а не
язык запроса: клиент не должен получать письмо на языке того сотрудника, кто
нажал кнопку, и половина такого текста рождается в воркере, где запроса нет.

Английское демо. Манифесты разложены по demo_seed/data/<язык>/, набор
выбирается языком организации. Английский набор повторяет русский по составу —
тот же каст, те же тринадцать диалогов, — переводился только текст. Свои
документы знаний и каталог тканей; аватары и голосовые общие. Демо-провайдер
стал двуязычным: наборы стоп-слов и ключевых слов на оба языка, язык
определяется по письму клиента.

Выбор языка в мастере первого запуска. Переключатель меняет экран без
перезагрузки: форму уже заполняют, а пароль в хранилище браузера класть
нельзя. Выбранный язык становится языком установки, и на нём же идёт вся
установка — должность владельца, демо-данные, ошибки формы.

Попутно исправлено:

- Демо-стенд нельзя было редактировать. configure_agent_provider принимал
  только OpenRouter и Custom, а демо ставит агентов на встроенный DEMO.
  providerIntegrationId уходит с каждым PATCH карточки, поэтому падало любое
  сохранение агента — даже правка инструкций.
- Список типов уведомлений отдавал ярлык TextChoices — захардкоженную русскую
  строку. Ярлык переехал ключом в реестр TYPE_META.
- Результат «Проверить» у подключений (20 строк) и ошибки политики исходящих
  адресов уходили владельцу по-русски.
- Правило data/ в .gitignore ловило demo_seed/data: русские манифесты лежали в
  индексе с прежних времён, а новые файлы туда молча не попадали бы.

Каталог бэкенда — 519 ключей, наборы ru и en совпадают. Полный прогон
бэкенда: 759 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:12:18 +03:00
AndreyandClaude Opus 5 d7f87719de 🔖 release: 1.1.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:37:21 +03:00
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.

Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00
AndreyandClaude Fable 5.1 f91b3da27a 📄 docs(license): AGPL-3.0
Текст лицензии в LICENSE, раздел и бейдж в обоих README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:52:11 +03:00
627 changed files with 34503 additions and 8305 deletions

No files matched your search

+4 -4
View File
@@ -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"
+34 -10
View File
@@ -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
View File
@@ -20,7 +20,10 @@ test-results/
.DS_Store
# Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs
data/
# Тома docker-стенда (postgres, redis, caddy, media) — только в корне репозитория.
# Без якоря правило ловило и demo_seed/data: русские манифесты в индексе уже
# лежали, а новые файлы туда молча не попадали.
/data/
.env.bak-*
**/staticfiles/
+21 -2
View File
@@ -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 требует явного согласования до правок.
@@ -44,8 +48,23 @@ Engineering rules / обязательные практики:
- Не инлайнить сложный UI повторно. Выносить компонент, если структура повторяется или секция имеет отдельную ответственность.
- Если реализация по задаче конфликтует с этими правилами, остановиться и явно сообщить blocker вместо поставки плохой структуры.
Язык интерфейса:
- Текста, который видит человек, в коде не бывает. Строка живёт в словаре и подставляется через `t(...)`: во фронтенде это `src/i18n` приложения (`packages/ui` — свой словарь), на бэкенде `chatballs/i18n`. Русский каталог — источник ключей, английский обязан его повторить: пропущенный перевод во фронтенде ловит `tsc`, на бэкенде — тест каталога.
- Ключ пишется от смысла, а не от фразы: `settings.storage_bucket_name`, а не пересказ текста. Строку переформулируют чаще, чем переименовывают.
- Число не склоняют вручную: формы задаёт словарь, форму выбирает `tn(...)`. Дату, размер файла и разряды числа форматирует `fmt` — прибитые `"ru-RU"` и свои списки месяцев запрещены.
- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести.
- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя.
- На бэкенде `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.
+3
View File
@@ -51,6 +51,9 @@
import surfaces
}
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
import /config/sites/*.caddy
# Любой хост, которому ask-эндпоинт разрешил сертификат.
https:// {
encode gzip zstd
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+27 -17
View File
@@ -18,6 +18,7 @@
<img alt="Self-hosted" src="https://img.shields.io/badge/self--hosted-one%20command-1677ff">
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white">
<img alt="Bring your own model" src="https://img.shields.io/badge/AI-bring%20your%20own%20model-6f42c1">
<img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-blue">
</p>
---
@@ -31,11 +32,12 @@
- [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)
- [Troubleshooting](#troubleshooting)
- [License](#license)
---
@@ -43,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.
---
@@ -56,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.
@@ -99,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.
@@ -114,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)
@@ -130,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
@@ -188,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
@@ -240,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>
@@ -267,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>
@@ -309,15 +313,21 @@ 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
```
</details>
---
## License
Chatballs is distributed under the [GNU Affero General Public License v3.0](LICENSE). You may use, modify and self-host it freely. If you modify it and offer it to others as a service, you must publish your changes under the same license.
+27 -17
View File
@@ -18,6 +18,7 @@
<img alt="Self-hosted" src="https://img.shields.io/badge/self--hosted-one%20command-1677ff">
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white">
<img alt="Bring your own model" src="https://img.shields.io/badge/AI-bring%20your%20own%20model-6f42c1">
<img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-blue">
</p>
---
@@ -31,11 +32,12 @@
- [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска)
- [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе)
- [Виджет на сайте](#виджет-на-сайте)
- [Звонки через relay (опционально)](#звонки-через-relay-опционально)
- [Звонки](#звонки)
- [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально)
- [Обновление](#обновление)
- [Функции](#функции)
- [Решение проблем](#решение-проблем)
- [Лицензия](#лицензия)
---
@@ -43,7 +45,7 @@
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет.
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
---
@@ -56,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-адресу сервера, домен задаётся позже в настройках.
@@ -99,7 +101,7 @@ docker compose up -d --wait
|---|---|
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. |
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников.
@@ -114,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-сервис — его адреса вписываются в те же настройки.
### Внешнее хранилище файлов (опционально)
@@ -130,7 +133,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
### Обновление
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
```bash
docker compose pull && docker compose up -d --wait
@@ -188,7 +193,7 @@ Telegram, MAX, электронная почта и чат на сайте по
### Свой сервер и своя ИИ-модель
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову.
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель.
### Защита данных клиентов
@@ -240,8 +245,7 @@ docker compose logs gateway
1. Агент в статусе **Активен**, а не **Черновик**.
2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен.
3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус.
4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ.
5. Диалог не переведён в режим **Оператор** или **Пауза**.
4. Диалог не переведён в режим **Оператор** или **Пауза**.
</details>
<details>
@@ -267,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>
@@ -309,15 +313,21 @@ 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
```
</details>
---
## Лицензия
Chatballs распространяется по лицензии [GNU Affero General Public License v3.0](LICENSE). Продукт можно свободно использовать, менять и ставить у себя. Если вы меняете его и предоставляете другим как сервис, изменения нужно опубликовать под той же лицензией.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.9.1
+5
View File
@@ -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),
+38 -13
View File
@@ -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,6 +22,7 @@ from chatballs.channels.services import (
update_channel,
)
from chatballs.conversations.models import LifecycleState
from chatballs.i18n import normalize_language, t
from chatballs.tenancy.context import TenantContext
@@ -126,8 +127,10 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"aiStatus": agent.status,
"model": agent.model,
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
@@ -170,7 +173,7 @@ def _unique_agent_code(organization_id: int, name: str) -> str:
candidate = f"{base}-{suffix}"
if candidate not in taken:
return candidate
raise ValidationError({"name": "Не удалось подобрать уникальный код агента"})
raise ValidationError({"name": t("ai.agent_code_collision")})
def ensure_channel_agent(channel: Channel) -> AIAgent:
@@ -200,7 +203,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(
@@ -229,11 +232,12 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
"answerLanguage",
"knowledgeIds",
}
if ai_fields & set(body):
@@ -244,20 +248,26 @@ 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")})
transcription_integration_id = body.get(
"transcriptionIntegrationId", agent.transcription_integration_id
)
if transcription_integration_id is not None and not isinstance(
transcription_integration_id, int
):
raise ValidationError(
{"transcriptionIntegrationId": t("api.integer_id_required")}
)
update_agent(
context=context,
agent=agent,
@@ -265,12 +275,15 @@ def update_agent_card(
# Имя агента следует за именем карточки: сущность одна.
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_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,
),
)
@@ -280,6 +293,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 = (
@@ -295,7 +320,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)
+19 -14
View File
@@ -24,9 +24,14 @@ from chatballs.channels import services as channel_services
from chatballs.channels.models import Channel
from chatballs.channels.runtime import run_channel_turn
from chatballs.channels.selectors import channel_for_context
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
# Функция, а не константа: язык у каждого запроса свой, а константа собралась бы
# один раз при импорте — на языке, который случайно стоял в тот момент.
def agent_not_found() -> dict[str, str]:
return {"detail": t("ai.agent_not_found")}
def _validation_detail(error: ValidationError) -> str:
@@ -105,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,
@@ -130,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,
@@ -157,7 +162,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
from chatballs.ai.agent_card import ensure_channel_agent
ensure_channel_agent(channel)
@@ -167,7 +172,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
body = request.data if isinstance(request.data, dict) else {}
try:
channel = update_agent_card(
@@ -182,7 +187,7 @@ class AgentCardDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
name = channel.name
try:
delete_agent_card(context=request.tenant_context, channel=channel)
@@ -201,7 +206,7 @@ class _AgentCardStatusView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
try:
channel = set_agent_card_active(
context=request.tenant_context,
@@ -234,17 +239,17 @@ class AgentCardTestChatView(APIView):
capability="ai.view",
)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
message = str(request.data.get("message", "")).strip()
if not message:
return Response({"detail": "Пустое сообщение"}, status=400)
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,
@@ -263,11 +268,11 @@ class AgentCardConnectionsView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
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,
@@ -305,7 +310,7 @@ class AgentCardConnectionDetailView(APIView):
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
return Response(agent_not_found(), status=404)
try:
channel_services.unbind_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(
+7 -6
View File
@@ -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(
+6 -5
View File
@@ -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)
+1 -28
View File
@@ -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()
)
+7 -6
View File
@@ -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(
+12 -14
View File
@@ -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)
-32
View File
@@ -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,
),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.16 on 2026-09-15 04:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0019_drop_llm_cost_accounting'),
('integrations', '0008_encrypted_column_width'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_integration',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transcribing_agents', to='integrations.integration'),
),
]
+33 -11
View File
@@ -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"
@@ -327,7 +334,7 @@ class KnowledgeFragment(TenantRelationModel):
class AIAgent(TenantRelationModel):
tenant_relation_fields = ("channel", "provider_integration")
tenant_relation_fields = ("channel", "provider_integration", "transcription_integration")
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
@@ -351,6 +358,18 @@ class AIAgent(TenantRelationModel):
)
# Чем расшифровывать голосовые. Обычно это тот же провайдер, что и отвечает,
# но не всегда: модель, которая пишет ответы, может не уметь речь в текст
# (у Anthropic и Yandex Foundation Models аудио-эндпоинта нет вовсе).
# Пусто — расшифровка идёт к провайдеру ответов, как было.
transcription_integration = models.ForeignKey(
"integrations.Integration",
on_delete=models.PROTECT,
related_name="transcribing_agents",
null=True,
blank=True,
)
name = models.CharField(max_length=255)
status = models.CharField(
@@ -369,6 +388,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 +429,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 +471,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -469,10 +495,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)
-17
View File
@@ -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 -4
View File
@@ -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))
+52 -19
View File
@@ -22,31 +22,68 @@ from chatballs.ai.provider.base import (
LLMProvider,
ProviderError,
)
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
@@ -85,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()
@@ -98,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):
@@ -131,4 +164,4 @@ class DemoProvider(LLMProvider):
]
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
raise ProviderError("Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».")
raise ProviderError(t("ai.demo_no_transcription"))
+15 -1
View File
@@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured
from chatballs.ai.provider import routing
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.local import LocalProvider
from chatballs.i18n import t
def _test_provider() -> LLMProvider:
@@ -26,6 +27,19 @@ def get_provider(*, channel=None) -> LLMProvider:
if channel is None:
raise ProviderError(
"AI-провайдер не настроен: вызов без канала не может выбрать интеграцию"
t("ai.provider_not_configured")
)
return routing.resolve_provider(channel)
def get_transcription_provider(*, channel=None) -> LLMProvider:
"""Провайдер расшифровки голосовых.
Отличается от `get_provider` одним: агент может расшифровывать другим
провайдером, чем отвечает (chatballs.ai.provider.routing).
"""
if settings.CHATBALLS_AI_PROVIDER == "test":
return _test_provider()
if channel is None:
raise ProviderError(t("ai.provider_not_configured"))
return routing.resolve_transcription_provider(channel)
@@ -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 {}
@@ -1,5 +1,6 @@
from chatballs.ai.provider import openai_http
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
from chatballs.i18n import t
class OpenRouterProvider(LLMProvider):
@@ -21,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]:
@@ -36,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
# {"text": "..."}; ошибки транслируются в ProviderError.
#
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
# в журнал — по нему разбирают настройку.
import json
import logging
import urllib.error
import urllib.request
@@ -44,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
from chatballs.conversations.transports.base import multipart_body
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
body, body_type = multipart_body(
{"model": model},
file_field="file",
@@ -65,10 +72,25 @@ 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
logger.warning(
"Transcription rejected by %s: HTTP %s %s (model=%s)",
self.base_url,
error.code,
detail,
model,
)
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
# остальное — временный отказ, который лечится повтором.
if error.code in (401, 403):
raise ProviderError(t("ai.transcription_denied")) from error
if error.code == 404:
raise ProviderError(t("ai.transcription_unsupported")) from error
raise ProviderError(t("ai.transcription_failed")) from error
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
raise ProviderError(f"Расшифровка не удалась: {error}") from error
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
raise ProviderError(t("ai.transcription_unreachable")) from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError("Провайдер вернул пустую расшифровку")
raise ProviderError(t("ai.empty_transcript"))
return text
+26 -5
View File
@@ -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
@@ -63,10 +64,30 @@ def resolve_model(channel, *, fallback_model: str) -> str:
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
def _transcription_integration(channel) -> Integration:
"""Чем расшифровывать голосовые.
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
голосовые у такого агента расшифровать было нечем.
"""
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "transcription_integration", None) if agent else None
if integration is None or not integration.secret:
return _channel_integration(channel)
return integration
def resolve_transcription_provider(channel) -> LLMProvider:
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
return _provider_from_integration(_transcription_integration(channel))
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
integration = _channel_integration(channel)
"""Модель расшифровки из настроек той интеграции, которая расшифровывает
(поле «Модель расшифровки голосовых»); по умолчанию whisper-1."""
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
@@ -79,7 +100,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 +125,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,51 @@ 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)
def configure_agent_transcription(
*, context: TenantContext, integration_id: int | None
) -> Integration | None:
"""Интеграция, которой агент расшифровывает голосовые.
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
только, что интеграция принадлежит организации и умеет быть провайдером.
"""
if integration_id is None:
return None
try:
return Integration.objects.get(
id=integration_id,
organization_id=context.organization_id,
kind=IntegrationKind.LLM_PROVIDER,
provider__in=[
IntegrationProvider.OPENROUTER,
IntegrationProvider.CUSTOM,
IntegrationProvider.DEMO,
],
)
except (Integration.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError(
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
) from error
+36 -1
View File
@@ -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))
+23 -26
View File
@@ -9,8 +9,12 @@ from chatballs.ai.models import (
AIAgentStatus,
Knowledge,
)
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.provider_selection import (
configure_agent_provider,
configure_agent_transcription,
)
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -18,12 +22,14 @@ from chatballs.tenancy.context import TenantContext
class AgentInput:
name: str
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_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 -> выбор знаний не меняется
@@ -37,20 +43,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]:
@@ -71,7 +63,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
@@ -79,16 +71,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,
@@ -116,7 +108,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,
@@ -142,23 +134,28 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.model = selection.model if selection.integration else locked.model
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
context=context,
integration_id=data.transcription_integration_id,
)
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.limits = _normalize_limits(data.limits)
locked.persona = data.persona
locked.tone = data.tone
locked.instructions = data.instructions
locked.answer_language = data.answer_language
locked.save(
update_fields=[
"name",
"model",
"provider_integration",
"transcription_integration",
"model_params",
"allowed_tools",
"limits",
"persona",
"tone",
"instructions",
"answer_language",
"updated_at",
]
)
@@ -172,7 +169,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
)
@@ -180,10 +177,10 @@ 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": "Для запуска AI выберите провайдера организации"}
{"providerIntegrationId": t("ai.pick_provider_first")}
)
locked.status = target_status
locked.lifecycle_version += 1
@@ -274,6 +274,55 @@ class AgentCardActivationTests(AgentCardTestCase):
),
)
def test_transcription_integration_is_chosen_separately(self) -> None:
# Модель ответов не обязана уметь речь в текст: у части провайдеров
# аудио-эндпоинта нет вовсе, поэтому расшифровку можно увести к другому.
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
answering = self._byok_integration()
whisper = create_integration(
context=system_tenant_context(self.organization),
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name="Whisper",
secret="sk-whisper",
config={
"baseUrl": "https://api.groq.com/openai/v1",
"defaultModel": "any",
"transcriptionModel": "whisper-large-v3",
},
),
)
patched = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps(
{
"providerIntegrationId": answering.id,
"transcriptionIntegrationId": whisper.id,
}
),
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
self.assertEqual(
patched.json()["agent"]["transcriptionIntegrationId"], whisper.id
)
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(agent.transcription_integration_id, whisper.id)
self.assertEqual(agent.provider_integration_id, answering.id)
cleared = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps({"transcriptionIntegrationId": None}),
content_type="application/json",
)
self.assertEqual(cleared.status_code, 200)
self.assertIsNone(cleared.json()["agent"]["transcriptionIntegrationId"])
def test_activation_without_provider_integration_is_rejected(self) -> None:
# Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2);
# деактивация свободна.
@@ -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(
@@ -0,0 +1,143 @@
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
расшифровки выбирается на агенте отдельно.
"""
import json
import urllib.error
from io import BytesIO
from unittest import mock
from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_transcription_model,
resolve_transcription_provider,
)
from chatballs.ai.tests import make_channel_with_agent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
class TranscriptionRoutingTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.channel, self.agent = make_channel_with_agent(
self.organization, code="voice-agent", name="Голосовой агент"
)
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
if transcription_model:
config["transcriptionModel"] = transcription_model
return create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret="sk-key",
config=config,
),
)
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.save(update_fields=["provider_integration"])
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
def test_chosen_integration_takes_the_voice(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
answering = self._integration(
name="Ответы",
base_url="https://answers.example.test/v1",
transcription_model="answer-side-model",
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
def _provider(self) -> CustomProvider:
return CustomProvider(
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
)
def _fail_with(self, code: int, body: bytes):
error = urllib.error.HTTPError(
"https://api.example.test/v1/audio/transcriptions",
code,
"error",
{},
BytesIO(body),
)
return mock.patch(
"chatballs.integrations.proxy.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def _transcribe(self):
return self._provider().transcribe(
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
)
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
body = json.dumps(
{"error": {"message": "Subscription is not supported for service accounts"}}
).encode()
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
self._transcribe()
message = str(caught.exception)
self.assertNotIn("Subscription", message)
self.assertNotIn("403", message)
self.assertIn("ключ", message)
def test_missing_endpoint_tells_where_to_look(self) -> None:
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
self._transcribe()
self.assertIn("расшифров", str(caught.exception).lower())
-27
View File
@@ -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
+10 -9
View File
@@ -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)
+7 -5
View File
@@ -25,6 +25,8 @@ from typing import Any
from django.db.models import Q, QuerySet
from rest_framework.exceptions import ValidationError
from chatballs.i18n import t
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
DEFAULT_WINDOW_SIZE = 50
@@ -41,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)
@@ -55,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
@@ -192,7 +194,7 @@ def window(
выпадать из ленты или повторяться на границе окна.
"""
if not keys:
raise ValueError("окно требует хотя бы один ключ сортировки")
raise ValueError(t("api.window_needs_sort_key"))
ordered = queryset.order_by(*[key.ordering for key in keys])
if after is not None:
condition = _after_cursor(ordered, keys, after)
@@ -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,
+32 -3
View File
@@ -11,7 +11,8 @@ from chatballs.calls.models import (
CallStatus,
ParticipantSide,
)
from chatballs.conversations.models import Message, MessageAuthor
from chatballs.conversations.models import Message, MessageAuthor, SystemEvent
from chatballs.i18n import t
ALLOWED_TRANSITIONS = {
CallStatus.REQUESTED: {
@@ -39,6 +40,29 @@ def _format_duration(seconds: int) -> str:
return f"{seconds // 60:02d}:{seconds % 60:02d}"
# Код события и его параметры: интерфейс собирает фразу на языке читателя, а
# текст остаётся в базе запасным вариантом и читаемой записью.
_TIMELINE_EVENTS: dict[str, str] = {
CallStatus.ACCEPTED: SystemEvent.CALL_ACCEPTED,
CallStatus.DECLINED: SystemEvent.CALL_DECLINED,
CallStatus.CANCELLED: SystemEvent.CALL_CANCELLED,
CallStatus.MISSED: SystemEvent.CALL_MISSED,
CallStatus.EXPIRED: SystemEvent.CALL_EXPIRED,
CallStatus.ACTIVE: SystemEvent.CALL_STARTED,
CallStatus.ENDED: SystemEvent.CALL_ENDED,
CallStatus.FAILED: SystemEvent.CALL_FAILED,
}
def _timeline_event(call: CallSession, target_status: str) -> tuple[str, dict]:
"""Код события и параметры для строки таймлайна."""
code = _TIMELINE_EVENTS.get(target_status, "")
if code == SystemEvent.CALL_ENDED and call.duration_seconds is not None:
return code, {"duration": _format_duration(call.duration_seconds)}
return code, {}
def _timeline_text(call: CallSession, target_status: str) -> str | None:
# Системные события звонка в timeline диалога (SPEC-CHATBALLS-0013 §13).
# Вызывается только при фактической смене статуса — retry дублей не даёт.
@@ -76,10 +100,12 @@ def transition_call(
if target_status == call.status:
return call
if target_status not in ALLOWED_TRANSITIONS.get(call.status, set()):
raise CallInvalidTransition(f"Переход {call.status} -> {target_status} запрещён")
raise CallInvalidTransition(
t("calls.transition_forbidden", current=call.status, target=target_status)
)
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"]
@@ -106,9 +132,12 @@ def transition_call(
call.save(update_fields=update_fields)
timeline_text = _timeline_text(call, target_status)
if timeline_text is not None:
event, params = _timeline_event(call, target_status)
Message.objects.create(
conversation_id=call.conversation_id,
author_type=MessageAuthor.SYSTEM,
system_event=event,
system_params=params,
text=timeline_text,
)
return call
+2 -1
View File
@@ -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", "Таймаут"
+3 -2
View File
@@ -1,6 +1,7 @@
from chatballs.calls.errors import CallAccessDenied
from chatballs.calls.models import CallSession
from chatballs.conversations.models import Conversation
from chatballs.i18n import t
from chatballs.identity.models import OrganizationMembership
from chatballs.identity.policy import require_capability
from chatballs.tenancy.context import TenantContext
@@ -8,11 +9,11 @@ from chatballs.tenancy.context import TenantContext
def ensure_conversation_call_access(*, user, conversation: Conversation) -> None:
if not require_capability(user, "conversations.call", conversation):
raise CallAccessDenied("Нет доступа к звонкам")
raise CallAccessDenied(t("calls.no_access"))
from chatballs.conversations.selectors import conversation_is_visible
if not conversation_is_visible(actor=user, conversation=conversation):
raise CallAccessDenied("Диалог вне групп сотрудника")
raise CallAccessDenied(t("calls.conversation_outside_groups"))
def ensure_call_access(*, user, call_session: CallSession) -> None:
+17 -17
View File
@@ -26,10 +26,12 @@ from chatballs.calls.tokens import (
issue_call_access_token,
verify_call_access_token,
)
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.i18n import t
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)
@@ -39,15 +41,14 @@ class ResolvedInvite:
def resolve_invite(*, token: str) -> ResolvedInvite:
message = "Недействительное или истёкшее приглашение"
message = t("calls.invite_invalid")
token_hash = hash_invite_token(token)
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 = (
@@ -90,11 +91,10 @@ def _authorize_call_access(
claims = verify_call_access_token(token)
route = call_session_route(str(claims.call_session_id))
if route is None:
raise CallTokenError("Недействительный или истёкший call access token")
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
raise CallTokenError("Недействительный или истёкший call access token") from None
raise CallTokenError(t("calls.token_invalid"))
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:
@@ -102,9 +102,9 @@ def _authorize_call_access(
"conversation", "conversation__channel", "initiated_by", "organization"
).get(id=claims.call_session_id, organization=organization)
except CallSession.DoesNotExist:
raise CallTokenError("Недействительный или истёкший call access token") from None
raise CallTokenError(t("calls.token_invalid")) from None
if call.status in TERMINAL_CALL_STATUSES and not allow_terminal:
raise CallTokenError("Звонок уже завершён")
raise CallTokenError(t("calls.already_ended"))
membership = None
if claims.side == ParticipantSide.STAFF:
participant = call.participants.select_related("user").filter(
@@ -133,7 +133,7 @@ def _authorize_call_access(
or call.status not in {CallStatus.REQUESTED, CallStatus.RINGING}
)
if not valid:
raise CallTokenError("Недействительный или истёкший call access token")
raise CallTokenError(t("calls.token_invalid"))
context = (
TenantContext.for_membership(membership)
if membership is not None
@@ -170,7 +170,7 @@ def _customer_call(
allow_terminal=allow_terminal,
)
if claims.side != ParticipantSide.CUSTOMER:
raise CallTokenError("Недействительный или истёкший call access token")
raise CallTokenError(t("calls.token_invalid"))
return call, context
@@ -186,7 +186,7 @@ def accept_call_by_access_token(*, token: str) -> CallSession:
organization=context.organization,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя принять") from error
raise CallConflict(t("calls.invite_cannot_accept")) from error
def decline_call_by_access_token(*, token: str) -> CallSession:
@@ -205,7 +205,7 @@ def decline_call_by_access_token(*, token: str) -> CallSession:
organization=context.organization,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя отклонить") from error
raise CallConflict(t("calls.invite_cannot_decline")) from error
def end_call_by_access_token(*, token: str) -> CallSession:
+4 -3
View File
@@ -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)
+38 -26
View File
@@ -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,
@@ -49,9 +49,11 @@ from chatballs.conversations.models import (
LifecycleState,
Message,
MessageAuthor,
SystemEvent,
)
from chatballs.conversations.services import ClaimError, claim_locked_conversation
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.integrations.features import call_allowed
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -80,7 +82,7 @@ class CreatedCall:
def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
if conversation.contact_id is None or conversation.connection_id is None:
raise CallConflict("У диалога нет клиентской identity для звонка")
raise CallConflict(t("calls.no_client_identity"))
identities = list(
ConnectionIdentity.objects.filter(
contact_id=conversation.contact_id,
@@ -89,29 +91,29 @@ def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
.order_by("id")[:2]
)
if len(identities) != 1:
raise CallConflict("Клиентская identity звонка отсутствует или неоднозначна")
raise CallConflict(t("calls.identity_missing_or_ambiguous"))
return identities[0]
def _check_call_creation_conflicts(*, conversation: Conversation, initiator) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise CallConflict("Звонок можно запросить только в открытом диалоге")
raise CallConflict(t("calls.only_in_open_conversation"))
if CallSession.objects.filter(
conversation=conversation,
status__in=UNFINISHED_CALL_STATUSES,
).exists():
raise CallConflict("В диалоге уже есть незавершённый звонок")
raise CallConflict(t("calls.unfinished_call_exists"))
if CallSession.objects.filter(
initiated_by=initiator,
status__in=UNFINISHED_CALL_STATUSES,
).exists():
raise CallConflict("Сотрудник уже участвует в другом звонке")
raise CallConflict(t("calls.operator_busy"))
if (
conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
and conversation.assigned_operator_id != initiator.id
):
raise CallConflict("Диалог уже ведёт другой оператор")
raise CallConflict(t("calls.conversation_taken"))
@transaction.atomic
@@ -120,7 +122,7 @@ def create_call_request(
) -> CreatedCall:
initiator = context.actor_user
if initiator is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
conversation = (
Conversation.objects.select_for_update()
.select_related("channel")
@@ -129,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)
@@ -151,7 +153,7 @@ def create_call_request(
kind=kind,
)
except IntegrityError as error:
raise CallConflict("Не удалось создать второй незавершённый звонок") from error
raise CallConflict(t("calls.second_call_rejected")) from error
CallInvite.objects.create(
organization=context.organization,
@@ -181,6 +183,10 @@ def create_call_request(
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.CALL_REQUESTED,
# Вид звонка — параметр, а не часть кода: фразу собирает интерфейс, и
# «аудио» против «видео» там отдельным словом словаря.
system_params={"operator": initiator_label, "kind": call.kind},
text=f"Оператор {initiator_label} запросил {call_word}",
)
if conversation.connection.provider == IntegrationProvider.WEB:
@@ -210,13 +216,13 @@ def create_call_request(
def issue_staff_access_token(*, context: TenantContext, call_session: CallSession) -> str:
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
ensure_call_access(user=context.membership, call_session=call_session)
participant_exists = call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists()
if not participant_exists:
raise CallConflict("Сотрудник не является участником звонка")
raise CallConflict(t("calls.not_a_participant"))
if call_session.status in TERMINAL_CALL_STATUSES:
raise CallConflict("Звонок уже завершён")
raise CallConflict(t("calls.already_ended"))
return issue_call_access_token(
call_session_id=call_session.id,
side=ParticipantSide.STAFF,
@@ -225,21 +231,27 @@ 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("Для звонка требуется контекст сотрудника")
raise CallAccessDenied(t("calls.operator_context_required"))
ensure_call_access(user=context.membership, call_session=call_session)
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
raise CallConflict("Сотрудник не является участником звонка")
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("Звонок уже нельзя отменить") from error
raise CallConflict(t("calls.cannot_cancel")) from error
def active_call_for_conversation(conversation: Conversation) -> CallSession | None:
@@ -272,10 +284,10 @@ def webchat_active_call(identity: ConnectionIdentity) -> CallSession | None:
def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
call = webchat_active_call(identity)
if call is None:
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
invite = CallInvite.objects.select_for_update().get(call_session=call)
if invite.expires_at <= timezone.now():
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
if invite.opened_at is None:
invite.opened_at = timezone.now()
invite.save(update_fields=["opened_at"])
@@ -290,7 +302,7 @@ def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
call = webchat_active_call(identity)
if call is None:
raise CallTokenError("Активное приглашение не найдено")
raise CallTokenError(t("webchat.no_active_invite"))
try:
return transition_call(
call_session_id=call.id,
@@ -298,4 +310,4 @@ def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
ended_by=CallEndedBy.CUSTOMER,
)
except CallInvalidTransition as error:
raise CallConflict("Приглашение уже нельзя отклонить") from error
raise CallConflict(t("calls.invite_cannot_decline")) from error
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase):
1,
)
def test_operator_ends_call_the_customer_already_accepted(self) -> None:
# Клиент принял звонок, соединение не установилось (частый случай за
# NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться
# в запрет перехода.
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
open_call_for_identity(identity=self.identity)
transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED)
response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/")
self.assertEqual(response.status_code, 200)
created.call_session.refresh_from_db()
self.assertEqual(created.call_session.status, CallStatus.FAILED)
self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT")
self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF)
def test_non_participant_cannot_cancel(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self.client.force_authenticate(user=self.operator)
@@ -3,10 +3,11 @@ import hashlib
import hmac
import time
from django.test import SimpleTestCase, override_settings
from django.test import SimpleTestCase, TestCase, override_settings
from chatballs.calls.serializers import ice_servers_payload
from chatballs.calls.turn import turn_credentials
from chatballs.identity.instance_settings import InstanceSettings, turn_config
SECRET = "coturn-shared-secret"
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
@@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase):
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
self.assertEqual(ice_servers_payload(), [])
@override_settings(
CHATBALLS_CALL_STUN_URLS=[],
CHATBALLS_CALL_TURN_URLS=[],
CHATBALLS_CALL_TURN_SECRET=SECRET,
CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
)
class TurnDefaultsTests(TestCase):
"""Relay коробки: адреса берутся от адреса установки, без единой настройки."""
def _set_host(self, host: str) -> None:
row = InstanceSettings.load()
row.public_host = host
row.save()
def test_addresses_come_from_the_installation_address(self) -> None:
self._set_host("crm.example.com")
urls, _ttl = turn_config()
self.assertEqual(
urls,
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
def test_owner_addresses_win_over_the_defaults(self) -> None:
row = InstanceSettings.load()
row.public_host = "crm.example.com"
row.turn_urls = "turns:turn.example.net:5349?transport=tcp"
row.save()
urls, _ttl = turn_config()
self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"])
def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None:
self._set_host("crm.example.com")
servers = ice_servers_payload()
self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]})
self.assertEqual(
servers[1]["urls"],
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
self.assertEqual(
servers[1]["credential"], _expected_credential(servers[1]["username"])
)
def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None:
self._set_host("")
self.assertEqual(ice_servers_payload(), [])
+2 -1
View File
@@ -14,6 +14,7 @@ from django.conf import settings
from chatballs.calls.errors import CallTokenError
from chatballs.calls.models import ParticipantSide
from chatballs.i18n import t
ACCESS_TOKEN_VERSION = 1
ACCESS_TOKEN_PURPOSE = "call-access"
@@ -82,7 +83,7 @@ def issue_call_access_token(*, call_session_id: uuid.UUID, side: str, subject_id
def verify_call_access_token(token: str) -> CallAccessClaims:
message = "Недействительный или истёкший call access token"
message = t("calls.token_invalid")
if not token or "." not in token:
raise CallTokenError(message)
encoded, signature = token.rsplit(".", 1)
+13 -12
View File
@@ -25,6 +25,7 @@ from chatballs.calls.services import (
resolve_invite,
)
from chatballs.conversations.models import Conversation
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
@@ -49,16 +50,16 @@ class CallCreateView(APIView):
def post(self, request: Request, conversation_id: int) -> Response:
if "kind" not in request.data:
return Response({"detail": "Укажите тип звонка"}, status=400)
return Response({"detail": t("calls.kind_required")}, status=400)
kind = str(request.data.get("kind", ""))
if kind not in CallKind.values:
return Response({"detail": "Недопустимый тип звонка"}, status=400)
return Response({"detail": t("calls.kind_invalid")}, status=400)
try:
created = create_call_request(
context=request.tenant_context, conversation_id=conversation_id, kind=kind
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -95,7 +96,7 @@ class CallDetailView(APIView):
id=call_session_id, organization=request.tenant_context.organization
)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
try:
ensure_call_access(user=request.tenant_context.membership, call_session=call)
except CallAccessDenied as error:
@@ -113,7 +114,7 @@ class StaffAccessTokenView(APIView):
)
token = issue_staff_access_token(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -131,7 +132,7 @@ class CallCancelView(APIView):
)
cancel_call(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
return Response({"detail": t("calls.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
@@ -163,7 +164,7 @@ class ConversationActiveCallView(APIView):
user=request.tenant_context.membership, conversation=conversation
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
call = active_call_for_conversation(conversation)
@@ -181,7 +182,7 @@ class InviteResolveView(APIView):
try:
resolved = resolve_invite(token=token)
except CallTokenError:
return Response({"detail": "Недействительное или истёкшее приглашение"}, status=404)
return Response({"detail": t("calls.invite_invalid")}, status=404)
return _token_response(
{
"call": public_invite_payload(
@@ -214,7 +215,7 @@ class CallAccessStateView(_CallAccessView):
try:
call = call_state_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
return _token_response(
{"call": public_call_state_payload(call), "iceServers": ice_servers_payload()}
)
@@ -225,7 +226,7 @@ class CallAccessAcceptView(_CallAccessView):
try:
call = accept_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return _token_response({"call": public_call_state_payload(call)})
@@ -236,7 +237,7 @@ class CallAccessDeclineView(_CallAccessView):
try:
call = decline_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return _token_response({"call": public_call_state_payload(call)})
@@ -247,5 +248,5 @@ class CallAccessEndView(_CallAccessView):
try:
call = end_call_by_access_token(token=_bearer_token(request))
except CallTokenError:
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
return Response({"detail": t("calls.token_invalid")}, status=404)
return _token_response({"call": public_call_state_payload(call)})
@@ -9,6 +9,7 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
from chatballs.i18n import t
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.context import TenantContext
@@ -32,15 +33,17 @@ 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:
if not has_organization_capability(context, CHANNELS_MANAGE):
raise PermissionDenied("Нет прав на изменение канала")
raise PermissionDenied(t("channels.no_rights_to_change"))
def require_connections_manage(context: TenantContext) -> None:
if not has_organization_capability(context, INTEGRATIONS_MANAGE):
raise PermissionDenied("Привязка подключения требует integrations.manage")
raise PermissionDenied(t("channels.binding_needs_manage"))
+2 -1
View File
@@ -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
+12 -11
View File
@@ -10,6 +10,7 @@ from django.db import transaction
from chatballs.channels import authorization
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.identity.group_models import EmployeeGroup
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.tenancy.context import TenantContext
@@ -34,7 +35,7 @@ class ChannelHasReferences(Exception):
def __init__(self, blockers: list[dict[str, Any]]) -> None:
self.blockers = blockers
super().__init__("Канал нельзя удалить: есть связанные записи")
super().__init__(t("channels.cannot_delete_linked"))
def payload(self) -> dict[str, Any]:
return {"detail": str(self), "blockers": self.blockers}
@@ -46,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]:
@@ -73,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
@@ -90,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
@@ -113,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] = []
@@ -148,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
@@ -168,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
@@ -188,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"])
@@ -28,6 +28,7 @@ from chatballs.conversations.serializers import message_payload
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.conversations.voice_views import _visible_message
from chatballs.i18n import t
MAX_FILE_BYTES = 20 * 1024 * 1024
# Исполняемые и скриптовые типы в чат не отправляем ни в одну сторону.
@@ -38,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 ""
@@ -62,9 +63,9 @@ class MessageAttachmentView(ConversationViewBase):
try:
message = _visible_message(request, message_id)
except (Message.DoesNotExist, Conversation.DoesNotExist):
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
if message.kind != MessageKind.FILE or not message.attachment:
return Response({"detail": "Файл недоступен"}, status=404)
return Response({"detail": t("conversations.file_unavailable")}, status=404)
return attachment_response(message, inline="inline" in request.GET)
@@ -80,15 +81,15 @@ class ConversationAttachmentView(ConversationViewBase):
try:
conversation = self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
if conversation.lifecycle != LifecycleState.OPEN:
return Response({"detail": "Диалог закрыт"}, status=409)
return Response({"detail": t("conversations.closed")}, status=409)
connection = conversation.connection
if connection is None or not transports.supports_file_send(connection):
return Response({"detail": "Файлы недоступны в этом канале"}, status=400)
return Response({"detail": t("conversations.files_unavailable_channel")}, status=400)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "Прикрепите файл"}, status=400)
return Response({"detail": t("conversations.attach_file")}, status=400)
problem = validate_upload(upload)
if problem:
return Response({"detail": problem}, status=400)
@@ -113,7 +114,7 @@ class ConversationAttachmentView(ConversationViewBase):
caption=caption,
)
if not sent:
return Response({"detail": "Не удалось отправить файл в канал"}, status=502)
return Response({"detail": t("conversations.file_send_failed")}, status=502)
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.OPERATOR,
@@ -22,13 +22,16 @@ 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
from chatballs.i18n import t
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]:
@@ -53,10 +56,10 @@ class ConversationPriorityView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
priority = str(request.data.get("priority", "")).strip().upper()
if priority not in ConversationPriority.values:
return Response({"detail": "Неизвестный приоритет"}, status=400)
return Response({"detail": t("conversations.unknown_priority")}, status=400)
conversation.priority = priority
conversation.save(update_fields=["priority"])
self._audit(request, "priority_changed", conversation)
@@ -82,19 +85,19 @@ class ConversationContactView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
contact = conversation.contact
if contact is None:
return Response({"detail": "У диалога нет контакта"}, status=400)
return Response({"detail": t("conversations.no_contact")}, status=400)
changed: list[str] = []
for field, limit in self.LIMITS.items():
if field not in request.data:
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": "Имя контакта не может быть пустым"}, status=400)
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
setattr(contact, field, value)
changed.append(field)
if changed:
@@ -118,10 +121,10 @@ class ConversationNoteView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
note = str(request.data.get("note", ""))
if len(note) > 4000:
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
return Response({"detail": t("conversations.note_too_long")}, status=400)
conversation.note = note
conversation.note_author = request.user if note else None
conversation.note_updated_at = timezone.now() if note else None
@@ -146,12 +149,12 @@ class ConversationLabelsView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
return Response({"detail": t("conversations.not_found")}, status=404)
label_ids = request.data.get("labelIds")
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,
@@ -159,7 +162,7 @@ class ConversationLabelsView(ConversationViewBase):
)
)
if len(labels) != len(set(label_ids)):
return Response({"detail": "Неизвестная метка"}, status=400)
return Response({"detail": t("conversations.unknown_label")}, status=400)
conversation.labels.set(labels)
return Response(
{
@@ -182,13 +185,13 @@ class ConversationArchiveView(ConversationViewBase):
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
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": "Восстановить диалог может только администратор"}, status=403
{"detail": t("conversations.restore_admin_only")}, status=403
)
conversation.archived_at = timezone.now() if archived else None
conversation.save(update_fields=["archived_at"])
@@ -257,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,
@@ -300,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],
@@ -308,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,
}
@@ -336,9 +373,9 @@ class LabelListView(APIView):
name = str(request.data.get("name", "")).strip()
color = str(request.data.get("color", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
return Response({"detail": t("conversations.label_name_length")}, status=400)
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
existing = ConversationLabel.objects.filter(
organization_id=request.tenant_context.organization_id, name__iexact=name
).first()
@@ -365,16 +402,16 @@ class LabelDetailView(APIView):
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
return Response({"detail": t("conversations.label_not_found")}, status=404)
if "name" in request.data:
name = str(request.data.get("name", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
return Response({"detail": t("conversations.label_name_length")}, status=400)
label.name = name
if "color" in request.data:
color = str(request.data.get("color", "")).strip()
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
label.color = color
label.save()
return Response({"label": _label_payload(label)})
@@ -383,7 +420,7 @@ class LabelDetailView(APIView):
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
return Response({"detail": t("conversations.label_not_found")}, status=404)
label.delete()
return Response(status=204)
@@ -405,13 +442,13 @@ class ReplyTemplateListView(APIView):
title = str(request.data.get("title", "")).strip()
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
return Response({"detail": t("conversations.template_name_length")}, status=400)
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
return Response({"detail": t("conversations.template_text_required")}, status=400)
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
return Response({"detail": "Шаблон с таким названием уже есть"}, status=409)
return Response({"detail": t("conversations.template_name_taken")}, status=409)
template = ReplyTemplate.objects.create(
organization_id=request.tenant_context.organization_id,
title=title,
@@ -433,16 +470,16 @@ class ReplyTemplateDetailView(APIView):
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
return Response({"detail": t("conversations.template_not_found")}, status=404)
if "title" in request.data:
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
return Response({"detail": t("conversations.template_name_length")}, status=400)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
return Response({"detail": t("conversations.template_text_required")}, status=400)
template.text = text
template.save()
return Response({"template": _template_payload(template)})
@@ -451,6 +488,6 @@ class ReplyTemplateDetailView(APIView):
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
return Response({"detail": t("conversations.template_not_found")}, status=404)
template.delete()
return Response(status=204)
+29 -13
View File
@@ -18,6 +18,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
@@ -26,10 +27,11 @@ from chatballs.conversations.models import (
Conversation,
LifecycleState,
)
from chatballs.i18n import t
from chatballs.identity.audit_catalog import (
AUDIT_RESULT_LABELS,
audit_action_label,
audit_object_label,
audit_result_label,
)
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.identity.models import AuditEvent
@@ -154,9 +156,13 @@ def client_row(contact: Contact) -> dict:
return {
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"name": contact.name or t("conversations.guest"),
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
# на другом языке это перестало бы работать.
"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
@@ -181,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:
@@ -252,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]
@@ -277,17 +288,21 @@ 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 "Система",
"result": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"),
"result": audit_result_label(event.result),
}
)
return {
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"name": contact.name or t("conversations.guest"),
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
# на другом языке это перестало бы работать.
"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,
@@ -325,7 +340,7 @@ def _merges(organization_id: int, contact: Contact) -> list[dict]:
{
"id": row.id,
"sourceId": row.source_id,
"sourceName": row.source.name or "Гость",
"sourceName": row.source.name or t("conversations.guest"),
"sourceCid": f"CUS-{row.source_id}",
"reason": row.reason,
"actor": _actor_name(row.actor),
@@ -354,8 +369,9 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
return {
"id": other.id,
"cid": f"CUS-{other.id}",
"name": other.name or "Гость",
"avatarUrl": other.avatar_url,
"name": other.name or t("conversations.guest"),
"isGuest": not other.name,
"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)
@@ -13,6 +13,7 @@ from django.db import transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
@@ -24,7 +25,7 @@ MIN_REASON_LENGTH = 5
def _clean_reason(reason: str) -> str:
cleaned = (reason or "").strip()
if len(cleaned) < MIN_REASON_LENGTH:
raise ValidationError("Укажите причину объединения — она попадёт в журнал действий")
raise ValidationError(t("sales.merge_reason_required"))
return cleaned[:2000]
@@ -33,14 +34,14 @@ def merge_contacts(*, organization, target_id: int, source_id: int, reason: str,
"""Перенести идентичности и диалоги source в target."""
cleaned = _clean_reason(reason)
if target_id == source_id:
raise ValidationError("Нельзя объединить контакт с самим собой")
raise ValidationError(t("sales.merge_self"))
try:
target = Contact.objects.select_for_update().get(organization=organization, id=target_id)
source = Contact.objects.select_for_update().get(organization=organization, id=source_id)
except Contact.DoesNotExist as error:
raise ValidationError("Контакт не найден") from error
raise ValidationError(t("sales.contact_not_found")) from error
if source.merged_into_id is not None or target.merged_into_id is not None:
raise ValidationError("Контакт уже объединён с другим — сначала разъедините")
raise ValidationError(t("sales.already_merged"))
identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True))
conversation_ids = list(
@@ -94,9 +95,9 @@ def revert_merge(*, organization, merge_id: int, reason: str, actor, request=Non
try:
merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id)
except ContactMerge.DoesNotExist as error:
raise ValidationError("Объединение не найдено") from error
raise ValidationError(t("sales.merge_not_found")) from error
if merge.reverted_at is not None:
raise ValidationError("Это объединение уже разъединено")
raise ValidationError(t("sales.already_unmerged"))
source = merge.source
target = merge.target
@@ -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
+86 -29
View File
@@ -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,
@@ -28,10 +28,13 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
TranscriptStatus,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting
from chatballs.conversations.transports.base import InboundMessage
from chatballs.events.models import EventOwnership, InboxEvent
from chatballs.i18n import t
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.services import notify, notify_management
from chatballs.tenancy.context import TenantContext
@@ -97,7 +100,7 @@ class TranscriptionJob:
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.factory import get_transcription_provider
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
@@ -105,7 +108,7 @@ def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
if not message.audio:
return None
provider = get_provider(channel=channel)
provider = get_transcription_provider(channel=channel)
try:
model = resolve_transcription_model(channel)
except ProviderError:
@@ -175,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
@@ -222,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)
@@ -248,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:
@@ -281,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).
@@ -297,12 +315,28 @@ 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",
body_key="notifications.new_dialog_body",
text_params={
"channel": channel.name,
"contact": contact.name or t("conversations.guest"),
"provider": integration.provider,
"preview": message_text[:80],
},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -315,9 +349,12 @@ 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],
title_key="notifications.new_message",
text_params={"contact": contact.name or t("conversations.guest")},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -342,17 +379,20 @@ 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")},
body="Голосовое без расшифровки" if is_voice else message_text[:120],
body_key="notifications.voice_without_transcript" if is_voice else "",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -367,24 +407,31 @@ 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"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI недоступен — диалог передан оператору")
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_UNAVAILABLE,
text="AI недоступен — диалог передан оператору",
)
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
body="AI временно недоступен, диалог ждёт ответа",
body_key="notifications.ai_unavailable_waiting",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -392,9 +439,12 @@ 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",
body_key="notifications.ai_unavailable_handed_over",
text_params={"channel": channel.name},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
@@ -411,19 +461,26 @@ 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(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.AI_HANDED_OVER,
text="AI передал диалог оператору",
)
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")},
body=ai_input[:120],
target_id=conversation.id,
source_type="Conversation",
@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-09-09 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0020_conversation_last_message_at_and_more'),
]
operations = [
migrations.AddField(
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 передал диалог оператору'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
),
migrations.AddField(
model_name='message',
name='system_params',
field=models.JSONField(blank=True, default=dict),
),
]
@@ -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),
),
]
@@ -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),
),
]
+80 -1
View File
@@ -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,36 @@ class MessageAuthor(models.TextChoices):
CONTACT = "CONTACT", "Клиент"
AI = "AI", "AI"
OPERATOR = "OPERATOR", "Оператор"
SYSTEM = "SYSTEM", "Система"
SYSTEM = "SYSTEM", t("admin.actor_system")
class SystemEvent(models.TextChoices):
"""Код системного события диалога.
Текст события раньше писался в ``text`` по-русски и оставался таким
навсегда: история — записи, а не подписи, и перевести её задним числом
нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того,
кто её читает. ``text`` продолжает заполняться: он остаётся и запасным
вариантом для строк, записанных до этого поля, и тем, что видно в базе
глазами.
"""
OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог"
RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI"
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", "Клиент отклонил приглашение"
CALL_CANCELLED = "call_cancelled", "Приглашение отменено"
CALL_MISSED = "call_missed", "Звонок пропущен"
CALL_EXPIRED = "call_expired", "Приглашение истекло"
CALL_STARTED = "call_started", "Звонок начался"
CALL_ENDED = "call_ended", "Звонок завершён"
CALL_FAILED = "call_failed", "Звонок не состоялся"
class MessageKind(models.TextChoices):
@@ -300,6 +359,11 @@ class Message(TenantRelationModel):
# телефона), полученный контакт. Пустая строка = текст.
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
text = models.TextField(blank=True)
# Системное событие: код и его параметры (имя оператора, длительность).
# Пустой код — обычное сообщение либо системная запись, сделанная до
# появления поля; такие показываются по сохранённому тексту.
system_event = models.CharField(max_length=32, choices=SystemEvent.choices, blank=True, default="")
system_params = models.JSONField(default=dict, blank=True)
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
# ответы используют plain text.
content_html = models.TextField(blank=True)
@@ -323,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",
@@ -360,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
@@ -8,6 +9,7 @@ from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.conversations.stats import sales_overview_stats
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import EmployeeRole
@@ -20,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"
@@ -41,23 +66,23 @@ class ClientDetailView(ConversationViewBase):
try:
return Response({"client": client_detail(self._org(request).id, contact_id)})
except Contact.DoesNotExist:
return Response({"detail": "Клиент не найден"}, status=404)
return Response({"detail": t("sales.client_not_found")}, status=404)
def patch(self, request: Request, contact_id: int) -> Response:
organization = self._org(request)
try:
contact = Contact.objects.get(id=contact_id, organization=organization)
except Contact.DoesNotExist:
return Response({"detail": "Клиент не найден"}, status=404)
return Response({"detail": t("sales.client_not_found")}, status=404)
changed: list[str] = []
for field, limit in self.LIMITS.items():
if field not in request.data:
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": "Имя контакта не может быть пустым"}, status=400)
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
setattr(contact, field, value)
changed.append(field)
if changed:
@@ -86,7 +111,7 @@ class ClientMergeView(ConversationViewBase):
def _owner_only(self, request: Request) -> Response | None:
membership = request.tenant_context.membership
if membership is None or membership.role != EmployeeRole.OWNER:
return Response({"detail": "Объединять контакты может только владелец"}, status=403)
return Response({"detail": t("sales.merge_owner_only")}, status=403)
return None
def post(self, request: Request, contact_id: int) -> Response:
@@ -10,6 +10,7 @@ from django.db.models import (
Value,
When,
)
from django.db.models.functions import Coalesce
from chatballs.api.pagination import SortKey
from chatballs.conversations.models import (
@@ -63,6 +64,11 @@ def order_conversations(
Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и
переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми,
дольше всех ждущий выше», остальные — по убыванию активности.
«Дольше всех ждущий» считается от waiting_since — момента, когда диалог
встал в очередь. По времени последнего сообщения его считать нельзя: клиент,
написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он
напоминал о себе, тем позже до него доходила очередь.
"""
if sort != "waiting":
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
@@ -72,7 +78,9 @@ def order_conversations(
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
),
_wait_at=Case(
When(waits, then=F("last_message_at")),
# Coalesce — страховка для строк, встававших в очередь до появления
# waiting_since: без неё они ушли бы в конец очереди вместо начала.
When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))),
default=Value(_NOT_WAITING_AT),
output_field=DateTimeField(),
),
@@ -1,5 +1,6 @@
from django.db.models import Count, Max, Q
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -7,11 +8,23 @@ from chatballs.conversations.models import (
MessageAuthor,
MessageKind,
)
from chatballs.i18n import t
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
def _system_text(message: Message) -> str:
params = message.system_params or {}
# Вид звонка приходит кодом (AUDIO/VIDEO): слово для него — тоже в каталоге.
if params.get("kind"):
params = {**params, "kind": t(f"calls.kind_{str(params['kind']).lower()}")}
rendered = t(f"conversations.system.{message.system_event}", **params)
# Ключа нет в каталоге — t вернул сам ключ; тогда честнее показать то, что
# записано, чем служебный код.
return message.text if rendered.startswith("conversations.system.") else rendered
def message_payload(message: Message) -> dict[str, object]:
payload = {
"id": message.id,
@@ -21,7 +34,14 @@ def message_payload(message: Message) -> dict[str, object]:
"authorName": (message.author_user.full_name or message.author_user.email) if message.author_user_id and message.author_user else "",
"authorAvatarUrl": user_avatar_url_in(message.author_user, message.organization_id) if message.author_user_id and message.author_user else None,
"kind": message.kind,
"text": message.text,
# Системное событие собирается по коду на языке запроса: историю
# диалога читают оба — и русскоязычный оператор, и англоязычный, — а
# записана она один раз. Код без перевода и записи, сделанные до его
# появления, приходят сохранённым текстом.
"text": _system_text(message) if message.system_event else message.text,
# Код нужен интерфейсу ещё и для тона строки: передача — предупреждение,
# взятие — акцент. Раньше тон угадывался регуляркой по русским словам.
"systemEvent": message.system_event,
"contentHtml": message.content_html,
"createdAt": message.created_at.isoformat(),
}
@@ -209,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,
@@ -238,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
+137 -21
View File
@@ -11,18 +11,21 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
from chatballs.conversations.realtime import notify_conversation_changed
from chatballs.i18n import customer_language, t
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.notifications.models import (
Notification,
NotificationAudience,
NotificationType,
)
from chatballs.notifications.services import notify
from chatballs.tenancy.context import TenantContext
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
class ClaimError(Exception):
@@ -36,13 +39,13 @@ def _require_open(conversation: Conversation) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise ClaimError("Диалог закрыт")
raise ClaimError(t("conversations.closed"))
def _operator_label(operator) -> str:
def operator_label(operator) -> str:
return getattr(operator, "full_name", "") or operator.email
@@ -76,7 +79,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
raise ClaimError(t("conversations.unavailable"))
_require_open(conversation)
@@ -98,7 +101,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
):
raise ClaimError("Диалог уже ведёт другой оператор")
raise ClaimError(t("calls.conversation_taken"))
conversation.control_mode = ControlMode.HUMAN
@@ -106,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(
@@ -114,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)} перехватил диалог",
)
@@ -140,7 +149,7 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
if agent is None or not agent.is_active:
raise ClaimError("У канала нет активного AI-агента")
raise ClaimError(t("channels.no_active_agent"))
conversation.control_mode = ControlMode.AI
@@ -148,9 +157,16 @@ 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)
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.RETURNED_TO_AI,
text="Диалог возвращён AI",
)
return conversation
@@ -172,15 +188,19 @@ 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, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.RETURNED_TO_QUEUE,
text="Диалог возвращён в очередь",
)
return conversation
@@ -258,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(
@@ -326,6 +348,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
conversation.expected_responder = ExpectedResponder.NOBODY
leave_queue(conversation)
conversation.save(
update_fields=[
@@ -337,6 +361,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
"assigned_operator",
"expected_responder",
"waiting_since",
]
@@ -372,6 +397,8 @@ def mark_conversation_as_spam(
conversation.expected_responder = ExpectedResponder.NOBODY
leave_queue(conversation)
conversation.save(
update_fields=[
@@ -383,6 +410,7 @@ def mark_conversation_as_spam(
"assigned_operator",
"expected_responder",
"waiting_since",
]
@@ -390,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)
+21 -18
View File
@@ -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» уже покрывает латиницу",
)
+124 -9
View File
@@ -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()
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import ensure_downloadable
from chatballs.integrations.proxy import build_opener
@@ -59,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)
@@ -104,7 +109,7 @@ def download_bytes(
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("Файл больше допустимого размера")
raise ValueError(t("conversations.file_too_large"))
return data
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff. Show more