Commit Graph
264 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 31985fc35c ✨ feat(vk): канал ВКонтакте — сообщество как точка входа
Сообщения сообщества ВКонтакте принимаются через Bots Long Poll: тот же
способ, что у Telegram и MAX, поэтому подключению не нужен ни публичный
адрес, ни доступ извне — установка за NAT работает наравне с остальными.

Позиция потока живёт в poll_marker, адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы
запись в базу на каждом цикле опроса. Ответы failed 1-3 восстанавливаются
в том же цикле, иначе подключение висело бы с протухшим ключом до
перезапуска воркера.

Идентификатор сообщества владелец не вводит: его называет сам ключ
доступа, и проверка подключения кладёт его в конфигурацию — как имя бота
у Telegram и MAX. Проверка заодно смотрит настройки сообщества: без Long
Poll и события о входящем сообщении приём невозможен, и об этом честнее
сказать сразу, а не молчать зелёным статусом. Настройки чужого сообщества
при этом не меняются. Нехватка прав у ключа объясняется словами: ВКонтакте
отвечает на неё английским «no access», из которого не видно, что включить.

Имя, логин и фото отправителя ВКонтакте в апдейте не присылает — их
забирает один users.get на пачку сообщений, а не на каждое: на оживлённом
сообществе запрос на реплику упёрся бы в частоту обращений.

Телефона и кнопки «поделиться контактом» у ВКонтакте нет, поэтому просьба
уходит обычным сообщением, как и почтой. Голосовые принимаются, но не
отправляются: провайдер ждёт ogg/opus, а композер пишет webm.

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

Проверено на живом сообществе: проверка подключения, опрос Long Poll,
приём текста, голосового и фото с подстановкой имени, логина и аватара.
Тесты транспорта и проверки подключения, каталоги переводов, ruff из
корня, tsc и vitest по internal-ui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:02:20 +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
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
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
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
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 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 ee8367e205 🐛 fix: создавать категории при импорте и восстановить добавление меток
Создавать недостающие уровни categoryPath в транзакции документа и повторно использовать существующие категории. Разрешить импорт новых путей в предпросмотре, обновить переводы и baseline.

Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов.
2026-09-14 22:36:35 +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 3c01486aa3 🐛 fix(presence): isinstance через union — на этом падал ruff в CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:47:09 +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
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 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 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 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 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 Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00
AndreyandClaude Opus 5 482fa73d95 🚨 fix(lint): привести код к своей же конфигурации ruff и включить её в CI
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом
pyproject.toml (`select = ["E","F","I","UP","B","DJ"]`), а запускали ruff из
apps/backend — там конфигурации нет, и он молча брал правила по умолчанию.
Из корня та же команда показывает 1102 нарушения вместо четырнадцати.

Что с ними сделано:

- E501 (968 из 1102) вынесен в ignore с объяснением: длину строки правит
  форматтер, а `ruff format` в этом репозитории не принят — он переписал бы
  374 файла. Порога, до которого можно поднять line-length, нет: 398 строк
  длиннее 120 символов, 222 длиннее 140. Правило, которое всегда красное,
  просто перестают читать.
- Миграции исключены из проверки: их пишет Django.
- Остальные 104 исправлены. Содержательное: assertRaises(Exception) в тесте
  доставки приглашения заменён на конкретный CallInviteDeliveryError; убрана
  мёртвая переменная identity в демо-сиде (у Conversation нет такого поля —
  единственный источник identity это контакт); порядок членов у пяти моделей
  по Django Style Guide; __str__ двум синглтонам инстанса, которые в админке
  показывались бы как «object (1)»; isinstance с кортежем → оператор |.

CI: ruff добавлен в .github/workflows/checks.yml (push и pull request — до
этого на GitHub не проверялось ничего до первого тега), в стадию validate
GitLab и в scripts/check.ps1. Везде запуск из корня репозитория, иначе
повторится та же подмена правил. Версия берётся из requirements бэкенда,
чтобы CI и образ не разъезжались.

Из pre-commit убран ruff-format: висел хук, который при первой же установке
переписал бы 374 файла — это отдельное решение, а не побочный эффект.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:39:18 +03:00
AndreyandClaude Opus 5 bd40ff38fe 🔧 fix(repo): переводы строк в .py — только LF
21 файл доехал до репозитория с `\r\r\n` внутри: строка кончается двумя
возвратами каретки и переводом строки. Python такое читает, поэтому оно и
дожило, но линуксовые инструменты спотыкаются — ruff принимает одиночный CR за
перевод строки и пишет его в вывод, портя файл дальше.

