Compare commits

...
46 Commits
Author SHA1 Message Date
Andrey 089dd6ddc2 🔖 release: 1.10.0 2026-09-15 08:45:54 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:40:04 +03:00
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
320 changed files with 18242 additions and 5315 deletions

No files matched your search

+10 -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 требует явного согласования до правок.
@@ -56,7 +60,11 @@ Engineering rules / обязательные практики:
- Демо-набор ставится на языке организации: манифесты лежат в `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
+14 -14
View File
@@ -32,7 +32,7 @@
- [Step 2. First-run wizard](#step-2-first-run-wizard)
- [Step 3. Configure in the UI](#step-3-configure-in-the-ui)
- [Website widget](#website-widget)
- [Calls relay (optional)](#calls-relay-optional)
- [Calls](#calls)
- [External file storage (optional)](#external-file-storage-optional)
- [Updating](#updating)
- [Features](#features)
@@ -45,7 +45,7 @@
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key and set your own budget.
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key.
---
@@ -58,7 +58,7 @@ The platform installs on your own server with a single command. Customer data st
| **Server** | Linux, x86_64 |
| **Software** | Docker with the Docker Compose plugin |
| **Ports** | 80 and 443 open |
| **Calls relay (optional)** | A dedicated public IP, port 3478 and UDP range 49160–49999 |
| **Ports for calls** | 3478 (UDP and TCP) and the UDP range 49160–49999 |
A domain is not needed to start. The installation opens by the server's IP address; the domain is set later in the settings.
@@ -101,7 +101,7 @@ Everything else is done in **Settings**.
|---|---|
| **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. |
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. |
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model and a daily budget. Attach articles from the knowledge base. |
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. |
| **Employees** | Invite your team by email, assign roles and groups. |
The home screen shows a launch checklist: create an agent, connect an entry point, invite employees.
@@ -116,15 +116,16 @@ After creating a web widget, add one tag to your site:
The chat opens in an isolated window on top of the site.
### Calls relay (optional)
### Calls
Audio and video calls run directly between browsers. If customers or employees sit behind strict NAT or a corporate firewall, enable the TURN relay:
Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server.
```bash
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<domain> CHATBALLS_TURN_EXTERNAL_IP=<public IP> CHATBALLS_TURN_LISTENING_IP=<IP for TURN> docker compose up -d --wait
```
Open on the firewall:
The relay listens on a dedicated IP so that port 443 does not conflict with the web gateway. The certificate for TURN over TLS is placed in the directory set by `CHATBALLS_TURN_CERTS_DIR`. TURN addresses are then entered in **Settings → Communication**.
- 3478/udp and 3478/tcp — the relay itself;
- 49160–49999/udp — the conversation ports (two per call).
Networks that allow nothing but port 443 will not reach the relay on 3478. They need TURN over TLS on 443, which means a separate public address (443 on the main one belongs to the web gateway) or an external TURN service — its addresses go into the same settings.
### External file storage (optional)
@@ -192,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
@@ -244,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>
@@ -271,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>
+14 -14
View File
@@ -32,7 +32,7 @@
- [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска)
- [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе)
- [Виджет на сайте](#виджет-на-сайте)
- [Звонки через relay (опционально)](#звонки-через-relay-опционально)
- [Звонки](#звонки)
- [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально)
- [Обновление](#обновление)
- [Функции](#функции)
@@ -45,7 +45,7 @@
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет.
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
---
@@ -58,7 +58,7 @@ Chatballs берёт на себя первую линию общения с к
| **Сервер** | Linux, x86_64 |
| **ПО** | Docker и плагин Docker Compose |
| **Порты** | 80 и 443 открыты |
| **Relay для звонков (опционально)** | Выделенный публичный IP, порт 3478 и диапазон UDP 49160–49999 |
| **Порты для звонков** | 3478 (UDP и TCP) и диапазон UDP 49160–49999 |
Домен на старте не нужен. Установка открывается по IP-адресу сервера, домен задаётся позже в настройках.
@@ -101,7 +101,7 @@ docker compose up -d --wait
|---|---|
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. |
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников.
@@ -116,15 +116,16 @@ docker compose up -d --wait
Чат откроется в изолированном окне поверх сайта.
### Звонки через relay (опционально)
### Звонки
Аудио- и видеозвонки работают напрямую между браузерами. Если клиенты или сотрудники сидят за строгим NAT или корпоративным файрволом, включите TURN-relay:
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
```bash
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<домен> CHATBALLS_TURN_EXTERNAL_IP=<публичный IP> CHATBALLS_TURN_LISTENING_IP=<IP для TURN> docker compose up -d --wait
```
На файрволе нужно открыть:
Relay слушает выделенный IP, чтобы порт 443 не конфликтовал с веб-шлюзом. Сертификат для TURN-over-TLS кладётся в каталог, заданный переменной `CHATBALLS_TURN_CERTS_DIR`. Адреса TURN затем указываются в **Настройки → Коммуникации**.
- 3478/udp и 3478/tcp — сам relay;
- 49160–49999/udp — порты разговоров (по два на звонок).
Сети, где наружу разрешён только порт 443, relay на 3478 не пройдут. Для них нужен TURN-over-TLS на 443, а это отдельный публичный адрес (443 на основном занят веб-шлюзом) либо внешний TURN-сервис — его адреса вписываются в те же настройки.
### Внешнее хранилище файлов (опционально)
@@ -192,7 +193,7 @@ Telegram, MAX, электронная почта и чат на сайте по
### Свой сервер и своя ИИ-модель
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову.
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель.
### Защита данных клиентов
@@ -244,8 +245,7 @@ docker compose logs gateway
1. Агент в статусе **Активен**, а не **Черновик**.
2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен.
3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус.
4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ.
5. Диалог не переведён в режим **Оператор** или **Пауза**.
4. Диалог не переведён в режим **Оператор** или **Пауза**.
</details>
<details>
@@ -271,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>
+1 -1
View File
@@ -1 +1 @@
1.5.0
1.10.0
+34 -6
View File
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
)
def _integration_model(integration, key: str) -> str:
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
if integration is None:
return ""
return str((integration.config or {}).get(key) or "")
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
@@ -125,10 +132,19 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
# Модели агента: пустая строка означает «как в интеграции», и тогда
# карточка показывает модель интеграции подсказкой в поле.
"model": agent.model,
"transcriptionModel": agent.transcription_model,
"providerModel": _integration_model(agent.provider_integration, "default_model"),
"transcriptionProviderModel": _integration_model(
agent.transcription_integration or agent.provider_integration,
"transcription_model",
),
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
"tone": agent.tone,
@@ -231,8 +247,10 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"model",
"transcriptionModel",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
@@ -249,11 +267,8 @@ def update_agent_card(
):
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
model_params = body.get("modelParams", agent.model_params)
limits = body.get("limits", agent.limits)
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": t("api.object_required")})
if not isinstance(limits, dict):
raise ValidationError({"limits": t("api.object_required")})
provider_integration_id = body.get(
"providerIntegrationId", agent.provider_integration_id
)
@@ -261,6 +276,15 @@ def update_agent_card(
provider_integration_id, int
):
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
transcription_integration_id = body.get(
"transcriptionIntegrationId", agent.transcription_integration_id
)
if transcription_integration_id is not None and not isinstance(
transcription_integration_id, int
):
raise ValidationError(
{"transcriptionIntegrationId": t("api.integer_id_required")}
)
update_agent(
context=context,
agent=agent,
@@ -268,9 +292,13 @@ def update_agent_card(
# Имя агента следует за именем карточки: сущность одна.
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_integration_id,
model=str(body.get("model", agent.model)),
transcription_model=str(
body.get("transcriptionModel", agent.transcription_model)
),
model_params=model_params,
allowed_tools=agent.allowed_tools,
limits=limits,
persona=str(body.get("persona", agent.persona)),
tone=str(body.get("tone", agent.tone)),
instructions=str(body.get("instructions", agent.instructions)),
+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
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
for raw_name in raw_path:
if not isinstance(raw_name, str) or not raw_name.strip():
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
try:
category = KnowledgeCategory.objects.get(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
except KnowledgeCategory.DoesNotExist as error:
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
category, _ = KnowledgeCategory.objects.get_or_create(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
parent_id = category.id
assert category is not None
return category
-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,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'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-15 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0020_aiagent_transcription_integration'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_model',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AlterField(
model_name='aiagent',
name='model',
field=models.CharField(blank=True, default='', max_length=128),
),
]
@@ -0,0 +1,34 @@
"""Агент перестаёт дублировать модель интеграции.
Раньше модель копировалась на агента при каждом сохранении, и поле означало
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
агент отвечает именно этой моделью, даже если у интеграции другая по
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
продолжают следовать за интеграцией.
"""
from django.db import migrations
def release_copied_models(apps, schema_editor):
AIAgent = apps.get_model("ai", "AIAgent")
updated = []
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
integration = agent.provider_integration
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
if agent.model == default_model:
agent.model = ""
updated.append(agent)
AIAgent.objects.bulk_update(updated, ["model"])
class Migration(migrations.Migration):
dependencies = [
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
]
operations = [
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
]
+20 -12
View File
@@ -334,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")
@@ -358,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(
@@ -372,7 +384,13 @@ class AIAgent(TenantRelationModel):
lifecycle_version = models.PositiveIntegerField(default=0)
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
# ключе живут разные агенты, и дорогая модель нужна не каждому.
model = models.CharField(max_length=128, blank=True, default="")
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
# расшифровывает, а если и там пусто — whisper-1.
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
@@ -417,10 +435,6 @@ class AIAgent(TenantRelationModel):
allowed_tools = models.JSONField(default=list, blank=True)
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
limits = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -463,8 +477,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -489,10 +501,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"])
@@ -18,9 +18,6 @@ class ChatResult:
model: str
prompt_tokens: int
completion_tokens: int
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
cost_micros: int = 0
@property
def total_tokens(self) -> int:
@@ -30,3 +30,16 @@ def get_provider(*, channel=None) -> LLMProvider:
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.
"""
@@ -116,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 = {
@@ -142,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)
@@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
@@ -172,8 +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,
)
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
self.proxy_url = proxy_url or ""
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
return openai_http.chat_completions(
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
)
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
@@ -37,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
# {"text": "..."}; ошибки транслируются в ProviderError.
#
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
# в журнал — по нему разбирают настройку.
import json
import logging
import urllib.error
import urllib.request
@@ -45,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
from chatballs.conversations.transports.base import multipart_body
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
body, body_type = multipart_body(
{"model": model},
file_field="file",
@@ -66,9 +72,24 @@ class OpenRouterProvider(LLMProvider):
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:300]
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
logger.warning(
"Transcription rejected by %s: HTTP %s %s (model=%s)",
self.base_url,
error.code,
detail,
model,
)
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
# остальное — временный отказ, который лечится повтором.
if error.code in (401, 403):
raise ProviderError(t("ai.transcription_denied")) from error
if error.code == 404:
raise ProviderError(t("ai.transcription_unsupported")) from error
raise ProviderError(t("ai.transcription_failed")) from error
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
raise ProviderError(t("ai.transcription_failed", error=error)) from error
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
raise ProviderError(t("ai.transcription_unreachable")) from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError(t("ai.empty_transcript"))
+38 -3
View File
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
def resolve_model(channel, *, fallback_model: str) -> str:
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
означает «как у интеграции», поэтому агент, которому модель не назначали,
продолжает следовать за настройкой провайдера.
"""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "model", "") or "").strip()
if chosen:
return chosen
integration = _channel_integration(channel)
return str(integration.config.get("default_model") or "").strip() or fallback_model
@@ -64,10 +75,34 @@ def resolve_model(channel, *, fallback_model: str) -> str:
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
def _transcription_integration(channel) -> Integration:
"""Чем расшифровывать голосовые.
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
голосовые у такого агента расшифровать было нечем.
"""
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "transcription_integration", None) if agent else None
if integration is None or not integration.secret:
return _channel_integration(channel)
return integration
def resolve_transcription_provider(channel) -> LLMProvider:
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
return _provider_from_integration(_transcription_integration(channel))
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
integration = _channel_integration(channel)
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
интеграции, которая расшифровывает, иначе whisper-1."""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
if chosen:
return chosen
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
@@ -56,3 +56,31 @@ def configure_agent_provider(
{"providerIntegrationId": t("ai.integration_model_required")}
)
return ProviderSelection(model, integration)
def configure_agent_transcription(
*, context: TenantContext, integration_id: int | None
) -> Integration | None:
"""Интеграция, которой агент расшифровывает голосовые.
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
только, что интеграция принадлежит организации и умеет быть провайдером.
"""
if integration_id is None:
return None
try:
return Integration.objects.get(
id=integration_id,
organization_id=context.organization_id,
kind=IntegrationKind.LLM_PROVIDER,
provider__in=[
IntegrationProvider.OPENROUTER,
IntegrationProvider.CUSTOM,
IntegrationProvider.DEMO,
],
)
except (Integration.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError(
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
) from error
+23 -22
View File
@@ -9,7 +9,10 @@ from chatballs.ai.models import (
AIAgentStatus,
Knowledge,
)
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.provider_selection import (
configure_agent_provider,
configure_agent_transcription,
)
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -19,9 +22,13 @@ from chatballs.tenancy.context import TenantContext
class AgentInput:
name: str
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_integration_id: int | None
# Модели агента; пустая строка — «как в интеграции».
model: str
transcription_model: str
model_params: dict
allowed_tools: list
limits: dict
persona: str
tone: str
instructions: str
@@ -39,20 +46,6 @@ class AgentCreateInput:
knowledge_ids: list[int]
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
def _normalize_limits(raw: dict | None) -> dict:
if not isinstance(raw, dict):
return {}
value = raw.get("dailyCostUsd")
try:
cents = int(value)
except (TypeError, ValueError):
return {}
return {"dailyCostUsd": cents} if cents > 0 else {}
def knowledge_for_agent_ids(
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
) -> list[Knowledge]:
@@ -105,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
channel=channel,
name=f"{channel.name} Agent",
status=AIAgentStatus.DRAFT,
model=selection.model,
# Модель новой карточки не фиксируется: агент следует за интеграцией,
# пока человек не выберет свою.
model="",
provider_integration=selection.integration,
persona=data.persona,
tone=data.tone,
@@ -139,14 +134,19 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
context=context,
integration_id=data.provider_integration_id,
)
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
# чтобы PATCH инструкций не стирал её у черновика.
locked.model = selection.model if selection.integration else locked.model
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
# и разрешается в момент вызова (ai.provider.routing).
locked.model = data.model.strip()[:128]
locked.transcription_model = data.transcription_model.strip()[:128]
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
context=context,
integration_id=data.transcription_integration_id,
)
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.limits = _normalize_limits(data.limits)
locked.persona = data.persona
locked.tone = data.tone
locked.instructions = data.instructions
@@ -155,10 +155,11 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
update_fields=[
"name",
"model",
"transcription_model",
"provider_integration",
"transcription_integration",
"model_params",
"allowed_tools",
"limits",
"persona",
"tone",
"instructions",
+53 -2
View File
@@ -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);
# деактивация свободна.
@@ -292,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
# Своей модели у агента нет — он следует за интеграцией, и карточка
# показывает её модель подсказкой.
self.assertEqual(patched.json()["agent"]["model"], "")
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
knowledge = Knowledge.objects.get(title="Acme support")
self.assertEqual(knowledge.category_id, self.app.id)
def test_unknown_category_path_fails_per_document(self) -> None:
def test_missing_category_is_created_under_existing_parent(self) -> None:
response = self._import(
[
{
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
)
payload = response.json()
self.assertEqual(payload["created"], 1)
self.assertEqual(len(payload["failed"]), 1)
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
self.assertFalse(
KnowledgeCategory.objects.filter(
organization=self.organization,
name="Missing",
).exists()
self.assertEqual(payload["created"], 2)
self.assertEqual(payload["failed"], [])
category = KnowledgeCategory.objects.get(
organization=self.organization, parent=self.products, name="Missing"
)
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
documents = [
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
for title in ["First", "Second"]
]
before = KnowledgeCategory.objects.count()
self.assertEqual(self._import(documents).json()["created"], 2)
self.assertEqual(self._import(documents).json()["unchanged"], 2)
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
first = Knowledge.objects.get(title="First")
second = Knowledge.objects.get(title="Second")
self.assertEqual(first.category_id, second.category_id)
self.assertEqual(first.category.parent.parent.name, "New root")
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
response = self._import([
{"title": "Invalid", "content": "Text", "description": [],
"categoryPath": ["Rollback root", "Child"]},
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
])
self.assertEqual(response.json()["created"], 1)
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
with self.subTest(path=path):
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
def test_existing_document_moves_to_new_category(self) -> None:
self._import([{"title": "Moving", "content": "Text"}])
response = self._import([
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
])
self.assertEqual(response.json()["updated"], 1)
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
self._import(
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
{
"title": self.support_only.title,
"content": "Attempted overwrite",
"categoryPath": ["Forbidden category"],
}
]
}
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
self.assertEqual(response.status_code, 403)
self.support_only.refresh_from_db()
self.assertEqual(self.support_only.content, "")
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
)
agent = self.channel.ai_agent
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
# агент, которому модель не выбирали на карточке.
agent.model = ""
agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
return integration
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
self.channel.provider_integration = integration
self.channel.save(update_fields=["provider_integration"])
self.agent.provider_integration = None
self.agent.save(update_fields=["provider_integration"])
# Агент следует за интеграцией: своей модели у него нет.
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
),
)
self.agent.provider_integration = current
self.agent.save(update_fields=["provider_integration"])
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
)
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
# Ключ провайдера один на организацию, агентов на нём несколько: модель
# выбирают агенту, и она не должна теряться при смене настройки ключа.
integration = self._integration(default_model="integration-model")
self.agent.provider_integration = integration
self.agent.model = "own-model"
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
)
@@ -0,0 +1,200 @@
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
расшифровки выбирается на агенте отдельно.
"""
import json
import urllib.error
from io import BytesIO
from unittest import mock
from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_model,
resolve_transcription_model,
resolve_transcription_provider,
)
from chatballs.ai.tests import make_channel_with_agent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
class TranscriptionRoutingTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.channel, self.agent = make_channel_with_agent(
self.organization, code="voice-agent", name="Голосовой агент"
)
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
if transcription_model:
config["transcriptionModel"] = transcription_model
return create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret="sk-key",
config=config,
),
)
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.save(update_fields=["provider_integration"])
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
def test_chosen_integration_takes_the_voice(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
answering = self._integration(
name="Ответы",
base_url="https://answers.example.test/v1",
transcription_model="answer-side-model",
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionModelTests(TranscriptionRoutingTests):
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
def test_model_from_the_card_wins(self) -> None:
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = whisper
self.agent.transcription_integration = whisper
self.agent.transcription_model = "gpt-4o-mini-transcribe"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
)
def test_text_and_voice_models_are_independent(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.model = "yandexgpt/rc"
self.agent.transcription_model = "whisper-large-v3-turbo"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"model",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
self.assertEqual(
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
)
self.assertEqual(
resolve_transcription_provider(self.channel).base_url,
"https://whisper.example.test/v1",
)
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
def _provider(self) -> CustomProvider:
return CustomProvider(
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
)
def _fail_with(self, code: int, body: bytes):
error = urllib.error.HTTPError(
"https://api.example.test/v1/audio/transcriptions",
code,
"error",
{},
BytesIO(body),
)
return mock.patch(
"chatballs.integrations.proxy.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def _transcribe(self):
return self._provider().transcribe(
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
)
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
body = json.dumps(
{"error": {"message": "Subscription is not supported for service accounts"}}
).encode()
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
self._transcribe()
message = str(caught.exception)
self.assertNotIn("Subscription", message)
self.assertNotIn("403", message)
self.assertIn("ключ", message)
def test_missing_endpoint_tells_where_to_look(self) -> None:
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
self._transcribe()
self.assertIn("расшифров", str(caught.exception).lower())
-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
+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)
+13 -7
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,
@@ -231,6 +231,15 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
"""Оператор закончил звонок — из любой фазы, в которой тот ещё жив.
Кнопка у оператора одна и означает «прекратить»: до ответа клиента это
отмена, после — завершение. Раньше здесь был только переход в CANCELLED, и
он разрешён лишь из REQUESTED/RINGING: клиент принял звонок, соединение не
установилось (частый случай за NAT), оператор жмёт «завершить» — и получает
409, а звонок остаётся висеть. Фазу выбирает `finish_call`, тот же код, что
и у клиента.
"""
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied(t("calls.operator_context_required"))
@@ -238,12 +247,9 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
raise CallConflict(t("calls.not_a_participant"))
try:
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
return transition_call(
call_session_id=call_session.id,
target_status=CallStatus.CANCELLED,
ended_by=CallEndedBy.STAFF,
)
# Повторный вызов идемпотентен: у завершённого звонка finish_call
# возвращает его как есть.
return finish_call(call_session_id=call_session.id, side=ParticipantSide.STAFF)
except CallInvalidTransition as error:
raise CallConflict(t("calls.cannot_cancel")) from error
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase):
1,
)
def test_operator_ends_call_the_customer_already_accepted(self) -> None:
# Клиент принял звонок, соединение не установилось (частый случай за
# NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться
# в запрет перехода.
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
open_call_for_identity(identity=self.identity)
transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED)
response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/")
self.assertEqual(response.status_code, 200)
created.call_session.refresh_from_db()
self.assertEqual(created.call_session.status, CallStatus.FAILED)
self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT")
self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF)
def test_non_participant_cannot_cancel(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self.client.force_authenticate(user=self.operator)
@@ -3,10 +3,11 @@ import hashlib
import hmac
import time
from django.test import SimpleTestCase, override_settings
from django.test import SimpleTestCase, TestCase, override_settings
from chatballs.calls.serializers import ice_servers_payload
from chatballs.calls.turn import turn_credentials
from chatballs.identity.instance_settings import InstanceSettings, turn_config
SECRET = "coturn-shared-secret"
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
@@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase):
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
self.assertEqual(ice_servers_payload(), [])
@override_settings(
CHATBALLS_CALL_STUN_URLS=[],
CHATBALLS_CALL_TURN_URLS=[],
CHATBALLS_CALL_TURN_SECRET=SECRET,
CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
)
class TurnDefaultsTests(TestCase):
"""Relay коробки: адреса берутся от адреса установки, без единой настройки."""
def _set_host(self, host: str) -> None:
row = InstanceSettings.load()
row.public_host = host
row.save()
def test_addresses_come_from_the_installation_address(self) -> None:
self._set_host("crm.example.com")
urls, _ttl = turn_config()
self.assertEqual(
urls,
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
def test_owner_addresses_win_over_the_defaults(self) -> None:
row = InstanceSettings.load()
row.public_host = "crm.example.com"
row.turn_urls = "turns:turn.example.net:5349?transport=tcp"
row.save()
urls, _ttl = turn_config()
self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"])
def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None:
self._set_host("crm.example.com")
servers = ice_servers_payload()
self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]})
self.assertEqual(
servers[1]["urls"],
[
"turn:crm.example.com:3478?transport=udp",
"turn:crm.example.com:3478?transport=tcp",
],
)
self.assertEqual(
servers[1]["credential"], _expected_credential(servers[1]["username"])
)
def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None:
self._set_host("")
self.assertEqual(ice_servers_payload(), [])
@@ -22,6 +22,7 @@ from chatballs.conversations.models import (
LifecycleState,
ReplyTemplate,
)
from chatballs.conversations.queue_models import policy_for
from chatballs.conversations.selectors import apply_conversation_visibility
from chatballs.conversations.serializers import conversation_payload
from chatballs.conversations.view_base import ConversationViewBase
@@ -30,6 +31,7 @@ from chatballs.identity.avatars import user_avatar_url
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.policy import can_administer_access
from chatballs.presence import ONLINE_WITHIN_SECONDS, last_seen
def _label_payload(label: ConversationLabel) -> dict[str, object]:
@@ -258,10 +260,21 @@ class ConversationCountersView(ConversationViewBase):
}
for assignee in assignees:
assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id)
# Два разных ожидания (макет Q3): диалог ничей — взять может любой;
# диалог назначен лично на меня и ждёт, пока я его возьму. Смешивать их
# в одном счётчике значит прятать своё среди чужого.
waiting_qs = open_qs.filter(control_mode=ControlMode.PAUSED)
return Response(
{
"all": open_qs.count(),
"waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(),
"waiting": waiting_qs.count(),
"queue": waiting_qs.filter(assigned_operator__isnull=True).count(),
"waitingOnMe": waiting_qs.filter(assigned_operator_id=request.user.id).count(),
# Срок личной очереди: по нему клиент считает, через сколько
# диалог вернётся всем.
"assignmentTimeoutMinutes": policy_for(
request.tenant_context.organization
).assignment_timeout_minutes,
"mine": base.filter(assigned_operator_id=request.user.id).count(),
"ungrouped": ungrouped,
"groups": groups,
@@ -301,6 +314,22 @@ class ConversationDirectoryView(APIView):
# Ответственного можно назначить и вне выдачи — по поиску, поэтому
# оставшихся не прячем молча, а сообщаем признаком hasMore.
rows = list(members[: DIRECTORY_LIMIT + 1])
shown = rows[:DIRECTORY_LIMIT]
# Присутствие и загрузка — второй и третий признак при выборе
# ответственного (макет «Очередь и уведомления», кадр Q5). Назначить
# отсутствующего можно: признак приблизительный и ничего не запрещает.
user_ids = [member.user_id for member in shown]
seen = last_seen(organization_id, user_ids)
now = timezone.now()
load = dict(
Conversation.objects.filter(
organization_id=organization_id,
lifecycle=LifecycleState.OPEN,
assigned_operator_id__in=user_ids,
)
.values_list("assigned_operator_id")
.annotate(total=Count("id"))
)
return Response(
{
"groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups],
@@ -309,8 +338,15 @@ class ConversationDirectoryView(APIView):
"id": member.user_id,
"name": member.user.full_name or member.user.email,
"avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id),
"role": member.role,
"online": member.user_id in seen
and (now - seen[member.user_id]).total_seconds() <= ONLINE_WITHIN_SECONDS,
"lastSeenAt": (
seen[member.user_id].isoformat() if member.user_id in seen else None
),
"openDialogs": load.get(member.user_id, 0),
}
for member in rows[:DIRECTORY_LIMIT]
for member in shown
],
"hasMoreEmployees": len(rows) > DIRECTORY_LIMIT,
}
@@ -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,
@@ -161,7 +162,7 @@ def client_row(contact: Contact) -> dict:
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
"email": next(
(
identity.external_user_id
@@ -301,7 +302,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
# на другом языке это перестало бы работать.
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
# Поля карточки из чата (описание, компания, город).
"description": contact.description,
"company": contact.company,
@@ -370,7 +371,7 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
"cid": f"CUS-{other.id}",
"name": other.name or t("conversations.guest"),
"isGuest": not other.name,
"avatarUrl": other.avatar_url,
"avatarUrl": contact_avatar_url_in(other, other.organization_id),
"dialogs": other.conversations.count(),
"sources": sorted({identity.connection.provider for identity in identities}),
"phone": other.phone,
@@ -1,4 +1,9 @@
"""WebSocket оповещений о диалогах (см. chatballs.conversations.realtime).
"""WebSocket оповещений рабочего места (см. chatballs.conversations.realtime и
chatballs.notifications.realtime).
Сокет один на сессию: по нему идут и события диалогов, и события уведомлений.
Второй сокет ради второго источника означал бы второе переподключение, вторую
аутентификацию и вторую точку отказа на ровном месте.
Правила:
- аутентификация — сессией того же SPA (AuthMiddlewareStack), отдельного токена
@@ -23,6 +28,8 @@ 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 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
@@ -36,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)
@@ -46,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:
@@ -80,6 +111,11 @@ 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:
@@ -0,0 +1,87 @@
"""Фото контакта: скачиваем у провайдера и отдаём со своего адреса.
Рабочее место живёт под `Content-Security-Policy: img-src 'self'`, поэтому
ссылка на CDN мессенджера до экрана не доезжает — оператор видит инициалы
вместо лица. Значит, фото должно лежать у нас и отдаваться тенантным
эндпоинтом, как фото сотрудника (`identity.avatars`).
Источник фото запоминается в `Contact.avatar_source`: у MAX это адрес из
профиля отправителя, у Telegram — идентификатор файла фотографии. Пока
источник тот же, повторно ничего не качается. Строка `tg:none` означает «у
человека в Telegram фото нет»: без неё каждое его сообщение стоило бы лишнего
запроса к API.
"""
from __future__ import annotations
import hashlib
import logging
from django.core.files.base import ContentFile
from chatballs.conversations.models import Contact
from chatballs.identity.avatars import image_type, organization_public_id
logger = logging.getLogger(__name__)
MAX_AVATAR_BYTES = 2 * 1024 * 1024
# Проверено и фото нет: помним, чтобы не спрашивать провайдера снова.
NO_AVATAR = "none"
def contact_avatar_url_in(contact: Contact | None, organization_id: int) -> str | None:
"""Ссылка на фото контакта для рабочего места; None — фото нет."""
if contact is None:
return None
if contact.avatar:
version = hashlib.sha1(contact.avatar.name.encode("utf-8")).hexdigest()[:8]
public_id = organization_public_id(organization_id)
return f"/api/v1/organizations/{public_id}/conversations/clients/{contact.id}/avatar/?v={version}"
# Демо-набор и старые записи держат ссылку на наш же адрес — она рабочая.
return contact.avatar_url or None
def store_contact_avatar(contact: Contact, *, content: bytes, source: str) -> bool:
"""Сохранить скачанное фото. False — это не картинка или она слишком велика."""
if not content or len(content) > MAX_AVATAR_BYTES:
return False
detected = image_type(content)
if detected is None:
return False
content_type, suffix = detected
if contact.avatar:
contact.avatar.delete(save=False)
contact.avatar.save(f"avatar{suffix}", ContentFile(content), save=False)
contact.avatar_content_type = content_type
contact.avatar_source = source[:512]
contact.save(update_fields=["avatar", "avatar_content_type", "avatar_source"])
return True
def _checked_marker(source: str) -> str:
"""«Фото по этому источнику спрашивали, его нет» — чтобы не спрашивать снова."""
return f"{NO_AVATAR}:{source}"[:512]
def refresh_contact_avatar(integration, inbound, contact: Contact) -> None:
"""Подтянуть фото отправителя, если провайдер его отдаёт и оно новое.
Сбой скачивания не мешает сообщению: фото — украшение карточки, а не её
содержание.
"""
from chatballs.conversations import transports
try:
source = transports.avatar_source(integration, inbound)
if not source or contact.avatar_source in (source, _checked_marker(source)):
return
fetched = transports.download_avatar(integration, inbound)
if fetched is None:
contact.avatar_source = _checked_marker(source)
contact.save(update_fields=["avatar_source"])
return
content, source_key = fetched
if not store_contact_avatar(contact, content=content, source=source_key):
logger.info("Contact %s avatar from %s is not an image", contact.id, source_key)
except Exception as error: # noqa: BLE001 - провайдер/сеть, деградация мягкая
logger.info("Contact %s avatar download failed: %s", contact.id, error)
@@ -0,0 +1,184 @@
"""Что происходит, когда диалог ждёт слишком долго.
Раньше не происходило ничего. Про ждущий диалог операторов окликали ровно один
раз, и дальше `dedup_key` на сутки гарантировал тишину: если в тот момент никто
не смотрел на экран, диалог мог провисеть до автозакрытия, и узнать об этом было
неоткуда.
Уровень выбирается по времени ожидания, а не по счётчику попыток: состояние
хранить не нужно, потому что повтор гасит тот же `dedup_key` — свой у каждого
уровня. Свип идеемпотентен и может выполняться сколь угодно часто.
Назначенный диалог не эскалируется: он не в общей очереди, а в личной, и у неё
свой срок — не взял, значит возвращаем всем.
Присутствие сокращает ожидание, но не заменяет его. Если в группе диалога сейчас
никого нет за рабочим местом, ждать второго порога бессмысленно: напоминать
некому, и круг расширяется сразу. Обратного правила нет — присутствие никого не
задерживает и ничего не запрещает, потому что ошибается в обе стороны.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from django.utils import timezone
from chatballs.conversations.models import (
ControlMode,
Conversation,
LifecycleState,
Message,
MessageAuthor,
SystemEvent,
)
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
from chatballs.conversations.services import operator_label
from chatballs.i18n import t
from chatballs.notifications.models import (
NotificationAudience,
NotificationLevel,
NotificationType,
)
from chatballs.notifications.recipients import audience_user_ids
from chatballs.notifications.services import notify, notify_management
from chatballs.presence import online_user_ids
from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
def _contact_name(conversation: Conversation) -> str:
return getattr(conversation.contact, "name", "") or t("conversations.guest")
def _waiting_conversations(context: TenantContext):
return Conversation.objects.filter(
organization=context.organization,
lifecycle=LifecycleState.OPEN,
control_mode=ControlMode.PAUSED,
waiting_since__isnull=False,
).select_related("contact", "assigned_operator", "group")
def sweep_waiting_conversations(context: TenantContext) -> int:
"""Оклики по ждущим диалогам организации. Возвращает число новых уведомлений."""
policy = policy_for(context.organization)
now = timezone.now()
fired = 0
for conversation in _waiting_conversations(context):
if conversation.assigned_operator_id:
fired += _expire_stale_assignment(context, conversation, policy, now)
else:
fired += _escalate(context, conversation, policy, now)
return fired
def _nobody_is_watching(context: TenantContext, conversation: Conversation) -> bool:
"""В группе диалога никого нет за рабочим местом."""
watchers = audience_user_ids(
organization_id=context.organization_id,
audience=NotificationAudience.OPERATORS,
group_id=conversation.group_id,
)
return not online_user_ids(context.organization_id, watchers)
def _escalate(
context: TenantContext,
conversation: Conversation,
policy: QueueEscalationPolicy,
now: datetime,
) -> int:
waited = now - conversation.waiting_since
contact = _contact_name(conversation)
common = {
"context": context,
"type": NotificationType.DIALOG_WAITING_LONG,
"text_params": {"contact": contact},
"target_id": conversation.id,
"source_type": "Conversation",
"source_id": conversation.id,
}
fired = 0
if waited >= timedelta(minutes=policy.remind_after_minutes):
# Тот же круг, что и в первый раз: смена на месте, просто не заметила.
fired += notify(
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
level=NotificationLevel.WARNING,
title=f"Диалог всё ещё ждёт · {contact}",
title_key="notifications.still_waiting",
body_key="notifications.still_waiting_body",
dedup_key=f"waiting:{conversation.id}:remind",
**common,
) is not None
widen_after = policy.widen_after_minutes
if waited >= timedelta(minutes=policy.remind_after_minutes) and _nobody_is_watching(
context, conversation
):
# Некому заметить напоминание — второй порог ждать незачем.
widen_after = min(widen_after, policy.remind_after_minutes)
if waited >= timedelta(minutes=widen_after):
# Круг шире группы: в своей группе ответить некому.
fired += notify(
audience=NotificationAudience.OPERATORS,
audience_group=None,
level=NotificationLevel.WARNING,
title=f"Диалог всё ещё ждёт · {contact}",
title_key="notifications.still_waiting",
body_key="notifications.still_waiting_body",
dedup_key=f"waiting:{conversation.id}:widen",
**common,
) is not None
if waited >= timedelta(minutes=policy.escalate_after_minutes):
# Это уже не про сменщика, а про то, что смены нет.
fired += notify_management(
level=NotificationLevel.CRITICAL,
title=f"Диалог никто не берёт · {contact}",
title_key="notifications.waiting_unattended",
body_key="notifications.waiting_unattended_body",
dedup_key=f"waiting:{conversation.id}:management",
**common,
)
return fired
def _expire_stale_assignment(
context: TenantContext,
conversation: Conversation,
policy: QueueEscalationPolicy,
now: datetime,
) -> int:
if conversation.assigned_at is None:
return 0
if now - conversation.assigned_at < timedelta(minutes=policy.assignment_timeout_minutes):
return 0
label = operator_label(conversation.assigned_operator)
conversation.assigned_operator = None
conversation.assigned_at = None
conversation.save(update_fields=["assigned_operator", "assigned_at"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.ASSIGNMENT_EXPIRED,
system_params={"operator": label},
text=f"{label} не взял диалог — он вернулся в очередь",
)
logger.info("Assignment on conversation %s expired", conversation.id)
contact = _contact_name(conversation)
return notify(
context=context,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
level=NotificationLevel.WARNING,
title=f"Диалог снова ничей · {contact}",
title_key="notifications.assignment_expired",
text_params={"contact": contact, "operator": label},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"unassigned:{conversation.id}:{conversation.waiting_since.isoformat()}",
) is not None
+51 -27
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,
@@ -31,6 +31,7 @@ from chatballs.conversations.models import (
SystemEvent,
TranscriptStatus,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting
from chatballs.conversations.transports.base import InboundMessage
from chatballs.events.models import EventOwnership, InboxEvent
from chatballs.i18n import t
@@ -99,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,
@@ -107,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:
@@ -177,13 +178,27 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
context = TenantContext.for_resource(channel.organization)
agent = getattr(channel, "ai_agent", None)
ai_available = bool(agent and agent.is_active)
if not ai_available:
# Частая причина «диалог сразу ждёт оператора»: у канала подключения нет
# агента или он не активен. В журнале это должно быть видно одной
# строкой, иначе настройку ищут перебором.
logger.info(
"Channel %s has no active AI agent (agent=%s) — conversation goes to the operator queue",
channel.id,
getattr(agent, "status", None),
)
source = f"{integration.provider.lower()}:{integration.id}"
if _already_processed(context, source, inbound.external_id, inbound.text):
return
# Явный шаринг контакта: сообщение без текста, но с телефоном.
is_contact_share = bool(inbound.phone)
is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content)
is_voice = bool(
inbound.voice_file_id
or inbound.voice_url
or inbound.voice_content
or inbound.voice_unavailable
)
files = tuple(inbound.files or ())
# Файлы без текста: сообщение-контейнер не создаём, каждый файл — своя реплика.
files_only = bool(files) and not inbound.text and not is_contact_share and not is_voice
@@ -224,11 +239,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if identity.phone_verified_at is None:
identity.phone_verified_at = timezone.now()
identity.save(update_fields=["phone_verified_at"])
# Аватар обновляем при каждом заходе: провайдер может сменить фото,
# а контакт ещё не шарил телефон (is_contact_share=False).
# Адрес фото у провайдера храним как было, но показываем оператору не
# его: страница под CSP `img-src 'self'` чужую картинку не покажет.
if inbound.avatar_url and contact.avatar_url != inbound.avatar_url:
contact.avatar_url = inbound.avatar_url
contact.save(update_fields=["avatar_url"])
refresh_contact_avatar(integration, inbound, contact)
conversation = (
Conversation.objects.filter(channel=channel, contact=contact, lifecycle=LifecycleState.OPEN)
@@ -250,6 +266,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
external_chat_id=inbound.chat_id,
control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED,
expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR,
waiting_since=None if ai_available else timezone.now(),
previous_conversation=previous,
)
elif inbound.chat_id and not conversation.external_chat_id:
@@ -283,9 +300,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
conversation.last_activity_at = timezone.now()
update_fields = ["external_chat_id", "last_activity_at"]
if conversation.control_mode == ControlMode.AI and not ai_available:
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
update_fields.extend(["control_mode", "expected_responder"])
enter_queue(conversation)
update_fields.extend(QUEUE_FIELDS)
if inbound.thread_meta:
# Email: Message-ID последнего входящего — для ответа в тред;
# тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035).
@@ -299,10 +315,18 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
conversation.save(update_fields=update_fields)
if is_new:
# Диалог, которым занялся агент, — это «новый диалог» и больше ничего.
# Диалог, отвечать в котором некому, — уже просьба о человеке: событие
# одно, а смысл для смены разный, и подписки на них тоже разные.
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
type=(
NotificationType.OPERATOR_REQUESTED
if is_waiting(conversation)
else NotificationType.NEW_DIALOG
),
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Новый диалог · {channel.name}",
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
title_key="notifications.new_dialog",
@@ -325,6 +349,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
context=context,
type=NotificationType.DIALOG_NEW_MESSAGE,
audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS,
audience_group=conversation.group,
recipient_user=operator,
title=f"Новое сообщение · {contact.name or 'Гость'}",
body=message_text[:120],
@@ -354,15 +379,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
ai_input = transcribe_voice_message(channel, message)
if (is_voice and not ai_input) or files_only:
if conversation.control_mode == ControlMode.AI:
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "expected_responder"])
enter_queue(conversation)
conversation.save(update_fields=QUEUE_FIELDS)
if is_new:
return
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
@@ -382,15 +407,13 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
except (ProviderError, LimitExceeded) as error:
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
# отвечаем клиенту понятным fallback.
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
enter_queue(conversation)
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
@@ -401,8 +424,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"Нужен оператор · {contact.name or 'Гость'}",
title_key="notifications.operator_needed",
text_params={"contact": contact.name or t("conversations.guest")},
@@ -415,7 +439,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
)
notify_management(
context=context,
type=NotificationType.INTEGRATION_ERROR,
type=NotificationType.AI_STOPPED,
title=f"Ошибка AI · {channel.name}",
body="AI временно недоступен, диалог передан оператору",
title_key="notifications.ai_error",
@@ -437,11 +461,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
conversation.last_activity_at = timezone.now()
if handoff:
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
enter_queue(conversation)
else:
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
if handoff:
Message.objects.create(
@@ -452,8 +475,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
type=NotificationType.OPERATOR_REQUESTED,
audience=NotificationAudience.OPERATORS,
audience_group=conversation.group,
title=f"AI передал диалог · {contact.name or 'Гость'}",
title_key="notifications.ai_handed_over",
text_params={"contact": contact.name or t("conversations.guest")},
@@ -0,0 +1,40 @@
"""Время ожидания в очереди — отдельным полем.
Раньше «дольше всех ждущий» вычислялся по времени последнего сообщения, и
очередь работала обратно смыслу: клиент, напомнивший о себе, двигал
last_message_at вперёд и падал в конец очереди. Поле ставится один раз при входе
в очередь (chatballs.conversations.queue) и снимается при выходе из неё.
Backfill берёт last_message_at — единственное, что известно про уже ждущие
диалоги. Для них порядок не ухудшится: в старой сортировке ключ был тот же.
"""
from django.db import migrations, models
BACKFILL = """
UPDATE conversations_conversation
SET waiting_since = last_message_at
WHERE lifecycle = 'OPEN' AND control_mode = 'PAUSED' AND waiting_since IS NULL
"""
class Migration(migrations.Migration):
dependencies = [
('conversations', '0021_i18n_events'),
]
operations = [
migrations.AddField(
model_name='conversation',
name='waiting_since',
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop),
migrations.AddIndex(
model_name='conversation',
index=models.Index(
fields=['organization', 'waiting_since'], name='conv_waiting_order'
),
),
]
@@ -0,0 +1,46 @@
"""Личная очередь и пороги эскалации.
assigned_at нужен, чтобы у назначения был срок: waiting_since для этого не
годится — назначить могут и через час после того, как диалог встал в очередь.
Пороги — строка на организацию с дефолтами: «долго» у круглосуточной
поддержки и у приёма по будням означает разное.
"""
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0022_conversation_waiting_since'),
('identity', '0039_remove_organization_currency'),
]
operations = [
migrations.AddField(
model_name='conversation',
name='assigned_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='message',
name='system_event',
field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('assigned_to', 'Диалог назначен сотруднику'), ('assignment_expired', 'Назначение истекло'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
),
migrations.CreateModel(
name='QueueEscalationPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('remind_after_minutes', models.PositiveIntegerField(default=5)),
('widen_after_minutes', models.PositiveIntegerField(default=15)),
('escalate_after_minutes', models.PositiveIntegerField(default=30)),
('assignment_timeout_minutes', models.PositiveIntegerField(default=10)),
('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='queue_policy', to='identity.organization')),
],
options={
'db_table': 'conversations_queueescalationpolicy',
},
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.16 on 2026-09-13 19:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0023_queue_escalation'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='queueescalationpolicy',
name='updated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='queueescalationpolicy',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]
@@ -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),
),
]
@@ -11,6 +11,14 @@ from chatballs.tenancy.models import TenantRelationModel
# разделено на независимые оси; перехват оператором — атомарный.
def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
import uuid
from pathlib import Path
suffix = Path(filename).suffix.lower()[:8] or ".jpg"
return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}"
class Contact(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
name = models.CharField(max_length=255, blank=True)
@@ -22,6 +30,16 @@ class Contact(models.Model):
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
# само изображение живёт на стороне провайдера.
avatar_url = models.URLField(max_length=512, blank=True, default="")
# Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой
# обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`,
# и картинка с чужого домена до экрана не доезжает — оператор видит
# инициалы вместо фото. Источник запоминается, чтобы не качать то же самое
# на каждое сообщение.
avatar = models.FileField(
upload_to=contact_avatar_upload_path, max_length=512, blank=True, default=""
)
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
avatar_source = models.CharField(max_length=512, blank=True, default="")
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
# заполняет оператор.
description = models.TextField(blank=True, default="")
@@ -166,6 +184,10 @@ class Conversation(models.Model):
lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN)
control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI)
expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI)
# С какого момента диалог ждёт человека (chatballs.conversations.queue).
# Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а
# больше прежнего, и в очереди обязан оставаться выше, а не ниже.
waiting_since = models.DateTimeField(null=True, blank=True)
# Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при
# создании, переносится вручную. NULL — диалог виден всем сотрудникам.
group = models.ForeignKey(
@@ -177,6 +199,9 @@ class Conversation(models.Model):
)
# «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп.
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
# Когда назначили. С этого момента идёт срок личной очереди: не взял —
# диалог возвращается в общую (chatballs.conversations.escalation).
assigned_at = models.DateTimeField(null=True, blank=True)
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
priority = models.CharField(
max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE
@@ -219,6 +244,10 @@ class Conversation(models.Model):
models.Index(
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
),
# Очередь к оператору: кто ждёт дольше всех и не дождался порога.
models.Index(
fields=["organization", "waiting_since"], name="conv_waiting_order"
),
]
constraints = [
# Диалог всегда принадлежит контакту.
@@ -274,6 +303,8 @@ class SystemEvent(models.TextChoices):
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику"
ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло"
CALL_REQUESTED = "call_requested", "Запрошен звонок"
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
@@ -400,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,
)
@@ -0,0 +1,69 @@
"""Очередь к оператору: единственное место, где диалог в неё входит и выходит.
Правило «диалог ждёт человека» — это три поля сразу: control_mode = PAUSED,
expected_responder = OPERATOR и момент, с которого пошло ожидание. Раньше первые
два выставлялись в шести местах подряд (создание диалога без доступного AI,
клиент написал в диалог без AI, голосовое без расшифровки, сбой провайдера,
хендофф агента, ручной возврат оператором), а третьего не было вовсе: «дольше
всех ждущий» считался по времени последнего сообщения.
Из-за этого очередь вела себя обратно смыслу. Клиент, который писал повторно,
двигал last_message_at вперёд и падал в конец очереди: чем настойчивее человек,
тем позже до него доходили руки. Поэтому waiting_since ставится один раз — при
входе в очередь — и не обновляется, пока диалог из неё не вышел.
"""
from __future__ import annotations
from datetime import datetime
from django.utils import timezone
from chatballs.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
)
# Что пишет enter_queue. Вызывающий кладёт это в update_fields своего save():
# состояние диалога меняется вместе с остальными полями, одной записью.
QUEUE_FIELDS = ("control_mode", "expected_responder", "waiting_since")
def is_waiting(conversation: Conversation) -> bool:
"""Диалог стоит в очереди к человеку.
Закрытый и спам тоже лежат в PAUSED, но никого не ждут — отсюда проверка
жизненного цикла.
"""
return (
conversation.lifecycle == LifecycleState.OPEN
and conversation.control_mode == ControlMode.PAUSED
)
def enter_queue(conversation: Conversation, *, now: datetime | None = None) -> bool:
"""Ставит диалог в очередь. True — если он в неё только что попал.
Возврат нужен вызывающему, чтобы решить, звать ли операторов: повторное
сообщение клиента в уже ждущий диалог очередь не меняет и второго оклика не
заслуживает.
"""
entered = not is_waiting(conversation)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
if entered:
conversation.waiting_since = now or timezone.now()
return entered
def leave_queue(conversation: Conversation) -> None:
"""Диалог больше никого не ждёт.
Несимметрично enter_queue намеренно: вход в очередь — одно состояние, а
выходов несколько (оператор взял, диалог вернули AI, закрыли, пометили
спамом), и control_mode у каждого свой. Общее у них только одно — ожидание
закончилось, и его начало больше не имеет смысла.
"""
conversation.waiting_since = None
@@ -0,0 +1,53 @@
"""Пороги очереди: через сколько напоминать, расширять круг и звать руководство.
Числа разные у разных организаций — у круглосуточной поддержки хостинга и у
клиники с приёмом по будням «долго» означает не одно и то же, — поэтому они
настройка, а не константа в коде. Строка одна на организацию и заводится с
дефолтами при первом обращении.
"""
from __future__ import annotations
from django.conf import settings
from django.db import models
class QueueEscalationPolicy(models.Model):
organization = models.OneToOneField(
"identity.Organization", on_delete=models.CASCADE, related_name="queue_policy"
)
# Диалог ждёт дольше этого — повторный оклик той же группе.
remind_after_minutes = models.PositiveIntegerField(default=5)
# Ждёт ещё дольше — круг расширяется за пределы группы диалога.
widen_after_minutes = models.PositiveIntegerField(default=15)
# Совсем долго — это уже не про сменщика, а про руководство.
escalate_after_minutes = models.PositiveIntegerField(default=30)
# Назначили ответственного, а он не взял — диалог возвращается в общую
# очередь. Без этого назначение работает как способ спрятать диалог: из
# общей очереди он ушёл, а отвечать некому.
assignment_timeout_minutes = models.PositiveIntegerField(default=10)
# Кто и когда менял: в разделе настроек это подпись под формой. Сроки —
# правило работы смены, и знать, чьё это решение, важнее, чем кажется.
updated_at = models.DateTimeField(null=True, blank=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
# Значения по умолчанию — они же «обычные сроки» в кнопке сброса.
DEFAULTS = {
"remind_after_minutes": 5,
"widen_after_minutes": 15,
"escalate_after_minutes": 30,
"assignment_timeout_minutes": 10,
}
class Meta:
db_table = "conversations_queueescalationpolicy"
def __str__(self) -> str:
return f"queue-policy:{self.organization_id}"
def policy_for(organization) -> QueueEscalationPolicy:
policy, _ = QueueEscalationPolicy.objects.get_or_create(organization=organization)
return policy
@@ -0,0 +1,62 @@
"""Сроки очереди: раздел «Когда звать на помощь» (макет Q2).
До этого пороги правились только в служебной админке — то есть де-факто никем.
Читает их тот, кто видит настройки; меняет — тот, кто ими управляет.
"""
from __future__ import annotations
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
from chatballs.i18n import t
from chatballs.identity.policy import ResourceScope, authorize
FIELDS = tuple(QueueEscalationPolicy.DEFAULTS)
# Сутки: всё, что дольше, — это не «позвать на помощь», а другая задача.
MAX_MINUTES = 24 * 60
def _payload(policy: QueueEscalationPolicy) -> dict:
author = policy.updated_by
return {
**{field: getattr(policy, field) for field in FIELDS},
"defaults": dict(QueueEscalationPolicy.DEFAULTS),
"updatedAt": policy.updated_at.isoformat() if policy.updated_at else None,
"updatedBy": (author.full_name or author.email) if author else "",
}
class QueuePolicyView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
context = request.tenant_context
if not authorize(context.membership, "settings.view", ResourceScope(context.organization_id)):
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
return Response(_payload(policy_for(context.organization)))
def patch(self, request: Request) -> Response:
context = request.tenant_context
if not authorize(context.membership, "settings.manage", ResourceScope(context.organization_id)):
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
policy = policy_for(context.organization)
changed = []
for field in FIELDS:
if field not in request.data:
continue
value = request.data[field]
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_MINUTES:
return Response({"detail": t("settings.queue_minutes_range")}, status=400)
if getattr(policy, field) != value:
setattr(policy, field, value)
changed.append(field)
if changed:
policy.updated_at = timezone.now()
policy.updated_by = context.actor_user
policy.save(update_fields=[*changed, "updated_at", "updated_by"])
return Response(_payload(policy))
@@ -13,12 +13,7 @@
from __future__ import annotations
import logging
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
logger = logging.getLogger(__name__)
from chatballs.realtime import publish
INBOX_EVENT = "inbox.changed"
CONVERSATION_EVENT = "conversation.changed"
@@ -32,27 +27,12 @@ def conversation_group(conversation_id: int) -> str:
return f"conv.{conversation_id}"
def _publish(group: str, payload: dict[str, object]) -> None:
"""Оповещение — вспомогательный путь: его сбой не должен ронять запись.
Сообщение уже сохранено к моменту отправки; если канал недоступен, клиент
узнает об изменении следующим опросом — он остаётся как запасной путь.
"""
layer = get_channel_layer()
if layer is None:
return
try:
async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload})
except Exception: # noqa: BLE001 — канал не должен ломать сохранение
logger.warning("realtime fanout failed for %s", group, exc_info=True)
def notify_inbox_changed(organization_id: int) -> None:
_publish(inbox_group(organization_id), {"type": INBOX_EVENT})
publish(inbox_group(organization_id), {"type": INBOX_EVENT})
def notify_conversation_changed(conversation_id: int, *, organization_id: int) -> None:
_publish(
publish(
conversation_group(conversation_id),
{"type": CONVERSATION_EVENT, "conversationId": conversation_id},
)
@@ -1,4 +1,5 @@
from django.core.exceptions import ValidationError
from django.http import FileResponse
from rest_framework.request import Request
from rest_framework.response import Response
@@ -21,6 +22,29 @@ class ConversationStatsView(ConversationViewBase):
return Response(sales_overview_stats(request.tenant_context, period))
class ContactAvatarView(ConversationViewBase):
"""Фото контакта, скачанное у провайдера.
Отдаётся со своего адреса: страница рабочего места живёт под CSP
`img-src 'self'`, и ссылка на CDN мессенджера до экрана не доезжает.
Видно тому же, кто видит диалоги, — фото клиента и есть часть карточки.
"""
def get(self, request: Request, contact_id: int):
contact = Contact.objects.filter(
organization=self._org(request), id=contact_id
).first()
if contact is None or not contact.avatar:
return Response({"detail": t("sales.client_not_found")}, status=404)
response = FileResponse(
contact.avatar.open("rb"),
content_type=contact.avatar_content_type or "application/octet-stream",
filename="avatar",
)
response["Cache-Control"] = "private, max-age=86400"
return response
class ClientsView(ConversationViewBase):
required_capability = "customers.view"
@@ -10,6 +10,7 @@ from django.db.models import (
Value,
When,
)
from django.db.models.functions import Coalesce
from chatballs.api.pagination import SortKey
from chatballs.conversations.models import (
@@ -63,6 +64,11 @@ def order_conversations(
Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и
переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми,
дольше всех ждущий выше», остальные — по убыванию активности.
«Дольше всех ждущий» считается от waiting_since — момента, когда диалог
встал в очередь. По времени последнего сообщения его считать нельзя: клиент,
написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он
напоминал о себе, тем позже до него доходила очередь.
"""
if sort != "waiting":
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
@@ -72,7 +78,9 @@ def order_conversations(
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
),
_wait_at=Case(
When(waits, then=F("last_message_at")),
# Coalesce — страховка для строк, встававших в очередь до появления
# waiting_since: без неё они ушли бы в конец очереди вместо начала.
When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))),
default=Value(_NOT_WAITING_AT),
output_field=DateTimeField(),
),
@@ -1,5 +1,6 @@
from django.db.models import Count, Max, Q
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -228,7 +229,9 @@ def conversation_payload(
"id": conversation.contact_id,
"name": conversation.contact.name,
"phone": conversation.contact.phone,
"avatarUrl": conversation.contact.avatar_url,
"avatarUrl": contact_avatar_url_in(
conversation.contact, conversation.organization_id
),
"description": conversation.contact.description,
"company": conversation.contact.company,
"city": conversation.contact.city,
@@ -257,6 +260,17 @@ def conversation_payload(
"isAssignedToViewer": bool(
viewer_id and conversation.assigned_operator_id == viewer_id
),
# Ожидание (макет «Очередь и уведомления», кадры Q3 и Q4): с какого
# момента диалог ждёт человека и с какого — конкретного человека.
# Считать «сколько осталось» клиент должен сам: минута на сервере и
# минута на экране расходятся, и показывать замерший счётчик хуже, чем
# не показывать никакого.
"waitingSince": (
conversation.waiting_since.isoformat() if conversation.waiting_since else None
),
"assignedAt": (
conversation.assigned_at.isoformat() if conversation.assigned_at else None
),
"group": (
{"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color}
if conversation.group_id
@@ -13,9 +13,17 @@ from chatballs.conversations.models import (
MessageKind,
SystemEvent,
)
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
from chatballs.conversations.realtime import notify_conversation_changed
from chatballs.i18n import customer_language, t
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.notifications.models import (
Notification,
NotificationAudience,
NotificationType,
)
from chatballs.notifications.services import notify
from chatballs.tenancy.context import TenantContext
@@ -37,7 +45,7 @@ def _require_open(conversation: Conversation) -> None:
def _operator_label(operator) -> str:
def operator_label(operator) -> str:
return getattr(operator, "full_name", "") or operator.email
@@ -101,7 +109,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
leave_queue(conversation)
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
Message.objects.create(
@@ -111,9 +121,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
system_event=SystemEvent.OPERATOR_TOOK,
system_params={"operator": _operator_label(operator)},
system_params={"operator": operator_label(operator)},
text=f"Оператор {_operator_label(operator)} перехватил диалог",
text=f"Оператор {operator_label(operator)} перехватил диалог",
)
@@ -147,7 +157,9 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
conversation.expected_responder = ExpectedResponder.AI
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
leave_queue(conversation)
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
Message.objects.create(
conversation=conversation,
@@ -176,13 +188,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
_require_open(conversation)
conversation.control_mode = ControlMode.PAUSED
enter_queue(conversation)
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
conversation.save(update_fields=[*QUEUE_FIELDS, "assigned_operator"])
Message.objects.create(
conversation=conversation,
@@ -337,6 +348,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
conversation.expected_responder = ExpectedResponder.NOBODY
leave_queue(conversation)
conversation.save(
update_fields=[
@@ -348,6 +361,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
"assigned_operator",
"expected_responder",
"waiting_since",
]
@@ -383,6 +397,8 @@ def mark_conversation_as_spam(
conversation.expected_responder = ExpectedResponder.NOBODY
leave_queue(conversation)
conversation.save(
update_fields=[
@@ -394,6 +410,7 @@ def mark_conversation_as_spam(
"assigned_operator",
"expected_responder",
"waiting_since",
]
@@ -401,3 +418,91 @@ def mark_conversation_as_spam(
return conversation
@transaction.atomic
def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -> Conversation:
"""Назначить ответственного за диалог (или снять назначение).
Назначение — не взятие: человек ещё не ответил и мог даже не увидеть
диалог. Но из общей очереди диалог уходит — отвечать в нём, кроме
назначенного и руководства, уже никто не может, — поэтому назначенного надо
позвать лично и поставить срок. Не успел — диалог возвращается в общую
очередь (chatballs.conversations.escalation).
Раньше эта операция молча писала внешний ключ: назначенный не узнавал,
диалог продолжал числиться в общей очереди, а взять его оттуда было уже
нельзя.
"""
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.assigned_operator = assignee
conversation.assigned_at = timezone.now() if assignee is not None else None
conversation.save(update_fields=["assigned_operator", "assigned_at"])
if assignee is None:
return conversation
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
system_event=SystemEvent.ASSIGNED_TO,
system_params={"operator": operator_label(assignee)},
text=f"Диалог назначен на {operator_label(assignee)}",
)
# Себе назначил — сам и знает.
if context.actor_user is not None and assignee.pk == context.actor_user.pk:
return conversation
contact_name = getattr(conversation.contact, "name", "") or t("conversations.guest")
notify(
context=context,
type=NotificationType.DIALOG_ASSIGNED,
audience=NotificationAudience.USER,
recipient_user=assignee,
title=f"Вам назначен диалог · {contact_name}",
title_key="notifications.assigned_to_you",
text_params={"contact": contact_name},
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"assign:{conversation.id}:{assignee.pk}",
)
return conversation
@transaction.atomic
def delete_conversation(*, context: TenantContext, conversation: Conversation) -> None:
"""Удалить диалог насовсем: переписку, вложения, звонки и оклики о нём.
Раньше «удалить» означало архив: диалог пропадал из списков, но продолжал
жить — в него приходили сообщения из канала, он поднимал уведомления и
возвращался клиенту в виджете как ни в чём не бывало. Решение владельца
2026-09-14: удалён — значит удалён. Права на это есть только у владельца и
администратора (проверяет представление).
Файлы удаляются явно: `FileField` при удалении строки оставляет их на
диске, а «удалён» не должно означать «лежит в media».
"""
conversation_id = conversation.id
organization_id = conversation.organization_id
for message in conversation.messages.exclude(audio="", attachment=""):
if message.audio:
message.audio.delete(save=False)
if message.attachment:
message.attachment.delete(save=False)
# Оклик ведёт в диалог, которого больше нет: и в списке уведомлений, и в
# мессенджере сотрудника такая строка — тупик.
Notification.objects.filter(
organization_id=organization_id,
source_type="Conversation",
source_id=str(conversation_id),
).delete()
# Звонки держат диалог внешним ключом PROTECT; история звонка без самого
# диалога ничего не значит, поэтому уходит вместе с ним.
from chatballs.calls.models import CallSession
CallSession.objects.filter(conversation_id=conversation_id).delete()
conversation.delete()
# Открытые рабочие места узнают об этом событием: у того, кто держал диалог
# открытым, он должен закрыться, а не висеть мёртвой карточкой.
notify_conversation_changed(conversation_id, organization_id=organization_id)
+19 -17
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,7 @@ from chatballs.conversations.models import (
LifecycleState,
Message,
)
from chatballs.conversations.selectors import apply_conversation_visibility
from chatballs.i18n import t
_ACTIVE_WINDOW = timedelta(minutes=15)
@@ -64,28 +64,32 @@ def _chart(
return {"values": values, "labels": labels}
def _ai_cost(
org_id: int,
start: datetime,
end: datetime | None = None,
) -> int:
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
if end is not None:
qs = qs.filter(created_at__lt=end)
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
def sales_overview_stats(context, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
start, prev_start = _window(period, now)
open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN)
# Удалённые по-старому (архивные) диалоги в живых числах не участвуют: они
# не видны ни в одном списке, а бейдж из-за них не опускался до нуля.
open_qs = Conversation.objects.filter(
organization_id=organization_id,
lifecycle=LifecycleState.OPEN,
archived_at__isnull=True,
)
open_dialogs = open_qs.count()
# «Ждут оператора» = очередь: диалоги, которые никто не взял (PAUSED).
# Взятые оператором (HUMAN), но ещё без ответа, очередью не считаются —
# иначе бейдж «Диалоги» показывает число при полностью разобранном inbox.
waiting = open_qs.filter(control_mode=ControlMode.PAUSED).count()
#
# Это же число висит бейджем на пункте «Чат», поэтому считается по границе
# видимости сотрудника: иначе он видит счётчик диалогов, которых не увидит,
# и разобрать его не может (ADR-CHATBALLS-0043 §4). Счётчики вкладок списка
# (chat_extras_views) считают ровно так же.
waiting = (
apply_conversation_visibility(open_qs, context)
.filter(control_mode=ControlMode.PAUSED)
.count()
)
ops = {
"openDialogs": open_dialogs,
"activeNow": open_qs.filter(last_activity_at__gte=now - _ACTIVE_WINDOW).count(),
@@ -105,8 +109,6 @@ def sales_overview_stats(context, period: str) -> dict:
conversation__organization_id=organization_id,
created_at__gte=start,
).count(),
"aiCostMicros": _ai_cost(organization_id, start),
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
}
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
@@ -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)
@@ -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
)
+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)
@@ -154,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"
@@ -60,6 +60,10 @@ class InboundMessage:
voice_content: bytes = b""
voice_duration: int = 0
voice_mime: str = ""
# Голосовое в сообщении есть, но источника для скачивания провайдер не дал
# (незнакомая форма вложения). Сообщение всё равно доезжает до оператора
# заглушкой: молча терять реплику клиента нельзя.
voice_unavailable: bool = False
# Файлы и фото: каждый становится отдельным сообщением kind=file.
files: tuple[InboundFile, ...] = field(default_factory=tuple)
@@ -109,9 +109,10 @@ def _normalize(update: dict) -> InboundMessage | None:
chat_id = first(recipient, "chat_id", "chatId")
external_id = first(inner, "mid", "msgId", "seq") or first(update, "update_id", "updateId", "timestamp")
phone = _contact_phone(inner, msg)
voice_url, voice_duration = _voice_attachment(inner)
voice_url, voice_duration, voice_unavailable = _voice_attachment(inner)
files = _file_attachments(inner)
if (not text and not phone and not voice_url and not files) or user_id is None or external_id is None:
has_voice = bool(voice_url) or voice_unavailable
if (not text and not phone and not has_voice and not files) or user_id is None or external_id is None:
return None
return InboundMessage(
external_id=str(external_id),
@@ -125,6 +126,7 @@ def _normalize(update: dict) -> InboundMessage | None:
voice_url=voice_url,
voice_duration=voice_duration,
voice_mime="audio/ogg" if voice_url else "",
voice_unavailable=voice_unavailable,
files=files,
)
@@ -221,15 +223,91 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena
return False
def _voice_attachment(inner: dict) -> tuple[str, int]:
"""Голосовое/аудио-вложение MAX: payload.url для скачивания."""
def _voice_attachment(inner: dict) -> tuple[str, int, bool]:
"""Голосовое/аудио-вложение MAX: адрес для скачивания и длительность.
Третье значение — «вложение было, а адреса в нём нет». Форма вложения у MAX
описана неполно, и раньше такое сообщение уходило в никуда: текста в нём
нет, адреса нет — normalize возвращал None, и реплика клиента просто
пропадала. Теперь оператор видит её заглушкой, а в журнале остаётся сам
payload, по которому разбирают форму.
"""
for attachment in inner.get("attachments") or []:
if attachment.get("type") in ("audio", "voice"):
payload = attachment.get("payload") or {}
url = str(first(payload, "url", "download_url", default=""))
if url:
return url, int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
return "", 0
if attachment.get("type") not in ("audio", "voice"):
continue
payload = attachment.get("payload") or {}
duration = int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
url = str(first(payload, "url", "download_url", default=""))
if url:
return url, duration, False
logger.warning(
"MAX voice attachment without a download url: %s",
json.dumps(attachment, ensure_ascii=False),
)
return "", duration, True
return "", 0, False
# Насколько раньше события смотреть чаты, когда MAX прислал событие без тела.
BODYLESS_LOOKBACK_MS = 15_000
# Сколько чатов и сообщений забирать в этом случае: событие одно, чат почти
# всегда один, а перебирать весь список чатов на каждое такое событие нельзя.
BODYLESS_CHATS_LIMIT = 5
BODYLESS_MESSAGES_PER_CHAT = 3
def _is_bodyless(update: dict) -> bool:
"""Событие «пришло сообщение», в котором самого сообщения нет.
MAX присылает такое, например, на голосовые: update_type есть, timestamp
есть, а `message` отсутствует целиком. Разбирать в нём нечего, и раньше
реплика клиента на этом заканчивалась.
"""
if (update.get("update_type") or update.get("updateType")) != "message_created":
return False
return not (update.get("message") or update.get("payload"))
def _api(integration, path: str) -> dict:
return request_json(
f"{_base(integration)}{path}",
headers={"Authorization": integration.secret, "Content-Type": "application/json"},
proxy_url=_proxy(integration),
)
def _messages_since(integration, timestamp: int) -> list[InboundMessage]:
"""Свежие сообщения из чатов бота — ответ на событие без тела.
Забираются последние реплики тех чатов, где что-то происходило рядом с
событием, и прогоняются через тот же разбор. Повторы безвредны: входящие
отсеиваются по идентификатору сообщения (conversations.ingest).
"""
chats = (_api(integration, f"/chats?count={BODYLESS_CHATS_LIMIT * 4}") or {}).get("chats") or []
recent = [
chat
for chat in chats
if int(first(chat, "last_event_time", "lastEventTime", default=0) or 0)
>= timestamp - BODYLESS_LOOKBACK_MS
]
recent.sort(
key=lambda chat: int(first(chat, "last_event_time", "lastEventTime", default=0) or 0),
reverse=True,
)
found: list[InboundMessage] = []
for chat in recent[:BODYLESS_CHATS_LIMIT]:
chat_id = first(chat, "chat_id", "chatId")
if chat_id is None:
continue
payload = _api(
integration,
f"/messages?chat_id={chat_id}&count={BODYLESS_MESSAGES_PER_CHAT}",
)
for message in payload.get("messages") or []:
inbound = _normalize({"update_type": "message_created", "message": message})
if inbound is not None:
found.append(inbound)
return found
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
@@ -246,6 +324,15 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
# Событие без тела: содержимое забираем отдельным запросом, иначе сообщение
# клиента (в частности, голосовое) до оператора не доедет вовсе.
bodyless = [u for u in updates if _is_bodyless(u)]
if bodyless:
timestamp = min(int(u.get("timestamp") or 0) for u in bodyless)
try:
messages.extend(_messages_since(integration, timestamp))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX chat history fetch failed for integration %s: %s", integration.id, error)
new_marker = data.get("marker")
return messages, ("" if new_marker is None else str(new_marker))
@@ -180,6 +180,33 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard})
def download_profile_photo(integration, user_id: str) -> bytes | None:
"""Фото профиля отправителя: getUserProfilePhotos -> file_id -> getFile.
Telegram не кладёт фото в апдейт, поэтому его спрашивают отдельно — один
раз на контакт (chatballs.conversations.contact_avatars). None — фото у
человека нет или оно закрыто настройками приватности.
"""
token = integration.secret
if not token or not user_id:
return None
data = request_json(
f"{_base(integration)}/bot{token}/getUserProfilePhotos?user_id={user_id}&limit=1",
proxy_url=_proxy(integration),
)
photos = ((data.get("result") or {}).get("photos") or []) if data.get("ok") else []
if not photos or not photos[0]:
return None
# Размеры идут от меньшего к большему; аватар рисуется 40-64px, поэтому
# средний размер лучше самого большого — он в разы легче.
sizes = [size for size in photos[0] if size.get("file_id")]
if not sizes:
return None
chosen = sizes[min(1, len(sizes) - 1)]
content, _content_type = download_file(integration, str(chosen["file_id"]))
return content
def download_file(integration, file_id: str) -> tuple[bytes, str]:
"""Скачивание файла по file_id: getFile -> file_path -> /file/bot<token>/<path>."""
token = integration.secret
@@ -3,6 +3,7 @@ from django.urls import path
from chatballs.conversations import (
attachment_views,
chat_extras_views,
queue_policy_views,
reporting_views,
views,
voice_views,
@@ -13,6 +14,7 @@ urlpatterns = [
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
path("clients/<int:contact_id>/avatar/", reporting_views.ContactAvatarView.as_view(), name="conversation-client-avatar"),
path("clients/<int:contact_id>/merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"),
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
path("<int:conversation_id>/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
@@ -30,6 +32,7 @@ urlpatterns = [
path("<int:conversation_id>/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"),
path("<int:conversation_id>/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"),
path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"),
path("queue-policy/", queue_policy_views.QueuePolicyView.as_view(), name="conversation-queue-policy"),
path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"),
path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"),
path("labels/<int:label_id>/", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"),
+44 -2
View File
@@ -30,8 +30,10 @@ from chatballs.conversations.serializers import (
)
from chatballs.conversations.services import (
ClaimError,
assign_operator,
claim_conversation,
close_conversation,
delete_conversation,
mark_conversation_as_spam,
post_operator_message,
release_to_ai,
@@ -87,6 +89,21 @@ class ConversationListView(ConversationViewBase):
items = items.filter(
lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED
)
# Две вкладки ожидания (макет «Очередь и уведомления», кадр Q3): общая
# очередь — диалоги без ответственного, их берёт кто угодно; «на мне» —
# назначенные лично и ждущие, пока их возьмут.
if params.get("queue") == "1":
items = items.filter(
lifecycle=LifecycleState.OPEN,
control_mode=ControlMode.PAUSED,
assigned_operator__isnull=True,
)
if params.get("waitingOnMe") == "1":
items = items.filter(
lifecycle=LifecycleState.OPEN,
control_mode=ControlMode.PAUSED,
assigned_operator_id=request.user.id,
)
query = params.get("q", "").strip()
if query:
from django.contrib.postgres.search import SearchQuery, SearchVector
@@ -171,6 +188,28 @@ class ConversationDetailView(ConversationViewBase):
}
)
def delete(self, request: Request, conversation_id: int) -> Response:
"""Удалить диалог вместе с перепиской — насовсем.
Право есть только у владельца и администратора: оператор закрывает
диалог и помечает спам, но не стирает историю организации.
"""
try:
conversation = self._conversation(
request, conversation_id, "conversations.operate"
)
except Conversation.DoesNotExist:
return Response({"detail": t("conversations.not_found")}, status=404)
if not can_administer_access(request.tenant_context.membership):
return Response(
{"detail": t("conversations.delete_admin_only")}, status=403
)
# Запись журнала — до удаления: после него у диалога уже нет ни строки,
# ни идентификатора, который можно было бы записать.
self._audit(request, "deleted", conversation)
delete_conversation(context=request.tenant_context, conversation=conversation)
return Response(status=204)
class ConversationClaimView(ConversationViewBase):
required_capability = "conversations.operate"
@@ -469,8 +508,11 @@ class ConversationAssigneeView(ConversationViewBase):
if membership is None:
return Response({"detail": t("admin.employee_not_found")}, status=400)
assignee = membership.user
conversation.assigned_operator = assignee
conversation.save(update_fields=["assigned_operator"])
conversation = assign_operator(
context=request.tenant_context,
conversation_id=conversation.id,
assignee=assignee,
)
self._audit(request, "assignee_changed", conversation)
return Response(
{
@@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone
from chatballs.calls.maintenance import expire_stale_calls
from chatballs.conversations.escalation import sweep_waiting_conversations
from chatballs.conversations.maintenance import close_stale_conversations
from chatballs.conversations.poller import poll_all_messengers
from chatballs.events.handlers import dispatch
@@ -22,6 +23,9 @@ logger = logging.getLogger(__name__)
MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles
MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs)
CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect)
# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом:
# проверка дешёвая, а повтор гасится dedup-ключом уровня.
QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps
class Command(BaseCommand):
@@ -39,6 +43,7 @@ class Command(BaseCommand):
last_poll = 0.0
last_maintenance = 0.0
last_call_sweep = 0.0
last_queue_sweep = 0.0
while True:
try:
event = claim_next_outbox_event()
@@ -91,6 +96,14 @@ class Command(BaseCommand):
expire_stale_calls(context)
except Exception: # pragma: no cover
logger.exception("Call sweep cycle failed")
if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
last_queue_sweep = now
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
sweep_waiting_conversations(context)
except Exception: # pragma: no cover
logger.exception("Waiting queue sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
+36 -8
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file",
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
"admin.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found",
@@ -54,7 +53,10 @@ MESSAGES: dict[str, object] = {
"conversations.not_a_voice_message": "This is not a voice message",
"conversations.not_found": "Conversation not found",
"conversations.note_too_long": "The note is longer than 4000 characters",
"conversations.delete_admin_only": "Only the owner or an administrator can delete a conversation",
"conversations.restore_admin_only": "Only an administrator can restore a conversation",
"conversations.system.assigned_to": "Conversation assigned to {operator}",
"conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue",
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
"conversations.system.call_accepted": "The customer accepted the call invitation",
@@ -97,11 +99,23 @@ MESSAGES: dict[str, object] = {
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
"notifications.new_message": "New message · {contact}",
"notifications.operator_needed": "An operator is needed · {contact}",
"notifications.still_waiting": "Conversation is still waiting · {contact}",
"notifications.still_waiting_body": "Nobody has picked this conversation up. Open it if you can reply.",
"notifications.waiting_unattended": "Nobody is picking this conversation up · {contact}",
"notifications.waiting_unattended_body": "The customer has been waiting longer than usual and the queue is not being worked.",
"notifications.assigned_to_you": "A conversation was assigned to you · {contact}",
"notifications.assignment_expired": "Conversation is unassigned again · {contact}",
"notifications.voice_without_transcript": "A voice message without a transcript",
"onboarding.membership_required": "Onboarding is available to organization members",
"onboarding.unknown_action": "Unknown onboarding action",
"portals.article_not_found": "Article not found",
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
"portals.file_not_found": "File not found",
"portals.helpful_must_be_boolean": "helpful must be a boolean",
"portals.installation_domain_missing": (
"The installation address has no domain: set one in Settings, "
"otherwise there is nowhere to host the portal"
),
"portals.invalid_article": "Invalid article",
"portals.invalid_article_data": "Invalid article data",
"portals.invalid_category": "Invalid category",
@@ -111,7 +125,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Invalid section",
"portals.not_found": "Portal not found",
"portals.section_not_found": "Section not found",
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
"profile.choose_photo_file": "Choose a photo file",
"profile.link_not_found": "Link not found",
"profile.notification_bot_not_found": "Notification bot not found",
@@ -184,6 +198,7 @@ MESSAGES: dict[str, object] = {
"audit.action_contacts_unmerged": "Contacts unmerged",
"audit.action_conversation_contact_updated": "Conversation contact changed",
"audit.action_conversations_archived": "Conversation moved to the archive",
"audit.action_conversations_deleted": "Conversation deleted",
"audit.action_conversations_assignee_changed": "Conversation assignee changed",
"audit.action_conversations_claimed": "Conversation taken",
"audit.action_conversations_closed": "Conversation closed",
@@ -247,6 +262,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
@@ -332,6 +348,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Invalid credentials",
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
@@ -342,6 +359,10 @@ MESSAGES: dict[str, object] = {
"identity.totp_challenge_inactive": "TOTP challenge is not active",
"notifications.ids_list_or_all": "ids must be a list or use all=true",
"notifications.push_types_list": "pushTypes must be a list",
"notifications.enabled_bool": "enabled must be true or false",
"settings.queue_minutes_range": "The delay is set in minutes, from 1 to 1440",
"settings.queue_policy_forbidden": "Only someone who manages settings can change these delays",
"notifications.unknown_transport": "Unknown delivery channel",
"platform.idempotency_key_required": "Idempotency-Key header is required",
"portals.articles_non_empty": "articles must be a non-empty list",
"profile.current_password_invalid": "Current password is invalid",
@@ -352,8 +373,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Conversation · {channel}",
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
"ai.transcription_failed": "Transcription failed: {error}",
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
"ai.transcription_denied": "The provider refused the transcription request: check the key and the access to the transcription model in the integration settings",
"ai.transcription_failed": "The provider could not transcribe the recording. Try again",
"ai.transcription_unreachable": "The transcription provider is unavailable. Try again",
"ai.transcription_unsupported": "This provider cannot transcribe speech. Pick another integration for transcription on the agent card",
"api.expected_integer": "{name}: an integer is expected",
"api.expected_positive": "{name}: a number greater than zero is expected",
"api.expected_record_id": "{name}: a record identifier is expected",
@@ -474,10 +497,15 @@ MESSAGES: dict[str, object] = {
"ai.unexpected_provider_response": "Unexpected provider response: {error}",
"settings.base_url_rejected": "Base URL: {error}",
"settings.proxy_url_rejected": "Proxy: {error}",
"notifications.type_dialog_waiting": "A dialog is waiting for an operator",
"notifications.type_dialog_new_message": "A new message in a dialog",
"notifications.type_release_published": "An agent release was published",
"notifications.type_integration_error": "Integration error",
"notifications.type_new_dialog": "A new conversation",
"notifications.type_operator_requested": "A customer asked for a person",
"notifications.type_dialog_new_message": "A new message in my conversation",
"notifications.type_dialog_assigned": "A conversation was assigned to me",
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
"notifications.type_ai_stopped": "AI stopped by an error",
"integrations.check_rejected": "The provider refused the check ({status})",
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
"integrations.check_no_connection": "No connection: {error}",
"integrations.check_api_key_missing": "The API key is not set",
"integrations.check_base_url_missing": "Base URL is not set",
+36 -8
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена",
@@ -58,8 +57,11 @@ MESSAGES: dict[str, object] = {
"conversations.not_a_voice_message": "Это не голосовое сообщение",
"conversations.not_found": "Диалог не найден",
"conversations.note_too_long": "Заметка длиннее 4000 символов",
"conversations.delete_admin_only": "Удалить диалог может владелец или администратор",
"conversations.restore_admin_only": "Восстановить диалог может только администратор",
"conversations.system.ai_handed_over": "AI передал диалог оператору",
"conversations.system.assigned_to": "Диалог назначен на {operator}",
"conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь",
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
@@ -102,10 +104,22 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "Новое сообщение · {contact}",
"notifications.operator_needed": "Нужен оператор · {contact}",
"notifications.voice_without_transcript": "Голосовое без расшифровки",
"notifications.still_waiting": "Диалог всё ещё ждёт · {contact}",
"notifications.still_waiting_body": "Диалог никто не взял. Откройте его, если можете ответить.",
"notifications.waiting_unattended": "Диалог никто не берёт · {contact}",
"notifications.waiting_unattended_body": "Клиент ждёт ответа дольше обычного, и очередь не разбирают.",
"notifications.assigned_to_you": "Вам назначен диалог · {contact}",
"notifications.assignment_expired": "Диалог снова ничей · {contact}",
"onboarding.membership_required": "Онбординг доступен участнику организации",
"onboarding.unknown_action": "Неизвестное действие онбординга",
"portals.article_not_found": "Статья не найдена",
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
"portals.file_not_found": "Файл не найден",
"portals.helpful_must_be_boolean": "helpful должен быть boolean",
"portals.installation_domain_missing": (
"Адрес установки задан без домена: укажите домен в «Настройках», "
"иначе портал негде разместить"
),
"portals.invalid_article": "Некорректная статья",
"portals.invalid_article_data": "Некорректные данные статьи",
"portals.invalid_category": "Некорректная категория",
@@ -115,7 +129,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Некорректный раздел",
"portals.not_found": "Портал не найден",
"portals.section_not_found": "Раздел не найден",
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
"profile.choose_photo_file": "Выберите файл фото",
"profile.link_not_found": "Привязка не найдена",
"profile.notification_bot_not_found": "Бот уведомлений не найден",
@@ -188,6 +202,7 @@ MESSAGES: dict[str, object] = {
"audit.action_contacts_unmerged": "Разъединение контактов",
"audit.action_conversation_contact_updated": "Изменён контакт диалога",
"audit.action_conversations_archived": "Диалог отправлен в архив",
"audit.action_conversations_deleted": "Диалог удалён",
"audit.action_conversations_assignee_changed": "Изменён ответственный за диалог",
"audit.action_conversations_claimed": "Диалог взят в работу",
"audit.action_conversations_closed": "Диалог закрыт",
@@ -251,6 +266,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
@@ -336,6 +352,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Неверный email или пароль",
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
@@ -346,6 +363,10 @@ MESSAGES: dict[str, object] = {
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
"notifications.ids_list_or_all": "ids — список, либо all=true",
"notifications.push_types_list": "pushTypes — список",
"notifications.enabled_bool": "enabled — да или нет",
"settings.queue_minutes_range": "Срок указывается в минутах: от 1 до 1440",
"settings.queue_policy_forbidden": "Менять сроки может тот, кто управляет настройками",
"notifications.unknown_transport": "Неизвестный способ доставки",
"platform.idempotency_key_required": "Нужен заголовок Idempotency-Key",
"portals.articles_non_empty": "articles — непустой список",
"profile.current_password_invalid": "Текущий пароль неверен",
@@ -356,8 +377,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Диалог · {channel}",
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
"ai.transcription_failed": "Расшифровка не удалась: {error}",
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
"ai.transcription_denied": "Провайдер не принял запрос на расшифровку: проверьте ключ и доступ к модели расшифровки в настройках интеграции",
"ai.transcription_failed": "Провайдер не смог расшифровать запись. Попробуйте ещё раз",
"ai.transcription_unreachable": "Провайдер расшифровки недоступен. Попробуйте ещё раз",
"ai.transcription_unsupported": "Этот провайдер не умеет расшифровывать речь. Выберите на карточке агента другую интеграцию для расшифровки",
"api.expected_integer": "{name}: ожидается целое число",
"api.expected_positive": "{name}: ожидается число больше нуля",
"api.expected_record_id": "{name}: ожидается идентификатор записи",
@@ -478,10 +501,15 @@ MESSAGES: dict[str, object] = {
"ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}",
"settings.base_url_rejected": "Base URL: {error}",
"settings.proxy_url_rejected": "Прокси: {error}",
"notifications.type_dialog_waiting": "Диалог ждёт оператора",
"notifications.type_dialog_new_message": "Новое сообщение в диалоге",
"notifications.type_release_published": "Опубликован релиз агента",
"notifications.type_integration_error": "Ошибка интеграции",
"notifications.type_new_dialog": "Новый диалог",
"notifications.type_operator_requested": "Клиент запросил оператора",
"notifications.type_dialog_new_message": "Новое сообщение в моём диалоге",
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
"integrations.check_no_connection": "Нет связи: {error}",
"integrations.check_api_key_missing": "Не указан API-ключ",
"integrations.check_base_url_missing": "Не указан Base URL",
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone"]
list_display = ["slug", "name", "timezone"]
search_fields = ["slug", "name"]
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
return {
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения.
"language": organization.language,
@@ -28,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
class OrganizationSettingsInput:
name: str
timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили.
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language)
if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")})
@@ -52,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
raise ValidationError(
{"timezone": t("admin.invalid_timezone")}
) from error
if currency != "RUB":
raise ValidationError(
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
@transaction.atomic
@@ -67,15 +62,14 @@ def update_organization_settings(
context: TenantContext,
data: OrganizationSettingsInput,
) -> Organization:
clean = _validate_input(data)
clean = validate_organization_settings(data)
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
)
organization.name = clean.name
organization.timezone = clean.timezone
organization.currency = clean.currency
organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"])
organization.save(update_fields=["name", "timezone", "language"])
return organization
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
data=OrganizationSettingsInput(
name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)),
),
)
@@ -293,37 +292,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
return actors
class LaunchChecklistView(APIView):
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
class OnboardingView(APIView):
"""Состояние онбординга «Начало работы» для текущего человека.
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
в организации: у каждого человека свои, и один администратор не прячет
визард команде.
"""
permission_classes = [HasCapability]
required_capability = "settings.view"
def get(self, request: Request) -> Response:
from chatballs.channels.models import Channel
from chatballs.identity.models import OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
membership = request.tenant_context.membership
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
}
)
organization_id = request.tenant_context.organization_id
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
connection_bound = Integration.objects.filter(
def post(self, request: Request) -> Response:
"""Закрыть визард или отметить его пройденным.
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
снимает оба признака: визард снова открывается по ссылке и пилюле.
"""
membership = request.tenant_context.membership
if membership is None:
return Response({"detail": t("onboarding.membership_required")}, status=403)
action = str(request.data.get("action") or "dismiss")
now = django_timezone.now()
if action == "dismiss":
membership.onboarding_dismissed_at = now
elif action == "complete":
membership.onboarding_dismissed_at = now
membership.onboarding_completed_at = now
elif action == "restart":
membership.onboarding_dismissed_at = None
membership.onboarding_completed_at = None
else:
return Response({"detail": t("onboarding.unknown_action")}, status=400)
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
"completedAt": _isoformat(membership.onboarding_completed_at),
}
)
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def onboarding_steps(organization_id: int) -> dict[str, bool]:
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
from chatballs.conversations.models import Conversation
from chatballs.identity import instance_settings
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
organization = Organization.objects.filter(pk=organization_id).first()
return {
"providerConnected": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.LLM_PROVIDER,
).exists(),
"agentActive": AIAgent.objects.filter(
organization_id=organization_id,
status=AIAgentStatus.ACTIVE,
).exists(),
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
"connectionBound": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.MESSENGER,
channel__isnull=False,
).exists()
employee_invited = (
OrganizationMembership.objects.filter(
organization_id=organization_id
).count()
> 1
or request.tenant_context.organization.invitations.exists()
)
return Response(
{
"agentCreated": agent_created,
"connectionBound": connection_bound,
"employeeInvited": employee_invited,
"done": agent_created and connection_bound and employee_invited,
}
)
).exists(),
"widgetPublished": WebChatWidget.objects.filter(
organization_id=organization_id,
status=WebChatWidgetStatus.PUBLISHED,
).exists(),
"employeeInvited": (
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
or (organization is not None and organization.invitations.exists())
),
# Шаг про саму установку, а не про организацию: домен и исходящая почта
# общие для всех организаций на сервере.
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
}
@@ -111,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "audit.action_organization_provisioned",
"organization.created": "audit.action_organization_created",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
@@ -147,6 +148,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"conversations.group_changed": "audit.action_conversations_group_changed",
"conversations.priority_changed": "audit.action_conversations_priority_changed",
"conversations.closed": "audit.action_conversations_closed",
"conversations.deleted": "audit.action_conversations_deleted",
"conversations.archived": "audit.action_conversations_archived",
"conversations.unarchived": "audit.action_conversations_unarchived",
"conversations.marked_spam": "audit.action_conversations_marked_spam",
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
class ProfileUpdateView(APIView):
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
# Членства роли app без контекста не видны: сначала каталог входа, затем
# каждое членство читается в контексте своей организации — как в
# identity.auth.common._user_payload.
oldest: tuple[object, int, str] | None = None
for route in membership_routes_for_user(request.user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
.first()
)
if membership is None:
continue
key = (membership.created_at, membership.id, membership.organization.language or "")
if oldest is None or key[:2] < oldest[:2]:
oldest = key
return oldest[2] if oldest is not None else ""
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
defaults={
"name": "Demo",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
)
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
@@ -23,9 +23,9 @@ urlpatterns = [
name="organization-logo",
),
path(
"launch-checklist/",
administration_views.LaunchChecklistView.as_view(),
name="launch-checklist",
"onboarding/",
administration_views.OnboardingView.as_view(),
name="onboarding",
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
@@ -30,9 +30,6 @@
"how-to-measure",
"lead-times"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"returns-and-exchanges",
"your-account"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"your-account",
"returns-and-exchanges"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -76,7 +76,7 @@
],
"notifications": [
{
"type": "DIALOG_WAITING",
"type": "OPERATOR_REQUESTED",
"level": "WARNING",
"title": "Dmitry Orlov is waiting for an operator",
"body": "Web Chat: the client asked for a member of staff. Waiting 6 min.",
@@ -107,7 +107,7 @@
"minutesAgo": 4
},
{
"type": "INTEGRATION_ERROR",
"type": "AI_STOPPED",
"level": "CRITICAL",
"title": "Telegram · delivery bot: connection error",
"body": "401 Unauthorized: bot token was revoked in @BotFather. Check the token in the connection settings.",
@@ -116,7 +116,7 @@
"minutesAgo": 4200
},
{
"type": "RELEASE_PUBLISHED",
"type": "DIALOG_ASSIGNED",
"level": "SUCCESS",
"title": "Chatballs update: voice messages",
"body": "Operators can send voice messages to Telegram and MAX, and incoming ones are transcribed with a single button.",
@@ -136,9 +136,9 @@
"connection": "tg",
"externalChatId": "482900010",
"pushTypes": [
"DIALOG_WAITING",
"OPERATOR_REQUESTED",
"DIALOG_NEW_MESSAGE",
"INTEGRATION_ERROR"
"AI_STOPPED"
]
},
{
@@ -146,7 +146,7 @@
"connection": "tg",
"externalChatId": "482900011",
"pushTypes": [
"DIALOG_WAITING"
"OPERATOR_REQUESTED"
]
}
],
@@ -30,9 +30,6 @@
"kak-snyat-merki",
"sroki-dostavki"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"vozvrat-i-obmen",
"lichnyj-kabinet"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"lichnyj-kabinet",
"vozvrat-i-obmen"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -76,7 +76,7 @@
],
"notifications": [
{
"type": "DIALOG_WAITING",
"type": "OPERATOR_REQUESTED",
"level": "WARNING",
"title": "Дмитрий Орлов ждёт оператора",
"body": "Web Chat: клиент попросил сотрудника. Ждёт 6 мин.",
@@ -107,7 +107,7 @@
"minutesAgo": 4
},
{
"type": "INTEGRATION_ERROR",
"type": "AI_STOPPED",
"level": "CRITICAL",
"title": "Telegram · бот доставки: ошибка подключения",
"body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.",
@@ -116,7 +116,7 @@
"minutesAgo": 4200
},
{
"type": "RELEASE_PUBLISHED",
"type": "DIALOG_ASSIGNED",
"level": "SUCCESS",
"title": "Обновление Chatballs: голосовые сообщения",
"body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.",
@@ -136,9 +136,9 @@
"connection": "tg",
"externalChatId": "482900010",
"pushTypes": [
"DIALOG_WAITING",
"OPERATOR_REQUESTED",
"DIALOG_NEW_MESSAGE",
"INTEGRATION_ERROR"
"AI_STOPPED"
]
},
{
@@ -146,7 +146,7 @@
"connection": "tg",
"externalChatId": "482900011",
"pushTypes": [
"DIALOG_WAITING"
"OPERATOR_REQUESTED"
]
}
],
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
agent.persona = item.get("persona", "")
agent.tone = item.get("tone", "")
agent.instructions = item.get("instructions", "")
agent.limits = item.get("limits", {})
if agent.status in ("ACTIVE", "DISABLED"):
agent.provider_integration = llm
agent.model = (llm.config or {}).get("default_model", "demo")
@@ -256,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=prompt,
completion_tokens=0 if failed else completion,
total_tokens=prompt + (0 if failed else completion),
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
latency_ms=random.randint(900, 4200),
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
error="Provider timeout after 30s" if failed else "",
@@ -274,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=tokens,
completion_tokens=0,
total_tokens=tokens,
cost_micros=int(tokens * 0.02),
latency_ms=random.randint(200, 900),
status=LlmInvocationStatus.SUCCESS,
)
@@ -18,6 +18,7 @@ from chatballs.calls.models import (
ParticipantConnectionState,
ParticipantSide,
)
from chatballs.conversations.queue_models import policy_for
from chatballs.identity.demo_seed import manifest
from chatballs.identity.demo_seed.loaders.common import backdate, moment, now
from chatballs.identity.demo_seed.refs import DemoRefs
@@ -28,7 +29,9 @@ from chatballs.notifications.models import (
NotificationAudience,
NotificationLevel,
NotificationRead,
NotificationTransport,
)
from chatballs.notifications.preferences import update_preference
from chatballs.tenancy.context import TenantContext
FINISHED = {
@@ -44,6 +47,9 @@ FINISHED = {
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("operations", refs.language)
current = now()
# Пороги очереди — данные организации, а не установки: демо-организация
# заводит их со значениями по умолчанию, как завела бы живая.
policy_for(refs.organization)
for item in data.get("calls", []):
_ensure_call(refs, item, current)
for item in data.get("notifications", []):
@@ -55,9 +61,15 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
defaults={
"organization": refs.organization,
"external_chat_id": item["externalChatId"],
"push_types": item.get("pushTypes", []),
},
)
update_preference(
organization_id=refs.organization.id,
user_id=refs.users[item["user"]].id,
transport=NotificationTransport.MESSENGER,
enabled=True,
types=item.get("pushTypes", []),
)
for item in data.get("messengerBindingCodes", []):
MessengerBindingCode.objects.get_or_create(
code=item["code"],
@@ -258,11 +258,39 @@ def email_from_address() -> str:
return str(settings.DEFAULT_FROM_EMAIL)
def turn_config() -> tuple[list[str], int]:
"""Адреса TURN и время жизни credentials из настроек установки.
# Порт relay в коробке: на нём coturn слушает и TURN, и STUN (compose.yaml).
TURN_PORT = 3478
Пустой список означает «relay не настроен»: звонки идут напрямую и через
STUN. Переменная окружения, если задана, побеждает.
def default_turn_urls(host: str = "") -> list[str]:
"""Адреса relay, которые работают в коробке без единой настройки.
Relay стоит на том же сервере и на том же адресе, что и сама установка, —
адрес известен с мастера первого запуска, и заставлять человека вписывать
его руками незачем. UDP идёт первым, TCP — запасным для сетей, где UDP
режут.
"""
host = host or public_host()
if not host:
return []
return [
f"turn:{host}:{TURN_PORT}?transport=udp",
f"turn:{host}:{TURN_PORT}?transport=tcp",
]
def default_stun_urls(host: str = "") -> list[str]:
"""STUN отдаёт тот же coturn на том же порту."""
host = host or public_host()
return [f"stun:{host}:{TURN_PORT}"] if host else []
def turn_config() -> tuple[list[str], int]:
"""Адреса TURN и время жизни credentials.
Порядок: переменная окружения (если её всё-таки задали), затем то, что
владелец вписал в «Настройки», затем адреса коробки по адресу установки.
Пустой список остаётся только там, где адрес установки ещё не известен.
"""
from django.conf import settings
@@ -276,6 +304,6 @@ def turn_config() -> tuple[list[str], int]:
# настройках процесса.
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
if row is None or not row.turn_urls.strip():
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
return default_turn_urls(), settings.CHATBALLS_CALL_TURN_TTL_SECONDS
urls = [line.strip() for line in row.turn_urls.splitlines() if line.strip()]
return urls, row.turn_ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS
@@ -17,6 +17,7 @@ from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_lang
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.identity.instance_settings import (
InstanceSettings,
default_turn_urls,
email_connection,
email_from_address,
invalidate_cache,
@@ -50,7 +51,13 @@ def instance_payload(row: InstanceSettings) -> dict:
"turn": {
# Секрет общий с coturn и лежит в томе секретов: наружу не отдаём
# и в настройках не показываем — вводить его человеку не нужно.
"urls": [line for line in row.turn_urls.splitlines() if line.strip()],
#
# Адреса показываются действующие: пока владелец их не менял, это
# relay коробки на адресе установки. Пустое поле означало бы, что
# звонки через relay не работают, пока человек что-то впишет, —
# а они работают сразу после установки.
"urls": [line for line in row.turn_urls.splitlines() if line.strip()]
or default_turn_urls(),
"ttlSeconds": row.turn_ttl_seconds,
"secretReady": bool(settings.CHATBALLS_CALL_TURN_SECRET),
},
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
return _invitation_for_token(token, accepted=False)
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
"""Приглашение по токену из письма — без tenant-контекста на входе.
Ссылка /join приходит до входа в организацию, а таблица приглашений и
строка организации роли app без контекста не видны (tenancy/0003, 0033).
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
и приглашение читается уже в её контексте — вместе с организацией, чтобы
вызывающий код мог обращаться к ней и после выхода из контекста.
"""
if not token:
return None
return OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=True,
token_hash = _token_hash(token)
route = invitation_route(token_hash)
if route is None:
return None
query = OrganizationInvitation.objects.select_related("organization").filter(
id=route.resource_id,
organization_id=route.organization_id,
token_hash=token_hash,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
)
if accepted:
query = query.filter(accepted_at__isnull=False)
else:
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
with tenant_atomic(route.organization_id):
return query.first()
@transaction.atomic
@@ -314,16 +338,13 @@ def _already_accepted_for(
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
invitation = OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=False,
revoked_at__isnull=True,
).first()
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
with tenant_atomic(invitation.organization_id):
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
if membership is None:
return None
return AcceptedInvitation(
@@ -0,0 +1,25 @@
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
# в организации: у каждого он свой, и закрытие одним администратором не прячет
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
# «не закрывал» и увидят визард при следующем входе.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0037_invitation_membership_fields"),
]
operations = [
migrations.AddField(
model_name="organizationmembership",
name="onboarding_dismissed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="organizationmembership",
name="onboarding_completed_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
# снят вместе с лимитами.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("identity", "0038_membership_onboarding_dismissed_at"),
]
operations = [
migrations.RemoveField(
model_name="organization",
name="currency",
),
]
+9 -2
View File
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
# без этой отметки принимался бы второй раз ещё полторы минуты.
totp_last_counter = models.BigIntegerField(default=0)
# Внешний вид — глобальная настройка пользователя (не membership):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
@@ -125,7 +125,6 @@ class Organization(models.Model):
default=OrganizationStatus.ACTIVE,
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
@@ -184,6 +183,14 @@ class OrganizationMembership(models.Model):
phone = models.CharField(max_length=32, blank=True)
totp_required = models.BooleanField(default=False)
blocked_at = models.DateTimeField(null=True, blank=True)
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
# членстве, а не на организации: иначе первый же закрывший спрятал бы
# визард всей команде. NULL — не закрывал, значит увидит при следующем
# входе, включая тех, кто работает в системе давно.
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
# шаге, и тогда возвращаться к настройке ещё есть зачем.
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -0,0 +1,142 @@
"""Создание организации человеком из интерфейса.
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
на страницу с полями организации; тот, кто её заполнил, становится владельцем
новой организации и сразу в неё переключается. Это второй путь появления
организации рядом с платформенным провижинингом (platform.provisioning_service):
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
заводит её себе.
Кто может: администратор установки и любой, у кого есть роль владельца или
администратора хотя бы в одной организации. Сотрудник, работающий только в
чате, чужую установку организациями не засевает.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.administration_services import (
OrganizationSettingsInput,
validate_organization_settings,
)
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class CreatedOrganization:
organization: Organization
membership: OrganizationMembership
def can_create_organization(user: HumanUser) -> bool:
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
if not user.is_active:
return False
if user.is_instance_admin:
return True
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
role = (
OrganizationMembership.objects.filter(
id=route.resource_id, user=user, blocked_at__isnull=True
)
.values_list("role", flat=True)
.first()
)
if role in MANAGER_ROLES:
return True
return False
def unique_organization_slug(name: str) -> str:
"""Слаг из имени, уникальный среди организаций установки.
Проверка идёт через каталог организаций: роль app без контекста строк
организаций не видит (tenancy/0033).
"""
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def create_organization(
*, data: OrganizationSettingsInput, owner: HumanUser
) -> CreatedOrganization:
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
Порядок тот же, что у мастера первого запуска (identity.setup): id
выделяется заранее, строка вставляется уже в контексте этого id — иначе
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
"""
clean = validate_organization_settings(data)
with transaction.atomic():
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
organization = Organization(
id=organization_id,
name=clean.name,
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
language=clean.language,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
membership = OrganizationMembership.objects.create(
user=owner,
organization=organization,
role=EmployeeRole.OWNER,
# Должность — текстом на языке организации, как в провижининге.
position_title=t("setup.owner_position", language=customer_language(organization)),
totp_required=False,
)
record_audit_event(
action="organization.created",
actor=owner,
organization=organization,
object_type="Organization",
object_id=str(organization.public_id),
payload={"organizationName": organization.name},
)
enqueue_event(
DomainEvent(
aggregate_type="Organization",
aggregate_id=str(organization.public_id),
event_type="organization.provisioned",
payload={},
tenant_context=TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
actor_user=owner,
),
)
)
return CreatedOrganization(organization=organization, membership=membership)
@@ -0,0 +1,12 @@
from django.urls import path
from chatballs.identity import organization_views
urlpatterns = [
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
path(
"options/",
organization_views.OrganizationCreateOptionsView.as_view(),
name="organization-create-options",
),
]
@@ -0,0 +1,75 @@
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
(auth.invitations): обновлённая учётная запись со списком членств и публичный
id организации, в которую интерфейсу переключиться.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
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.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
)
from chatballs.identity.administration_services import OrganizationSettingsInput
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.organization_creation import (
can_create_organization,
create_organization,
)
def _forbidden() -> Response:
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
class OrganizationCreateOptionsView(APIView):
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
return Response(
{
"timezones": administration_timezones(),
"languages": administration_languages(),
"currencies": ["RUB"],
}
)
class OrganizationCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
body = request.data if isinstance(request.data, dict) else {}
try:
created = create_organization(
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
language=str(body.get("language", "")),
),
owner=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response(
{
"user": _user_payload(request.user),
"organizationPublicId": str(created.organization.public_id),
},
status=201,
)
+20 -2
View File
@@ -15,6 +15,12 @@ from chatballs.identity.models import EmployeeRole, OrganizationMembership
# сохраняется; scope-модель и отделы упразднены.
# Руководство организации: видит всё независимо от групп. Набор один и тот же
# в проверке доступа, в видимости диалогов и в подборе получателей уведомлений,
# поэтому записан здесь один раз.
MANAGEMENT_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class ResourceScope:
organization_id: int
@@ -47,6 +53,18 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool:
return capability in _role_capabilities(profile.role)
def roles_with_capability(capability: str) -> frozenset[str]:
"""Роли, которым право выдано.
Нужна там, где получателей ищут пачкой: спрашивать authorize() по одному
сотруднику — это запрос в базу на каждого, то есть сотня запросов на сотню
рабочих мест. Право выводится из роли, значит и отбирать можно по роли.
"""
return frozenset(
role for role in EmployeeRole.values if capability in _role_capabilities(role)
)
def has_capability_any_scope(actor, capability: str) -> bool:
profile = _active_membership(actor)
if profile is None:
@@ -56,7 +74,7 @@ def has_capability_any_scope(actor, capability: str) -> bool:
def can_administer_access(actor) -> bool:
profile = _active_membership(actor)
return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}
return profile is not None and profile.role in MANAGEMENT_ROLES
def conversation_visibility(actor) -> dict | None:
@@ -69,7 +87,7 @@ def conversation_visibility(actor) -> dict | None:
profile = _active_membership(actor)
if profile is None:
return {"none": True}
if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}:
if profile.role in MANAGEMENT_ROLES:
return None
from chatballs.identity.group_models import member_group_ids
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
name="Example",
slug="administration",
timezone="Europe/Moscow",
currency="RUB",
)
self.owner = HumanUser.objects.create_user(
email="owner@administration.test",
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Новая компания",
"timezone": "Asia/Yekaterinburg",
"currency": "rub",
},
format="json",
)
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
self.organization.refresh_from_db()
self.assertEqual(self.organization.name, "Новая компания")
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
self.assertEqual(self.organization.currency, "RUB")
def test_invalid_timezone_is_rejected(self) -> None:
response = self.client.patch(
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("timezone", response.json()["errors"])
def test_only_ruble_currency_is_accepted(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/",
{"currency": "USD"},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("российский рубль", response.json()["errors"]["currency"])
def test_logo_upload_download_and_delete(self) -> None:
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
uploaded = self.client.post(
@@ -4,6 +4,14 @@ from django.test import TransactionTestCase
class MembershipMigrationTests(TransactionTestCase):
"""Перенос сотрудников на членства (identity/0012) не теряет идентификаторы.
Тест уводит базу к состоянию до переноса, поэтому обязан вернуть её обратно
за собой. Иначе всё, что запускается после него в той же сессии, работает
на схеме годичной давности: откат по identity утягивает и зависимые
миграции других приложений, и чем их больше, тем разрушительнее последствия.
"""
migrate_from = [("identity", "0011_enforce_capability_registry")]
migrate_to = [("identity", "0012_membership_identity")]
@@ -0,0 +1,122 @@
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
from __future__ import annotations
import json
from django.test import TestCase, override_settings
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
URL = "/api/v1/organizations/"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OrganizationCreationTests(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
self.owner = HumanUser.objects.create_user(
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.owner,
role=EmployeeRole.OWNER,
position_title="Владелец",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _post(self, user: HumanUser, **body):
client = TenantAPIClient()
client.force_login(user)
return client.post(
URL,
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
content_type="application/json",
)
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
response = self._post(self.owner, language="en")
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
created = Organization.objects.get(public_id=payload["organizationPublicId"])
self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
# Список членств в ответе уже содержит новую организацию: интерфейсу
# есть куда переключиться без повторного запроса сессии.
self.assertEqual(
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
{str(self.first.public_id), str(created.public_id)},
)
self.assertTrue(
AuditEvent.objects.filter(
organization=created, action="organization.created", actor=self.owner
).exists()
)
def test_same_name_gets_a_distinct_slug(self) -> None:
first = self._post(self.owner).json()["organizationPublicId"]
second = self._post(self.owner).json()["organizationPublicId"]
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
self.assertEqual(len(slugs), 2)
def test_employee_cannot_create_organizations(self) -> None:
response = self._post(self.employee)
self.assertEqual(response.status_code, 403)
self.assertEqual(Organization.objects.count(), 1)
def test_instance_admin_without_memberships_can_create(self) -> None:
admin = HumanUser.objects.create_user(
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
)
response = self._post(admin)
self.assertEqual(response.status_code, 201, response.content)
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
def test_empty_name_is_a_field_error(self) -> None:
response = self._post(self.owner, name=" ")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.json()["errors"])
self.assertEqual(Organization.objects.count(), 1)
def test_anonymous_is_rejected(self) -> None:
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
self.assertIn(response.status_code, {401, 403})
def test_options_list_timezones_and_languages(self) -> None:
client = TenantAPIClient()
client.force_login(self.owner)
response = client.get(f"{URL}options/")
self.assertEqual(response.status_code, 200)
self.assertIn("Europe/Moscow", response.json()["timezones"])
self.assertTrue(response.json()["languages"])
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
("updates", "updatestate"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
+54 -1
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import imaplib
import json
import logging
import re
import smtplib
import urllib.error
import urllib.request
@@ -25,6 +27,8 @@ from django.conf import settings
from chatballs.i18n import t, tn
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
# рабочий и с валидным сертификатом — platform-api.max.ru.
@@ -46,11 +50,60 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
return response.status, data
def _error_reason(body: str) -> str:
"""Короткая причина из ответа провайдера.
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
человеку нужна одна фраза, а не то и другое целиком.
"""
try:
payload = json.loads(body)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or "")[:160]
for key in ("message", "detail", "error_description"):
if payload.get(key):
return str(payload[key])[:160]
if isinstance(error, str):
return error[:160]
text = re.sub(r"<[^>]+>", " ", body)
text = " ".join(text.split())
return text[:160]
def _http_failure(error: urllib.error.HTTPError) -> str:
"""Отказ провайдера словами, а не кодом.
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
"""
try:
body = error.read().decode("utf-8", "replace")
except (OSError, ValueError):
body = ""
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
getattr(error, "url", ""),
body[:500],
)
reason = _error_reason(body)
if reason:
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
if error.code in (401, 403):
return t("integrations.check_rejected_access", status=error.code)
return t("integrations.check_rejected", status=error.code)
def _safe(fn) -> CheckResult:
try:
return fn()
except urllib.error.HTTPError as error:
return False, f"HTTP {error.code}: {error.reason}", {}
return False, _http_failure(error), {}
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, t("integrations.check_no_connection", error=error), {}
+26 -4
View File
@@ -18,6 +18,8 @@ import ssl
import urllib.parse
import urllib.request
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
def user_agent() -> str:
"""Чем продукт представляется чужим API.
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
банит такой запрос до того, как его увидит сам провайдер: на бою это
выглядело как 403 «error code: 1010» у провайдера, который через тот же
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
проходит. Заголовок ставится на opener, поэтому свой User-Agent
конкретного запроса он не перебивает.
"""
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
def _named(opener):
opener.addheaders = [("User-Agent", user_agent())]
return opener
def build_opener(proxy_url: str, *, validate_redirect=None):
"""urllib opener, проксирующий http/https/socks5 запросы.
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
if validate_redirect is not None:
blocked.append(_GuardedRedirectHandler(validate_redirect))
if not proxy_url:
return urllib.request.build_opener(*blocked)
return _named(urllib.request.build_opener(*blocked))
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
if scheme in SOCKS_SCHEMES:
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
return _named(
urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
)
)
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
class OutboundUserAgentTests(TestCase):
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
def test_opener_introduces_the_product(self) -> None:
from chatballs.integrations.proxy import build_opener, user_agent
for proxy in ("", "http://proxy:8080"):
with self.subTest(proxy=proxy or "без прокси"):
agents = dict(build_opener(proxy).addheaders)
self.assertEqual(agents["User-Agent"], user_agent())
self.assertNotIn("urllib", agents["User-Agent"])
def test_request_keeps_its_own_agent(self) -> None:
import urllib.request
from chatballs.integrations.proxy import build_opener
request = urllib.request.Request(
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
)
opener = build_opener("")
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
class CheckFailureTextTests(TestCase):
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
def _reject(self, code: int, body: bytes):
import urllib.error
from io import BytesIO
error = urllib.error.HTTPError(
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
)
return mock.patch(
"chatballs.integrations.checks.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
body = json.dumps(
{"error": {"message": "Your API key is invalid"}}
).encode()
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("Your API key is invalid", detail)
self.assertIn("403", detail)
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertNotIn("<", detail)
self.assertIn("blocked", detail.lower())
def test_silent_refusal_tells_where_to_look(self) -> None:
with self._reject(403, b""):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("регион", detail)
class OpenRouterProviderProxyTests(TestCase):
def test_provider_routes_through_proxy_handler(self) -> None:
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more