Установка раз в шесть часов и по кнопке проверяет страницу релизов
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>
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.
Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.
Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Провижининг организаций через платформенный 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>
Настройки установки — адрес, почта, 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>
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Язык ответов агента. Поле 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>
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.
Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.
Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.
Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.
Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.
Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.
gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом
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>
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>
Кэш живёт на весь процесс — в проде это ровно то, что нужно: после установки
признак больше не меняется, а до неё его спрашивает каждый запрос. Но в
тестах каждый тест начинается с пустой базы, и первый же из них, который
создал организацию и постучался с нелокальным Host, закрывал мастер всем
остальным в том же процессе: тесты мастера падали с «Invalid host» в
зависимости от порядка сбора.
Заодно README описывает переход на TLS так, как он теперь работает: домен
задаётся в «Настройках», шлюз выписывает на него сертификат сам, прежний
адрес остаётся принятым.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разбор аудита, блоки 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>
Раньше 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>
Клиент опрашивал сервер: список раз в четыре секунды, карточку и дельту
истории — раз в три; на оператора выходило под сорок запросов в минуту, и новое
сообщение всё равно появлялось с задержкой.
- WebSocket-канал организации (channels уже стоял ради сигналинга звонков):
аутентификация — сессией того же SPA, организация — в адресе, как в HTTP;
- событие несёт только повод обновиться, данные клиент забирает обычным
запросом: проверка видимости остаётся в одном месте, и канал не может в ней
ошибиться. Событие инбокса не содержит идентификаторов — сотрудник видит не
все диалоги организации; на события диалога подписка отдельная, и сервер
проверяет видимость перед ней;
- события шлют те же сигналы, что держат свежесть диалога: одно место на семь
мест создания сообщений и на все изменения состояния;
- опрос остался запасным путём и замедляется до тридцати секунд, пока канал
жив: при обрыве всё возвращается к прежнему поведению само.
Проверено сквозь шлюз: апгрейд проходит до консьюмера, анонимное соединение
отклоняется кодом 403.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пагинация была правильной, но опиралась на то, что не ложится в индекс: список
диалогов сортировался по агрегату 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>
Выборы работали списком без поиска: при большой команде найти человека в них
было нельзя, а справочник коллег отдавался целиком.
- /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте
и сообщает hasMoreEmployees;
- выбор ответственного берёт справочник сам (сквозной проп employees убран из
ChatPage и карточки контакта) и показывает строку поиска, когда коллег
больше, чем помещается в выдачу;
- передача владения ищет кандидатов по имени, почте и должности; строка поиска
появляется, только если администраторов больше страницы;
- в маленькой команде оба выбора выглядят как прежде — строка поиска не
появляется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Список знаний отдавался целиком, ветку категорий и фильтр по агенту отбирал
браузер, а число прикреплённых агентов считалось перебором всех карточек
агентов организации — из-за этого приходилось грузить и их.
- список знаний отдаётся страницей; ветка категорий, агент, состояние и поиск
применяются до среза;
- счётчики знания считаются подзапросами: фильтр по агенту идёт по той же
связи и больше не урезает «прикреплено к N агентам»;
- материал несёт agentIds, карточка — список агентов с состоянием AI: диалогу
прикрепления и рейке карточки больше не нужен весь набор агентов;
- /agents/directory/ — компактный справочник для выпадающих выборов с именем,
состоянием AI и числом материалов;
- четвёртый подвал со страницами (KnowledgeTableFooter) заменён общим;
- подписи «Показано N из M» в подвалах сотрудников и агентов убраны: таких
строк нет в макетах, а счётчик уже стоит в шапке экрана.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор,
браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой
из четырёх списков рос вместе с организацией и целиком уезжал клиенту.
Серверная часть — страницы и фильтры до среза:
- контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не
урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом»
и порядок — в SQL;
- сотрудники: роль, группа и поиск по имени, почте и должности;
- агенты: группа и поиск;
- порталы: статус и поиск, архивные последними;
- библиотека статей: категория с вложенными, язык, статус и поиск по последней
редакции.
Клиент:
- один подвал со страницами на всё приложение вместо трёх разных
(PortalTableFooter, SalesClientsPagination, TablePagination удалены);
- usePagedResource: страница принадлежит набору фильтров, гонки ответов
отсекаются, сервер решает, какая страница существует;
- useDebounced вынесен в shared — поиск придерживает запрос;
- карточка сотрудника грузится по идентификатору, а передача владения сама
запрашивает кандидатов: список постраничный, и нужного человека может не
быть на открытой странице;
- App больше не тянет всех сотрудников на старте.
Выпадающие выборы (ответственный, передача владения, фильтр по агентам)
работают со справочниками; библиотеке знаний нужен серверный контракт со
счётчиками прикреплений — до него потолок в 100 карточек оставлен явным.
Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Открытие диалога тянуло все сообщения разом: карточка несла 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>
Колонки пережили решения, которыми были отменены:
- `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>
`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>
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.
Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.
Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ключ выводился из SECRET_KEY, а ошибка расшифровки глоталась молча. Значит
смена ключа подписи — обычное действие после утечки — делала нечитаемыми
секреты TOTP сотрудников, токены интеграций, пароль SMTP и ключи S3, и в логе
об этом не было ни строки: секреты просто становились пустыми.
Теперь ключ живёт своим файлом в томе секретов: его кладёт туда первый старт
стека, выводя из secret_key ровно тем же способом, каким это делал сам продукт.
Значение от этого не меняется, поэтому работающая установка ничего не теряет —
но ключ больше не привязан к SECRET_KEY, и подпись можно ротировать.
Человек ключ по-прежнему не вводит: файла с переменными у продукта нет.
Молчание убрано: не расшифровавшееся значение пишет предупреждение в лог, а
неверный ключ в настройке падает ImproperlyConfigured сразу, а не отдаёт пустой
секрет при первой расшифровке.
Проверено на живом томе: shell-вывод ключа совпадает с питоновским байт в байт
(сверено в образе pgvector/pgvector:pg16); после запуска скрипта сгенерированный
ключ равен действующему выводимому; приложение читает его из файла и
расшифровывает все пять сохранённых секретов интеграций. Четыре теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Переход на запуск без .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>
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>
Константа AUTH_TIMEOUT_CLOSE намекала на срок, но кода не было: соединение
принималось до аутентификации (иначе клиенту некуда прислать токен) и дальше
ждало первое сообщение сколько угодно долго. Неаутентифицированный клиент так
держал сокет и запись в channel layer.
Срок — 10 секунд, снимается при успешной аутентификации и при отключении.
Два теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владельца сессии хранит её подписанное содержимое, отдельной колонки у
django_session нет, поэтому счётчик сессий сотрудника расшифровывал строки и
сравнивал. Перебиралась вся таблица — а просроченные строки Django сам не
удаляет и `clearsessions` не звал никто, так что стоимость просмотра карточки
сотрудника росла вместе с историей входов за всё время. Перебор ограничен
живыми сессиями, а чистку просроченных ведёт maintenance-цикл воркера.
Там же исправлено голодание самого воркера: обработка события обрывала цикл
на `continue`, и при непрерывном потоке событий — а породить его может кто
угодно через публичный виджет — переставали забираться входящие сообщения и
истекать приглашения на звонки. Периодические работы теперь выполняются в
любом случае, а сон остался только когда работы нет: скорость разбора очереди
не пострадала.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Файл статьи отдавался inline с типом, который прислал загружавший, и на домене
портала. То есть `text/html` открывался как страница на чужом домене, а имя
файла не прогонялось через safe_filename. Спасала только CSP поверхности —
единственный слой, и он теряется, если установщик переопределит CHATBALLS_APP_CSP.
Теперь inline остались картинки и PDF, остальное уходит вложением; тип
приводится к известному, а незнакомый угадывается по имени файла — значение
идёт в заголовок ответа, брать его у клиента как есть нельзя. Плюс на каждый
такой ответ ставится своя `default-src 'none'; sandbox`: svg-картинка со
скриптом внутри не должна зависеть от того, не ослабят ли CSP установки.
Три теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Отметка `totp_last_used_at` писалась, но ни с чем не сравнивалась. Код живёт
30 секунд и принимается с окном ±1 интервал, значит подсмотренный код работал
второй раз ещё полторы минуты — RFC 6238 §5.2 требует запоминать интервал.
Появилось поле `totp_last_counter`: `verify_totp` возвращает номер принятого
интервала, и коды до него включительно больше не проходят. Тем же путём идёт
подтверждение при настройке 2FA — код, которым её включили, на входе не
сработает.
Заодно два соседних. Шаг с кодом доставал пользователя без `is_active`: пароль
приняли раньше, и сотрудник, отключённый между шагами, всё равно входил.
И у начатого входа не было срока — брошенная вкладка ждала код сколько угодно
долго; теперь пять минут.
Три теста.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Значение приходило заголовком и ложилось в CharField(max_length=128) записи
аудита и очереди событий как есть. Строка длиннее валила запись в БД, и первым
это ловил публичный /api/v1/auth/login/, где аудит пишется на каждую неудачную
попытку: вместо 401 приходило 500, а след входа терялся. Заголовок разрешён в
CORS, то есть это штатный клиентский ввод.
Теперь значение обрезается до 128 символов и чистится до печатного ASCII:
оно идёт и в строку лога, и в заголовок ответа.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cookie с префиксом `__Host-` браузер принимает только с самого хоста, по TLS
и без Domain — в этом весь её смысл. Но при наличии обеих сторона выигрывало
обычное имя, а его может выставить сосед по родительскому домену: приоритет
у незащищённого имени сводил защиту на нет и позволял подменить и сессию, и
CSRF-токен. Теперь защищённое имя всегда сильнее, а обычное по TLS вовсе
игнорируется — сами мы его по TLS не выдаём, значит пришло оно не от нас.
Цена — один повторный вход в тот момент, когда перед установкой впервые
появился сертификат: cookie, выданные по http, дальше не в счёт.
`Vary` ставился присваиванием и сносил «Vary: Cookie», который Django
проставляет на ответах с данными пользователя. За общим кешем это позволяло
отдать ответ одного пользователя другому. Заменено на patch_vary_headers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`base_url` и `proxy_url` сохранялись свободными строками, без проверки схемы.
Дальше ответ провайдера задавал адрес скачивания вложения, а `download_bytes`
открывал его голым `urllib.request.build_opener()` — у которого включены
обработчики file и ftp. То есть владелец организации, поставив `base_url` на
свой сервер, получал ответом ссылку `file:///run/chatballs/secrets/secret_key`
и содержимое секрета оседало вложением в диалоге. Тем же путём проверка
«Проверить» ходила по внутренним адресам и возвращала код ответа в UI.
Адреса разделены по источнику доверия (chatballs.integrations.outbound).
Настройку вводит владелец, и он вправе указать хост внутри своей сети —
self-hosted ставит рядом свой Bot API или LLM-сервер; у неё проверяется только
схема. А адрес из ответа провайдера — данные: он ограничен http/https и
публичными хостами, кроме того хоста, который владелец сам задал в `base_url`
этого подключения. При настроенном прокси проверка хоста снимается: до цели
хаб идёт не сам, а при socks5h локальный резолвер о ней и не знает.
Сбой DNS считается разрешением, а не отказом: соединение пойдёт через тот же
резолвер и упадёт там же, так что запрещать нечего, — иначе скачивание
вложений стало бы заложником доступности DNS. От DNS rebinding проверка не
спасает, и это записано в коде честно.
Второй рубеж — opener: `build_opener` штатные обработчики не убирает, но
предпочитает наследника, поэтому file, ftp и data подменены заглушками.
Это закрывает и редирект на ftp, который urllib пропускает сам.
13 тестов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Публичные endpoint'ы виджета принимают кого угодно без входа и без CSRF, и
до сих пор у них не было ни одного ограничителя. Три дыры разом.
Origin страницы-хозяина брался из поля `hostOrigin` тела запроса — раньше
заголовков. Поле заявляет клиент, поэтому ограничение по доменам виджета
(`allowed_origins`) обходилось строчкой в curl. Теперь решают заголовки,
которые ставит браузер, а `hostOrigin` читается только там, где они указывают
на нас самих: виджет живёт в iframe на нашем же origin, и узнать хозяина
оттуда больше нечем. Что это заявление, а не доказательство, записано в
докстринге `host_origin` — чтобы поверх не построили настоящий контроль.
Лимитов не было вовсе. Каждое сообщение — ход AI по ключу организации
(ingest зовёт провайдера прямо в запросе), каждый файл — место в хранилище:
один скрипт тратил чужие деньги и диск, а поток порождённых событий вдобавок
вытеснял из воркера поллинг мессенджеров. Контуров два. По адресу клиента —
для того, у кого сессии ещё нет; потолки нарочно высокие, потому что за одним
адресом мобильного оператора сидят тысячи живых посетителей. По токену сессии
— строго: за NAT адрес общий, и счёт по нему либо бьёт по соседям, либо
бесполезен. Токен для лимита достаётся из заголовка, а не из тела: иначе
двадцатимегабайтная загрузка доезжала бы до сервера прежде, чем её отобьют.
Сессия не истекала никогда. Токен лежит в localStorage: на общем компьютере
он открывал бы чужую переписку сколько угодно долго. Теперь срок считается от
последней активности (30 суток), отметка обновляется не чаще раза в час —
иначе опрос раз в 2.5 с писал бы в строку сессии четыре раза в минуту.
Виджет на 401 забывает токен и предлагает начать заново, а не висит молча.
Пять тестов. Для главного отдельно проверено, что он ловит прежнее поведение:
со старым `_origin` подделанный `hostOrigin` давал 201 вместо 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Счётчик прилипал к подписи: вкладки читались как «Диалоги3» и
«Идентификаторы1». У `.ui-underline-tabs button` не было ни gap, ни своей
типографики счётчика, а компонент используется только здесь — правило
дописано в общий стандарт, как в кадре K3: gap 6px, счётчик 11.5/600 --n-4.
Вкладка «Аудит» показывала сырьё из БД: «contacts.merged», «Contact 1»,
«SUCCESS». Причина — свой словарь подписей на пять действий диалогов в
`conversations/clients.py` мимо общего каталога журнала. Теперь подписи,
типы объектов и результаты берутся из `identity/audit_catalog.py`: те же
«Объединение контактов» и «Выполнено», что в журнале действий. Пустая
подпись по-прежнему означает «её ещё нет» и показывает код — правило
каталога не меняется. Заголовки таблицы приведены к обычному регистру
(капслок делает CSS), «АКТОР» → «Сотрудник» как в журнале.
Из текстов убраны коды решений: «(ADR-0006)» стоял в подсказке об
идентичностях и в подписи про право объединять. Макет их содержит, но
служебная лексика из макета в продукт не переносится.
Карточка «Объединённые контакты» кадра не имеет — по решению владельца
убрана до появления кадра вместе с разъединением из интерфейса: ушли
самодельная кнопка «Разъединить» мимо `Button`, обрубки «0 идентичн. ·
0 диал.» и диалог разъединения. Серверные операции merge/unmerge целы.
Шеврон «назад» больше не поворачивается на 90° из шеврона вниз: в
`icons.tsx` заведён `chevronLeft` путём из таблицы ICON макета. Тот же
поворот остался ещё в шести местах — вынесено отдельно.
Проверено на живой карточке контакта в обеих темах: вкладки «Диалоги 3»,
«Идентификаторы 1», журнал читаемый, ADR-кодов на экране нет. Бэкенд —
10 тестов contacts_merge, включая новый на читаемость журнала карточки;
фронт — typecheck и 76 тестов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вид сообщения «файл» добавили в модель без миграции, файл 0019 лежал в рабочем
дереве неотслеживаемым. Без него `makemigrations --check --dry-run` требует
ровно эту миграцию, то есть на чистом клоне проверка падает.
Только choices, схему БД не трогает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
К тесту про дубли сотрудников добавлена общая проверка: значения и подписи
уникальны во всех списках фильтров, а не только в том, где баг уже был.
Разделы и результаты приходят из каталога и продублироваться могут только
задвоенным ключом словаря — проверка ловит и это.
Заодно проверены остальные три места с distinct() в бэкенде: ai/selectors,
calls/maintenance и notifications/binding берут distinct по целым строкам либо
сортируются по полю той же строки, что и ключ, — там дублей быть не может.
Ломается только случай, когда поле сортировки не определяется выбранными
колонками, как было с временем события против полей сотрудника.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Список сотрудников для фильтра собирался values().distinct(), но у AuditEvent
есть Meta.ordering, и поля сортировки Django подмешивает в SELECT перед
DISTINCT. Уникальность считалась по паре «сотрудник + время события», то есть
по каждому событию отдельно: сотрудник попадал в фильтр столько раз, сколько
совершил действий.
Лечится сбросом сортировки — .order_by() перед .values().distinct().
Регрессионный тест проверяет именно уникальность списка: на пяти событиях
одного сотрудника плюс одно системное он давал 6 пунктов вместо 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Журнал был непригоден: последние 50 событий сплошной лентой, без фильтров и
страниц, а действие показывалось одной строкой. Из 67 действий, которые пишет
код, подписи имели 24 — остальные схлопывались в «Системное действие», то есть
две трети журнала не сообщали ничего.
Информативность:
- identity/audit_catalog.py — подписи на все действия, что пишутся, плюс два
правила против повторения: раздел выводится из префикса кода (новое действие
само попадает в свой раздел и в фильтр), а действие без подписи отдаётся с
пустым label и показывается сырым кодом моноширинным — видно, что произошло,
и видно, какую подпись дописать;
- наружу пошли поля, которые всё это время лежали в базе и не отдавались:
object_type/object_id, source_ip, correlation_id и payload. Строка таблицы
раскрывается и показывает их — разобраться в событии стало чем;
- в строке теперь подпись действия, раздел под ней, объект («Сотрудник · 12»)
и результат цветом. Дни отбиты заголовком «Сегодня»/«Вчера»/«5 сен» —
сплошной ленты больше нет.
Фильтры и страницы (всё на сервере, журнал растёт без предела):
- период пресетами (сегодня · 7 · 30 · 90 дней · всё время), считается в
часовом поясе организации, а не в UTC;
- раздел, сотрудник (включая «Систему»), результат, поиск по коду действия,
объекту, имени и почте сотрудника;
- page/pageSize со счётчиком; страница за пределами журнала отдаёт последнюю.
Списки для фильтров считаются по всей организации, а не по текущей выборке,
иначе фильтр схлопывается до одного значения и из него не выбраться;
- синонимы префиксов (conversation.* и conversations.*) ищутся вместе, иначе
раздел «Диалоги» терял половину событий.
Экран приведён в общий ритм списков раздела: своя лента и колонка 1220px по
центру, контролы — общие SearchInput/FilterDropdown/Segmented, а не свои копии.
Поиск отложен на 300 мс, ответ устаревшего запроса не затирает свежий, прошлая
страница на время загрузки гаснет, а не моргает пустотой.
Попутно: paginationItems переехал из features/sales в shared/ui-controls рядом
с TablePagination — один расчёт страниц на приложение. AdministrationPage и
administrationSection удалены: роут аудита теперь на своём AuditPage со своим
хуком, а useAdministration остался только под «Организацию» в Настройках.
Проверено: backend chatballs.identity 123 OK (6 новых тестов на фильтры,
страницы, синонимы префиксов и отсутствующую подпись), tsc + vitest 76 OK,
геометрия снята с копии экрана на реальном CSS — колонка 1220px, запас 110px
слева и справа поровну.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но
из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка»,
в списке порталов — колонка «Продукты». Решение владельца — убрать сущность
целиком вместе со всем контуром, который на ней держался.
Удалено:
- приложения `products` и `support` (контракты идентификации, снимки личности,
Product Support Token, публичные endpoints сессии);
- `Channel.product` и `requires_authenticated_product_identity` (инварианты
P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`;
- `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна
анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета;
- `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat;
- capability `products.*`, аудит-действия `products.*`, продукты в карточке
контакта и в статистике (`byProduct`);
- раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2.
У диалога один источник identity — контакт: XOR заменён на
`conversation_requires_contact`. `PUT /portals/{id}/products/` удалён,
`GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`.
Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений
вычищены от ссылок (чистая установка объектов не создаёт), существующие БД
чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги
со снимком личности не удаляются — каждому создаётся контакт с именем из снимка.
Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и
web-chat OK, миграция прогнана на локальной БД, публичный портал открывается.
В коммит также вошли накопленные незакоммиченные правки рабочего дерева
(библиотека знаний, настройки, dev-compose и nginx, макеты базлайна).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.
Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
генерирует одноразовый сервис secrets в именованный том, пароли БД —
через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.
Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.
Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.
Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Агенты (кадры G1–G5, S1): список с порядком активные → на паузе →
отключённые, цвет плитки по id агента, подключения и знания на карточке,
диалог выбора знаний, стоимость и лимиты.
Сотрудники (кадры E1–E10): список, карточка, панель создания в четыре шага,
пароль первичного доступа (показать или письмом), завершение сессий,
передача владения. Бэкенд: employee_password.py, reset-password и
revoke-sessions вместо заглушек, password_changed_at.
Общие контролы: один поиск и один фильтр-селект на всё приложение —
SearchInput с реакцией на фокус и клавишей «/», FilterDropdown на antd
Dropdown вместо нативного select; копии в контактах и списке диалогов
удалены. Ширина списков Сотрудники/Контакты/Агенты ограничена колонкой
1220px как в макете вместо растяжки на всё окно.
Тем же коммитом уходит незакоммиченная работа предыдущих сессий этого дня:
Профиль отдельной страницей, субменю Настроек, интеграции, метаданные
сессий и TOTP, объединение контактов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тема портала — папка в apps/internal-ui/src/features/help-center/themes/ с
manifest.ts и theme.css; реестр собирается автообнаружением, регистрировать
тему больше нигде не нужно. Вёрстка Help Center переведена на контракт из 30
токенов --help-* (themes/contract.css): прямых --n-*, --surface-* и
литеральных цветов в ней не осталось, значения по умолчанию равны прежним —
вид не изменился, кроме ховера кнопки поддержки (#000 → color-mix, иначе
ломается тёмная схема) и скруглений скелетонов (8→7, 6→5 px).
Портал хранит theme, theme_scheme (LIGHT/DARK/SYSTEM) и theme_settings
(JSON, зарезервирован под параметры тем); каталог тем бэкенду неизвестен,
проверяется только формат идентификатора. Неизвестная тема деградирует до
classic на публичной странице и помечается недоступной в настройках. Схема
применяется атрибутом data-theme — тем же механизмом, что персональная тема
хаба; SYSTEM следует prefers-color-scheme. Тема применяется до первого кадра
контента, CSS темы грузится ленивым чанком.
Настройки: секция «Оформление» в карточке портала на стандартных
SelectField. Тесты каталога (registry.test.ts) заодно линтят правила для
авторов тем: id == имя папки, скоуп [data-portal-theme], префикс @keyframes,
только токены контракта. Сами темы не разрабатывались — поставляется
базовая classic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Контакты: список и карточка показывают фото контакта (как в чате); в шапке
карточки описание, компания и город, правка карандашом той же формой, что в
контекст-панели (общий ContactEditForm); PATCH /conversations/clients/<id>/
(customers.manage) с теми же полями и пределами, аудит; вкладка «Consent» →
«Согласие». Маршруты: /contacts, /contacts/<id>, /portals, /portals/<id>
(старые /departments/... по-прежнему открываются).
Агенты: подсказки ведут в «Настройки» (AI-провайдер, Интеграции), ссылка
«Открыть настройки». Сотрудники: цветные точки групп в карточке и мастере.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Модель расшифровки голосовых — поле «Модель расшифровки голосовых» в
настройках AI-провайдера (config.transcription_model, по умолчанию whisper-1);
CHATBALLS_AI_TRANSCRIPTION_MODEL из settings/.env убран.
- «Расшифровка AI · Показать» — та же подпись и размер, что «… Скрыть», с
отступом 8px от плеера.
- Композер: поле растёт вверх под текст до 7 строк, дальше скролл.
- Меню пользователя без «Настроек» (настройки системы — в навигации).
- Фото в ленте открывается в попапе (antd Modal): имя, размер, «Скачать».
- Смена диалога — короткое появление ленты (180ms, уважает reduced-motion).
- Ширина сайдбара (180–320) и списка диалогов (280–520) тянется за край,
двойной клик сбрасывает, значение хранится в браузере.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AI: входящее голосовое (TG/MAX/виджет/портал) расшифровывается BYOK-провайдером
при входе, AI отвечает текстом по стенограмме; стенограммы участвуют в истории
контекста. Без расшифровки (провайдер не умеет/недоступен, FAILED) — диалог
оператору с уведомлением, как прежде. Демо-провайдер честно отказывает
(ProviderError) вместо текста-заглушки. Кнопка «Расшифровать» использует тот
же helper (transcribe_voice_message).
Портал поддержки: POST /support/sessions/messages/ принимает multipart audio и
file (+подпись), отдача /messages/<id>/audio|attachment/?credential=; в poll —
kind/hasAudio/durationSeconds/attachment; старт сессии возвращает features.
Виджет support-режима: запись голосового, скрепка, плеер и карточки.
Настройки → «Голосовые и звонки»: матрица точек входа × Голосовые /
Аудиозвонки / Видеозвонки (Integration.voice_messages_enabled,
audio_calls_enabled, video_calls_enabled; integrations/0007;
GET/PATCH company/administration/communication/). Флаги отдаются в
conversation.connection и в config/сессии виджетов: композер прячет микрофон,
карточка контакта — кнопки звонка, бэкенд отклоняет запись/звонок (400/403).
Почта звонки не поддерживает («недоступно»).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Отправка голосового оператором подчиняется тем же правилам, что текст и файл:
первая реплика перехватывает диалог у AI/очереди (раньше — 403 без назначения),
user_id берётся из identity (MAX без chat_id), expected_responder → клиент.
Почта: голосовое уходит письмом с аудио-вложением (EMAIL в _VOICE_SEND),
микрофон в композере доступен во всех каналах.
Telegram: audio/video/video_note/animation принимаются файлами (voice — как
прежде голосовым); MAX: video-вложение файлом.
Список диалогов: иконка микрофона перед превью голосового (макет d.voice).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Сообщение kind=file с вложением (имя, тип, размер) в тенантном хранилище.
Приём: Telegram (document/photo через getFile), MAX (attachments file/image),
почта (вложения письма вместо заглушки «не поддерживаются»), web-виджет
(multipart file + подпись). Файл без текста уходит оператору, как голосовое.
Отправка оператором: POST /conversations/<id>/attachments/ (файл + подпись,
перехват диалога как у текста) — Telegram sendDocument/sendPhoto, MAX /uploads,
почта MIME-вложение, Web поллингом. Отдача: /messages/<id>/attachment/ (?inline
для фото/PDF) и /webchat/messages/<id>/attachment/ по токену сессии.
Ограничения: 20 МБ, исполняемые типы отклоняются.
UI: кнопка «Прикрепить» (32px, как в макете), чип файла над полем, вставка из
буфера; в ленте — превью фото или карточка файла со ссылкой «Скачать» (.link);
превью в списке «Фото / Файл · имя». Виджет: скрепка в композере, чип, карточки.
email.py: выправлены двойные CR в концах строк.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- tenancy.StorageSettings (одна строка на инстанс): тип хранилища, бакет, endpoint,
регион, адресация, ключи (шифруются Fernet); статус проверки и переноса
- динамические бэкенды DynamicTenantStorage/DynamicUserStorage: пишут в активное
хранилище, читают/удаляют из обоих (файлы до переключения доступны без переноса);
конфигурация из БД с кэшем 10 с — без .env и перезапуска
- API: GET/PATCH company/administration/storage/ (переключение на S3 только после
успешной пробной записи), POST …/check/ (быстрая проверка, без ретраев boto),
POST …/migrate/ — перенос локальных файлов в бакет фоновой задачей worker'а
- UI: секция «Хранилище файлов» в «Настройках» — переключатель, реквизиты,
«Проверить доступ», «Сохранить», «Перенести файлы с диска в S3» с прогрессом
- из .env убраны CHATBALLS_STORAGE_BACKEND/CHATBALLS_S3_*; production больше не
требует S3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>