Содержимое не менялось: `git diff -w` по этим файлам пуст, различаются только
переводы строк. Правило `*.py text eol=lf` в .gitattributes закрывает повтор —
на Windows такие файлы теперь и выкладываются с LF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:00:53 +03:00
AndreyandClaude Opus 5 f74d242988 ✅ fix(tests): признак «мастер пройден» не кэшируется в тестах
Кэш живёт на весь процесс — в проде это ровно то, что нужно: после установки
признак больше не меняется, а до неё его спрашивает каждый запрос. Но в
тестах каждый тест начинается с пустой базы, и первый же из них, который
создал организацию и постучался с нелокальным Host, закрывал мастер всем
остальным в том же процессе: тесты мастера падали с «Invalid host» в
зависимости от порядка сбора.

Заодно README описывает переход на TLS так, как он теперь работает: домен
задаётся в «Настройках», шлюз выписывает на него сертификат сам, прежний
адрес остаётся принятым.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:12:01 +03:00
AndreyandClaude Opus 5 cc03c5fa3d 🔒 fix(security): границы, которые обходились через соседний эндпоинт
Разбор аудита, блоки B, C и D. Каждая правка — с тестом.

Критичное:
- 2FA снималась без пароля: POST /auth/profile/totp/start/ выключал уже
  включённую 2FA, минуя и текущий пароль, и требование организации, которые
  спрашивает соседний /disable/. Теперь 409, если 2FA включена.
- Сброс пароля по письму оставлял чужие сессии живыми — то есть не помогал
  ровно в том случае, ради которого пароль и сбрасывают. Сессии завершаются,
  как при смене пароля из профиля.
- HTTPS на домене установки не выпускался никогда: ask-эндпоинт шлюза знал
  только домены порталов. Теперь он признаёт и адрес самой установки.
- WebSocket молча не работал на любой установке с TLS: браузер держит
  __Host-cookie, а Channels ищет сессию по обычному имени, и HTTP-middleware
  на хендшейк не выполняется. Добавлен chatballs.http.ws_middleware.

Существенное:
- Повтор входящего сообщения ронял весь цикл поллинга: IntegrityError ловился
  без точки сохранения внутри чужой транзакции.
- Смена адреса в «Настройках» выбрасывала того, кто её делает. Прежний адрес
  остаётся принятым (identity.0033).
- Редирект уводил скачивание во внутреннюю сеть: политика исходящих проверяла
  только исходный адрес. Проверка висит на каждом Location.
- Портал помощи можно было повесить на адрес установки и подменить
  сотрудникам приложение своим Help Center.
- Пароль прокси уходил в ответ API целиком; теперь маскируется, а маска при
  сохранении возвращает сохранённый пароль.
- /api/v1/health/ready/ закрыт на публичной границе.

Мелочи: адрес для A-записи портала считается от адреса установки, а не от
127.0.0.1; колонка EncryptedCharField вмещает шифротекст, а не открытое
значение; WS-маршруты проверяют Origin; мёртвый require_organization_scope
убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 05:35:16 +03:00
AndreyandClaude Opus 5 8fe66385d6 ✨ feat(release): установка на чистый хост одной командой
Раньше compose монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте у человека. Плюс production-образ backend вообще
не собирался: COPY content ссылался на каталог, которого в репозитории нет,
так что релиза не существовало ни на GitHub, ни на GitLab.

Теперь весь дистрибутив — один compose.yaml со страницы релиза:

  curl -fsSL .../compose.yaml -o compose.yaml
  docker compose up -d --wait

- Caddyfile переехал в свой образ шлюза (caddy validate — в сборке),
  init-скрипты базы — в свой образ postgres, генератор секретов — в
  backend-образ. Bind-mount'ов в production-манифесте не осталось.
- Состояние установки — именованные тома вместо каталогов рабочего каталога.
  Заодно чинит загрузку файлов на Linux: том наследует владельца из образа
  (hub), тогда как bind-mount доставался контейнеру как root:root.
- scripts/pin-release-compose.py закрепляет ссылки на образы по digest и
  падает, если хоть один ключ остался подстановкой.
