Язык ответов агента. Поле 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>
Go-шаблон был обёрнут во второй слой фигурных скобок и выводил строку
«{{json .Manifest.Digest}}» вместо digest — сборка gateway падала на
невалидном FROM.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Позиционирование, описание, установка одной командой, функции и
решение проблем. Основная версия — английская, русская рядом.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ветка feat/one-command-release целиком:
- установка на чистый хост одной командой (compose.yaml со страницы релиза);
- 19 правок по аудиту: 2FA, сессии при сбросе пароля, TLS на домене
установки, WebSocket под TLS, права на файлы, дедупликация входящих,
смена адреса, SSRF через редирект, домен портала, пароль прокси, health;
- код приведён к своей же конфигурации ruff, линтер включён в CI;
- переводы строк в .py нормализованы, GitLab и .claude убраны из репозитория.
Проверено: backend pytest 739 passed, playwright 8, vitest 82, CLI 22,
typecheck, ruff — всё зелёное; установка поднята и проверена через шлюз.
Канал релиза один — GitHub. Пайплайн GitLab оставался вторым источником
правды: он собирал те же образы своим способом и мог выдать релиз, разошедшийся
с тем, что публикует workflow. Такое расхождение уже стоило неработающего
релиза, поэтому второй канал убирается целиком, а не подпирается.
`.claude/launch.json` — локальные настройки запуска агента, а не часть
продукта: убран из индекса и добавлен в .gitignore. На диске файл остаётся.
Проверка python-линтера жила в стадии validate GitLab; она есть в
.github/workflows/checks.yml, так что ничего не теряется.
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>
В комментарий попал настоящий возврат каретки, из-за чего git читал хвост
строки как отдельный шаблон и ругался «такой is not a valid attribute name».
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>
Файл существовал только пока в нём был хотя бы один незакрытый пункт. Всё,
что должно жить дальше, уже описано в README, в коде и в тестах; сам чек-лист
в его финальном виде остаётся в истории — предыдущий коммит.
Осталась одна проверка, которую не сделать из репозитория: пройти установку на
настоящем домене с живым выпуском сертификата Let's Encrypt. Она вынесена в
описание PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
739 passed в бэкенд-сьюте, e2e и фронтовые проверки зелёные, установка в
каталоге с одним compose.yaml поднимается одной командой и отвечает через
шлюз. Открытых пунктов не осталось — файл удаляется следующим коммитом, как в
нём и записано.
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>
useMemo держал в зависимостях весь объект `props`, новый на каждом рендере, —
мемоизация не срабатывала ни разу. Сделать её настоящей нельзя без стабилизации
`onRetry` и `onClose` выше по дереву, а смысла в этом нет: buildStatus дёшев, а
идентичность результата никому не нужна — CallView не обёрнут в React.memo.
Считаем на месте, как это уже сделано в AudioCallOverlay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Утечка камеры и микрофона у оператора:
- терминальный статус приходит и поллингом состояния, а не только по RTC-сокету
(до «Присоединиться» сокет ещё не открыт), поэтому `onCallState` → `stop()` не
срабатывал. Экран уходил в `status`, а оба выхода с него RTC не глушили:
`endAndClose` для этого режима пропускает `finish()`, а «Закрыть» зовёт
`close()`, который только прячет модалку. Оверлей при этом остаётся
смонтированным, так что и cleanup хука не отрабатывал — камера оператора
горела до переключения диалога. Оба оверлея явно освобождают устройства,
когда оверлей закрыт или звонок завершён.
call-view.css переведён на токены дизайн-системы:
- радиус карточки 24px → `--radius-4xl` (14px), тень → `--shadow-lg`,
рамки → `--border-default`, второстепенный текст → `--text-secondary`,
круглые кнопки → `--n-7`/`--n-9`/`--n-3`, выключенные → `--error-*`,
таймер → `--error-bg`/`--error-text` + 12.5px и моноширинный шрифт,
таймер переподключения → `--warning-text` и точка `--warning`,
кнопки precall → `--radius-lg` (9px), 14px и `--shadow-primary`;
- радиус контейнера модалки взят из того же токена: снаружи виден именно он;
- хардкод оставлен там, где он есть и в макете — сцена видео и слои поверх неё
от темы не зависят.
Побочный эффект: карточка звонка у оператора теперь следует тёмной теме
internal-ui, как остальные поверхности. Проверено в обеих темах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вёрстка:
- мобильная сцена брала высоту как `height:100%` от родителя, у которого задан
только `min-height` — процент схлопывался в высоту по контенту, и окно выходило
412x427 вместо 412x915. Высота берётся от вьюпорта (vh/dvh), как у видеозвонка;
- на низком десктопном окне карточка 9:16 не влезала и кнопки приёма уезжали за
фолд — ширина зажата по высоте вьюпорта, пропорция остаётся ровно 9:16;
- операторской модалке нужен `height:auto`, иначе мобильная ветка растягивала
сцену на весь экран внутри antd Modal;
- пустая «пилюля» канала больше не рисуется без метки.
Жизненный цикл:
- токен из хеша сохраняется в sessionStorage: хеш срезается replaceState, а в
пути лежит callId, поэтому после F5 звонок было не восстановить;
- обрабатывается CONNECTED от собеседника — UI выходит из «Переподключения»,
когда обрыв был на его стороне;
- у режима reconnecting появился нижний бар: экран был без единой кнопки;
- клиенту не показывается «Позвонить снова», которого он не может сделать; на
DECLINED/MISSED/EXPIRED это была единственная кнопка, и уйти было некуда;
- «Повторить» на упавшем соединении рестартует RTC, а не перепроверяет микрофон.
Чистка:
- TERMINAL_CALL_STATUSES стал единственным источником вместо трёх копий набора;
- волна в тишине спокойная: амплитуда растёт от idle к speak по громкости;
- сигналинг и track.enabled вынесены из updater'а setState.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
В оживлённой организации поток событий плотнее прежнего опроса: пять сообщений
подряд дали бы пять перезапросов списка вместо одного за четыре секунды.
События схлопываются в окно 700 мс — обновление остаётся мгновенным для
человека и не превращается в шквал запросов.
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>
При переносе корня в code/chatballs под версионным контролем остались
только CI-файлы старого корня. AGENTS.md, pack.bat, resources/ и
.claude/launch.json физически уцелели, но перестали версионироваться.
- AGENTS.md, pack.bat, resources/ и .claude/launch.json перенесены в корень;
- launch.json: cwd теперь «.», корень репозитория и есть корень приложения;
- pack.bat снова работает — он требует запуска из корня git-репозитория;
- README: после клона нужен «cd chatballs», вложенного пути больше нет;
- .gitignore: Chatballs.zip, архив pack.bat, снова игнорируется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
История переписана git filter-repo: пути code/hub.edevs.tech, code/custocrm
и code/chatballs подняты в корень. Приложение больше не лежит во вложенном
каталоге, поэтому инфраструктура переезжает следом:
- .gitlab-ci.yml, .gitlab/ci/*, .github/workflows/release.yml — APP_DIR
теперь ".", все пути внутри джоб через него и остаются рабочими;
- .gitattributes — без него core.autocrlf=true отдавал бы .sh и CLI
chatballs с CRLF, что ломает exec в Linux-контейнерах;
- .gitignore — вернулись правила data/, .env.bak-*, **/staticfiles/,
жившие в прежнем корневом .gitignore.
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>
Скрипт был зелёным всегда. `$ErrorActionPreference = "Stop"` не влияет на код
возврата нативных команд, поэтому упавший pytest проходил как успех: проверено
на живом примере — прежняя схема при команде с кодом 3 доходила до конца и
возвращала 0. Теперь каждый шаг сверяется с $LASTEXITCODE, провалившиеся
перечисляются в конце, скрипт выходит с 1.
Кроме этого:
- явные `-f compose.yaml -f compose.dev.yaml`: сервисы frontend и web-chat
(node) существуют только в dev-оверлее, в базовом compose frontend —
production-образ nginx, где нет npm. Голый `docker compose` три шага из
четырёх выполнить не мог в принципе;
- добавлены CLI-тесты и e2e — раньше их не было ни в одном скрипте;
- порядок от быстрых проверок к длинным: typecheck, vitest, CLI, e2e, backend
(~20 минут). Дешёвые ошибки всплывают за секунды;
- скрипт не останавливается на первом падении: прогоняет всё и показывает
список провалов, иначе каждая ошибка стоила бы отдельного круга.
playwright.config.ts: `webServer` поднимал только internal-ui на :5173, а
проект web-chat-mobile ходит на :5175 — без заранее поднятого dev-стека прогон
падал. Описаны оба сервера, e2e стали самодостаточными; без этого включать их
в check.ps1 не имело смысла.
Проверка: полный прогон скрипта — 6 шагов, все зелёные, exit code 0
(typecheck ×2, vitest 76, CLI 9, e2e 8, backend 624).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пять из шести сценариев проверяли допитовный интерфейс и падали с 2026-09-03:
ждали заголовок «Командный центр», секции сайдбара «Продажи»/«Поддержка»,
маршрут /departments/sales/dialogs, роль OPERATOR, отделы и колонку «ДОСТУП».
Всё это снесено ADR-CHATBALLS-0041 и ADR-CHATBALLS-0043. В check.ps1 e2e не
входят, поэтому падения никто не видел.
Что проверяется теперь:
- владелец после входа попадает в чат и видит ровно семь пунктов навигации
(SPEC-CHATBALLS-0031 §4), а снесённых понятий — «Командный центр», «Отделы»,
«Продажи», «Каналы», «Подключения», «Продукты» — в интерфейсе нет; это
регрессионная страховка вместо прежних ожиданий;
- сотрудник попадает в тот же чат, но пунктов администрирования не получает
(дерево диалогов тоже лежит в nav.hub-nav, поэтому проверяются именно
button.hub-nav-item);
- сотрудник на менеджерском маршруте видит 403 — сценарий сохранён;
- организация берётся из адреса: запросы уходят только во вторую организацию,
хотя членство есть в обеих;
- на минимальной ширине 1024px нет горизонтальной прокрутки;
- экран сотрудников: колонки списка, ящик создания, карточка с разделами
«Должность и группы» и «Системная роль» (профилей доступа больше нет),
передача владения в «Опасной зоне» карточки владельца, а не в меню строки.
Фикстуры приведены к текущим контрактам: membership с группами вместо отделов,
права выводятся из роли зеркалом `identity/capabilities.py` (пустой список
скрывал кнопку «Добавить сотрудника»). В списке появился администратор — без
него диалог передачи владения показывал пустое состояние, и проверять в нём
было нечего.
Прогон: 8 passed, 6 skipped, 0 failed (было 3 passed, 5 failed).
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>
Макет ссылался на _ds/design-system/ (раньше — на каталог с прежним именем),
но самого каталога в этой папке никогда не было: шесть ссылок на токены,
styles.css и бандл вели в пустоту, и страница открывалась без оформления.
Рядом положен тот же комплект, что и у соседних макетов, — их копии побайтно
идентичны, каждая папка базлайна самодостаточна.
Проверено в браузере: все шесть запросов отдают 200, консоль чистая, страница
рендерится со стилями. Битых ссылок в design/ больше нет (0 из 121).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Требование к open-source публикации: в репозитории не должно остаться
CustoCRM и eDevs Hub. В docs/ это сделано ранее, здесь — код и дизайн.
Дизайн-система (design/design-system):
- заголовки, брендинг и примерные данные переведены на Chatballs;
- переименованы глобали справочника: CUSTOCRM_ICON_SPRITE →
CHATBALLS_ICON_SPRITE, CUSTOCRM_INVENTORY → CHATBALLS_INVENTORY,
data-атрибут спрайта — обе стороны замкнуты внутри справочника;
- в README поправлены пути открытия: справочник живёт в design/,
а не в apps/internal-ui/;
- личный адрес andrey@edevs.tech в примере таблицы заменён на example.com.
Макеты (design/baseline):
- каталоги _ds/edevs-hub-design-system-<uuid>/ переименованы в _ds/design-system/,
ссылки в .dc.html обновлены;
- JS-namespace бандла EdevsHubDesignSystem_e4c9df → ChatballsDesignSystem_e4c9df
(21 вхождение: бандл, манифест, все x-import в макетах);
- readme.md внутри бандлов удалён — это описание прежнего продукта
(hub.edevs.tech, checkout, командный центр), к текущему отношения не имеет;
- assets/custocrm-mark.svg → chatballs-mark.svg;
- удалён каталог «Каналы обработки» целиком: макеты сущности, упразднённой
ADR-CHATBALLS-0041. Восстанавливается из истории git, если понадобится.
Тесты: tests/cli/test_custocrm_cli.py → test_chatballs_cli.py, набор проходит
(9 passed).
Ссылки в макетах и справочнике проверены: из 121 ссылки битых 6, и все шесть —
в «Сотрудники/Сотрудники (новая модель).dc.html», где каталог _ds отсутствовал
и до этих правок.
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>
Последний след .env в продукте: скрипт переезда переписывал .env старой
установки и оставлял рядом .env.bak-custocrm. Переименование состоялось,
установок со старыми именами не осталось, а инструмент тянул за собой файл,
которого у продукта нет.
Раздел README про этот переезд удалён вместе со скриптом — он и сам уже читался
бессмысленно: заголовок «Chatballs / hub → Chatballs» и команда
«mv /opt/chatballs /opt/chatballs» получились оттого, что скрипт переименования
однажды прошёлся по собственной документации.
Проверено: pytest tests/cli — 9 passed; bash -n чист по всем скриптам.
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>
`X-Content-Type-Options: nosniff` и `Referrer-Policy` объявлены на уровне
server, но nginx не наследует add_header в location, где есть свой add_header,
— он заменяет весь набор. У `/`, `/chat/` и `/calls/` свои CSP, поэтому SPA и
страница виджета отдавались без nosniff, и вложение с чужим Content-Type
браузер додумывал сам.
Оба заголовка продублированы в каждую такую location, причина записана рядом.
Конфиг проверен `nginx -t` в сети compose.
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>