- GitHub-workflow собирает четыре образа и прикладывает к релизу compose.yaml
  (основной путь) и release.env (для `chatballs deploy`).
- tests/cli/test_release_compose.py держит свойство: манифест без bind-mount'ов
  и полностью закрепляем по digest.
- Из окружения шлюза убраны CHATBALLS_APP_DOMAIN и CHATBALLS_ACME_EMAIL —
  Caddyfile их не читает.

Dev-контур не меняется по смыслу: compose.dev.yaml по-прежнему собирает всё
из исходников и держит состояние в ./data.

AUDIT-TODO.md — временный список остального из аудита; удаляется целиком,
когда закрыт последний пункт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:55:17 +03:00
AndreyandClaude Opus 5 03825dfbd5 ⚡ perf(realtime): обновление по событию вместо опроса
Клиент опрашивал сервер: список раз в четыре секунды, карточку и дельту
истории — раз в три; на оператора выходило под сорок запросов в минуту, и новое
сообщение всё равно появлялось с задержкой.

- WebSocket-канал организации (channels уже стоял ради сигналинга звонков):
  аутентификация — сессией того же SPA, организация — в адресе, как в HTTP;
- событие несёт только повод обновиться, данные клиент забирает обычным
  запросом: проверка видимости остаётся в одном месте, и канал не может в ней
  ошибиться. Событие инбокса не содержит идентификаторов — сотрудник видит не
  все диалоги организации; на события диалога подписка отдельная, и сервер
  проверяет видимость перед ней;
- события шлют те же сигналы, что держат свежесть диалога: одно место на семь
  мест создания сообщений и на все изменения состояния;
- опрос остался запасным путём и замедляется до тридцати секунд, пока канал
  жив: при обрыве всё возвращается к прежнему поведению само.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:33:28 +03:00
AndreyandClaude Opus 5 f3cbf1658c ⚡ perf(db): порядок инбокса по индексу, страницы без N+1
Пагинация была правильной, но опиралась на то, что не ложится в индекс: список
диалогов сортировался по агрегату max(messages.created_at) — GROUP BY по всей
ленте на каждый запрос, — а превью строки и бейдж непрочитанных спрашивались
по два запроса на диалог, тридцать раз на страницу, каждые четыре секунды.

- Conversation.last_message_at: денормализованное поле вместо агрегата, держит
  сигнал на создание сообщения (одно место вместо семи), миграция заполняет
  существующие диалоги;
- индексы: conv_inbox_order (организация → свежесть) и conv_message_window
  (диалог → время → id) — под окно инбокса и окно истории; conv_contact_recent
  под подзапросы списка контактов; триграммы для поиска по имени и телефону;
- превью и бейдж считаются на страницу целиком (last_messages_for,
  pending_counts_for), а не построчно;
- список сотрудников: своя сортировка групп в payload отменяла prefetch, а
  организация тянулась на каждую строку — обе связи взяты в выборке;
- test_query_budget: число запросов сравнивается на разном объёме данных, так
  что вернуть N+1 незаметно больше нельзя;
- журнал аудита переведён на общий usePagedResource — он был последним списком
  со своей копией отложенного поиска и защиты от гонок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:12:34 +03:00
AndreyandClaude Opus 5 ed78617722 ✨ feat(pickers): поиск в выборе ответственного и передаче владения
Выборы работали списком без поиска: при большой команде найти человека в них
было нельзя, а справочник коллег отдавался целиком.

- /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте
  и сообщает hasMoreEmployees;
- выбор ответственного берёт справочник сам (сквозной проп employees убран из
  ChatPage и карточки контакта) и показывает строку поиска, когда коллег
  больше, чем помещается в выдачу;
- передача владения ищет кандидатов по имени, почте и должности; строка поиска
  появляется, только если администраторов больше страницы;
- в маленькой команде оба выбора выглядят как прежде — строка поиска не
  появляется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:39:11 +03:00
AndreyandClaude Opus 5 776bc89514 ⚡ perf(knowledge): библиотека знаний — страница и фильтры на сервере
Список знаний отдавался целиком, ветку категорий и фильтр по агенту отбирал
браузер, а число прикреплённых агентов считалось перебором всех карточек
агентов организации — из-за этого приходилось грузить и их.

- список знаний отдаётся страницей; ветка категорий, агент, состояние и поиск
  применяются до среза;
- счётчики знания считаются подзапросами: фильтр по агенту идёт по той же
  связи и больше не урезает «прикреплено к N агентам»;
- материал несёт agentIds, карточка — список агентов с состоянием AI: диалогу
  прикрепления и рейке карточки больше не нужен весь набор агентов;
- /agents/directory/ — компактный справочник для выпадающих выборов с именем,
  состоянием AI и числом материалов;
- четвёртый подвал со страницами (KnowledgeTableFooter) заменён общим;
- подписи «Показано N из M» в подвалах сотрудников и агентов убраны: таких
  строк нет в макетах, а счётчик уже стоит в шапке экрана.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:30:02 +03:00
AndreyandClaude Opus 5 e6a6bf77b6 ⚡ perf(ui): списки отдаются страницами, фильтры считает база
Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор,
браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой
из четырёх списков рос вместе с организацией и целиком уезжал клиенту.

Серверная часть — страницы и фильтры до среза:
- контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не
  урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом»
  и порядок — в SQL;
- сотрудники: роль, группа и поиск по имени, почте и должности;
- агенты: группа и поиск;
- порталы: статус и поиск, архивные последними;
- библиотека статей: категория с вложенными, язык, статус и поиск по последней
  редакции.

Клиент:
- один подвал со страницами на всё приложение вместо трёх разных
  (PortalTableFooter, SalesClientsPagination, TablePagination удалены);
- usePagedResource: страница принадлежит набору фильтров, гонки ответов
  отсекаются, сервер решает, какая страница существует;
- useDebounced вынесен в shared — поиск придерживает запрос;
- карточка сотрудника грузится по идентификатору, а передача владения сама
  запрашивает кандидатов: список постраничный, и нужного человека может не
  быть на открытой странице;
- App больше не тянет всех сотрудников на старте.

Выпадающие выборы (ответственный, передача владения, фильтр по агентам)
работают со справочниками; библиотеке знаний нужен серверный контракт со
счётчиками прикреплений — до него потолок в 100 карточек оставлен явным.

Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:05:02 +03:00
AndreyandClaude Opus 5 0636ef5fa9 ⚡ perf(conversations): история и инбокс грузятся окнами, а не целиком
Открытие диалога тянуло все сообщения разом: карточка несла messages без
ограничения, а workspace перезапрашивал её каждые 3 секунды — тысяча реплик
уезжала клиенту двадцать раз в минуту. Список диалогов приходил так же целиком
и обновлялся раз в 4 секунды.

Серверная часть:
- api/pagination: единый контракт — страницы (page/pageSize/total/pageCount) и
  окно по курсору (keyset) с потолками размера;
- карточка диалога больше не несёт сообщений ни в одном режиме; факт запроса
  контакта считает сервер, ленте для этого история не нужна;
- GET /conversations/<id>/messages/ — окно истории: before — вверх по ленте,
  after — дельта после последнего показанного сообщения;
- инбокс отдаётся окном по курсору с серверной сортировкой (активность или
  «ждущие первыми») и total по текущим фильтрам.

Клиент:
- useConversationHistory: хвост переписки при открытии, догрузка вверх,
  обновление дельтой вместо перезапроса всей ленты;
- useHistoryScroll: лента держит низ, подгружает старые сообщения у верхней
  границы и сохраняет место чтения при вклейке;
- useConversationList: окно инбокса, догрузка прокруткой, обновление головы
  списка без сброса прокрутки;
- поиск, вкладка и сортировка списка переехали в параметры запроса —
  фильтровать окно в браузере было бы неправильно;
- клавиатурная навигация вынесена в useDialogKeyboardNav.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:08 +03:00
AndreyandClaude Opus 5 c01bf8aef6 🔥 chore(db): мёртвые поля отменённых доменов удалены из схемы
Колонки пережили решения, которыми были отменены:

- `Organization.tax_regime` и `vat_mode` — налоговый профиль организации,
  отменён вместе с управленческой картиной продаж (ADR-HUB-0010, отменён
  ADR-CHATBALLS-0041). Миграция identity/0032;
- `Channel.allow_sales_attribution` и `allow_checkout_actions` — коммерческие
  флаги канала; ADR-CHATBALLS-0045 §4 объявил их запрещёнными безусловно, но
  колонки остались. Миграция channels/0008.

Вместе с флагами ушёл аппарат инвариантов канала. P1-P2 были их единственным
содержимым, P3-P5 сняты с сущностью Product: после удаления полей
`policy_violations()` всегда возвращала бы пустой кортеж, `PolicyInvariantError`
не могла быть возбуждена никогда, а `require_valid_policy()` стала бы
вызовом-пустышкой. В `channels/policy.py` остались только состав полей и их
отображение в payload для двух живых флагов (`allow_anonymous_sessions`,
`allow_self_reported_contact`).

Удалён `channels/test_policy_migration.py`: он проверял приведение данных
исторической миграции channels/0005 по колонкам, которых больше нет. Отсюда
622 backend-теста вместо 624 — ровно два его метода, покрытие не потеряно.

Из `identity/admin.py` колонки убраны из `list_display`: иначе админка падает
проверкой admin.E108.

Django выполняет `DROP COLUMN ... CASCADE` и молча уносит зависящие вьюхи,
поэтому зависимости проверены до миграции: три вьюхи зависят от таблиц, но ни
одна не читает эти колонки; политик RLS, constraint'ов и индексов на них нет.
После применения все 10 вьюх схемы chatballs на месте, колонок в
information_schema нет.

Проверка: `manage.py check` и `makemigrations --check` чисты, локальная БД
смигрирована, полный check.ps1 зелёный (typecheck ×2, vitest 76, CLI 9, e2e 8,
backend 622).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:44:01 +03:00
AndreyandClaude Opus 5 6a93cfcbd7 ✅ fix(tests): pytest-сьют backend снова запускается целиком
`docker compose run backend-app pytest` (то, что делает scripts/check.ps1) не
доходил до первого теста. Две независимые причины, обе давние.

1. Область сбора. В pytest.ini не было ни `testpaths`, ни `norecursedirs`, а
   compose монтирует `data/media` в `/app/apps/backend/media`. На установке с
   демо-данными это 741 каталог организаций, и сбор падал через 2:46 с
   `OSError: [Errno 12] Cannot allocate memory` на обходе медиа.

   `testpaths` перечисляет оба пакета с тестами: только `chatballs` было бы
   мало — в `chatballs_backend/test_surfaces.py` лежат проверки изоляции
   поверхностей (loopback-only admin, раздельные cookie, исключение
   gateway-эндпоинта из SSL-редиректа), и они бы молча выпали из прогона.
   `norecursedirs` добавлен сверх этого: на запуск с явным путём `testpaths`
   не распространяется.

2. Боевая функция, собираемая как тест. `ai/test_demo_provider.py` импортировал
   на уровне модуля `integrations.services.test_integration`; pytest считает
   тестом любое имя `test_*` в тестовом модуле и падал на ненайденных фикстурах
   `context` и `integration`.

   Функция не переименована намеренно: в `integrations/tests.py` и
   `integrations/test_email.py` она уже импортируется под алиасом
   `run_integration_test` — конвенция в проекте есть, в этом файле её забыли
   применить. Добавлен алиас и комментарий, зачем он.

Проверка: сбор — 625 позиций за 15 с вместо падения через 2:46; полный прогон —
624 passed, 0 failed (21 мин). Остальные наборы зелёные: internal-ui vitest
76/76, typecheck internal-ui и web-chat, CLI 9/9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:26:57 +03:00
AndreyandClaude Opus 5 e088ead06f 📝 docs: идентификаторы проектных документов приведены к CHATBALLS
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.

Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.

Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:13:33 +03:00
AndreyandClaude Opus 5 486492c1d3 🔒 fix(identity): ключ шифрования секретов в БД перестал следовать за SECRET_KEY
Ключ выводился из SECRET_KEY, а ошибка расшифровки глоталась молча. Значит
смена ключа подписи — обычное действие после утечки — делала нечитаемыми
секреты TOTP сотрудников, токены интеграций, пароль SMTP и ключи S3, и в логе
об этом не было ни строки: секреты просто становились пустыми.

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

Человек ключ по-прежнему не вводит: файла с переменными у продукта нет.

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

Проверено на живом томе: shell-вывод ключа совпадает с питоновским байт в байт
(сверено в образе pgvector/pgvector:pg16); после запуска скрипта сгенерированный
ключ равен действующему выводимому; приложение читает его из файла и
расшифровывает все пять сохранённых секретов интеграций. Четыре теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:43 +03:00
AndreyandClaude Opus 5 a2e6672424 🔥 chore: .env убран отовсюду — установка не читает файл с переменными
Переход на запуск без .env (41a4b0d) не был доведён: в дереве остались файл
`env.example`, чтение instance .env во всём deploy/cli и CI, который этот .env
сам же и создавал из `env.example`. Из-за остатков ломались две вещи.

`chatballs deploy` не работал на установке без .env: _normalize_schema_ownership
брал POSTGRES_USER и POSTGRES_DB из файла и падал с «POSTGRES_USER not set»,
хотя установка исправна. Теперь берёт те же значения по умолчанию, что compose.

`chatballs doctor` выдавал пять ложных ошибок подряд: искал в .env домены,
ACME-почту, POSTGRES_PASSWORD и CHATBALLS_SECRET_KEY. Первое задаёт владелец
в «Настройках», второе генерирует в том с секретами первый старт стека —
снаружи, с хоста, этого не видно, и проверки убраны.

Убрано: env.example (из репозитория, release bundle и README); instance_env_file()
и все его чтения в common/compose/deploy/doctor/status; --env-file instance .env
из вызова compose — остаётся только release.env с digest-пинами образов от CI;
создание .env в gateway:validate. Профиль calls и его адреса читаются из
переменных окружения — оттуда же, откуда их берёт сам compose.

Тесты CLI переведены с фиктивного .env на переменные окружения; три проверки
удалённого поведения doctor убраны. Девять оставшихся проходят вообще без .env
— это и есть проверка, что установка теперь обслуживается. Заодно у
test_custocrm_cli.py выправлены окончания строк: в файле были одиночные CR
внутри кода, отчего diff по нему больше содержательной правки.

Проверено: pytest tests/cli — 9 passed; docker compose config валиден с пустым
каталогом инстанса; bash -n чист по всем скриптам CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:29 +03:00
AndreyandClaude Opus 5 03914fba09 ⚡ perf(conversations): расшифровка ждёт провайдера без транзакции
TenantContextMiddleware заворачивает весь вызов view в транзакцию: RLS-контекст
ставится через SET LOCAL и живёт ровно столько же. Удобно и даёт запросу
атомарность, но пока идёт обращение наружу, запрос держит соединение из пула, а
пул на процесс небольшой. Расшифровка голосового ждёт провайдера до 90 секунд
(30 с таймаут на три попытки) — несколько операторов, нажавших «расшифровать»,
вставали поперёк всех остальных запросов процесса.

Появился отказ от общей транзакции: вьюха выставляет
`tenant_manages_own_transaction` и открывает tenant_atomic сама. Забытый блок
не опасен — без транзакции RLS-настройка пуста и строки просто не видны, то
есть ошибка проявится сразу, а не утечкой в чужую организацию.

`transcribe_voice_message` разложена на prepare → run → store, и вьюха держит
транзакцию только вокруг чтения и вокруг записи. Прежний вызов целиком остался
для ingest, где входящее сообщение обрабатывается одной транзакцией.

Тест на TransactionTestCase (обычный TestCase сам держит транзакцию на весь
тест) проверяет напрямую: во время вызова провайдера in_atomic_block — False,
RLS-контекст снят, результат сохранён.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 3659ce564a 🔒 fix(calls): сокет сигналинга не ждёт аутентификации вечно
Константа AUTH_TIMEOUT_CLOSE намекала на срок, но кода не было: соединение
принималось до аутентификации (иначе клиенту некуда прислать токен) и дальше
ждало первое сообщение сколько угодно долго. Неаутентифицированный клиент так
держал сокет и запись в channel layer.

Срок — 10 секунд, снимается при успешной аутентификации и при отключении.

Два теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 037be19786 ⚡ perf(identity): перебор только живых сессий, воркер чистит просроченные
Владельца сессии хранит её подписанное содержимое, отдельной колонки у
django_session нет, поэтому счётчик сессий сотрудника расшифровывал строки и
сравнивал. Перебиралась вся таблица — а просроченные строки Django сам не
удаляет и `clearsessions` не звал никто, так что стоимость просмотра карточки
сотрудника росла вместе с историей входов за всё время. Перебор ограничен
живыми сессиями, а чистку просроченных ведёт maintenance-цикл воркера.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:20 +03:00