mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(deploy): сохранить override и постоянные dev-окружения
Сохранить порядок дополнительных Compose-файлов при обновлении и подключать их помощнику на чтение. Добавить проверки override, постоянных каталогов dev-данных и документацию проекта. Явно задать русский язык в тестах русских подписей.
This commit is contained in:
1 parent
1a71abfe77
commit
91fab586fa
85 files changed
+2784
-32
No files matched your search
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: "0001"
|
||||
title: Состояния и жизненный цикл диалога
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Один комбинированный статус диалога даёт комбинаторный рост состояний: «открыт и отвечает AI», «открыт и ждёт оператора», «закрыт», «спам» и их сочетания. Повторное открытие давнего диалога смешивает разные обращения одного человека в одну ленту и искажает статистику.
|
||||
|
||||
## Решение
|
||||
|
||||
Состояние диалога разделяется на три независимые оси:
|
||||
|
||||
- **LifecycleState**: `OPEN`, `CLOSED`, `SPAM`;
|
||||
- **ControlMode** — кто ведёт: `AI`, `HUMAN`, `PAUSED`;
|
||||
- **ExpectedResponder** — от кого ждут реплики: `CUSTOMER`, `AI`, `OPERATOR`, `NOBODY`.
|
||||
|
||||
Правила:
|
||||
|
||||
- новый диалог создаётся в режиме `AI`;
|
||||
- закрытие панели веб-чата, переход на другую страницу и закрытие браузера не меняют жизненный цикл;
|
||||
- семь дней без активности переводят `OPEN` в `CLOSED`;
|
||||
- новое сообщение после `CLOSED` создаёт новый `Conversation` того же контакта с `previous_conversation_id`;
|
||||
- `SPAM` не переоткрывается и не создаёт новый диалог автоматически;
|
||||
- для оперативной статистики активным считается открытый диалог с активностью за последние 15 минут.
|
||||
|
||||
Отклонены: один общий enum (комбинаторный рост); повторное открытие закрытого диалога (смешивает обращения, ломает статистику).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Каждое обращение учитывается отдельно; оси не дублируют друг друга; история клиента связна через цепочку диалогов.
|
||||
- Переходы валидируются доменным сервисом, а не свободной записью поля.
|
||||
- Нужна фоновая задача автоматического закрытия.
|
||||
- Транспорт обязан направить новое сообщение в новый диалог, а не дописать его в закрытый.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: "0002"
|
||||
title: AI-first обработка и ручной перехват
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
AI должен обрабатывать основную массу обращений, но сотрудник обязан мгновенно получить исключительный контроль над диалогом. Модель должна работать и с одним сотрудником, и с несколькими — без гонок и двойных ответов клиенту.
|
||||
|
||||
## Решение
|
||||
|
||||
Новый диалог ведёт агент, в подключение которого пришло обращение.
|
||||
|
||||
**AI может:** отвечать по инструкциям и прикреплённым знаниям, уточнять потребность, передавать клиенту ссылки на вложения знаний и статьи портала, запрашивать сотрудника и формировать резюме передачи. **AI не может:** обещать сведения, отсутствующие в знаниях, и совершать действия, оставленные человеку.
|
||||
|
||||
### Детерминированная передача человеку
|
||||
|
||||
Передача выполняется, если клиент попросил человека; в знаниях нет основания для ответа; запрос относится к операции только для человека; возник конфликт или жалоба; достигнут лимит агента; есть подозрение на злоупотребление. Самооценка модели «уверен / не уверен» основанием не является.
|
||||
|
||||
### Общая очередь и атомарный claim
|
||||
|
||||
- запросы человека попадают в общую очередь диалогов, ждущих ответа;
|
||||
- сотрудник выполняет атомарный `claim`, забрать диалог может только один;
|
||||
- в режиме `HUMAN` писать клиенту может назначенный сотрудник;
|
||||
- владелец и администратор могут перехватить или переназначить диалог;
|
||||
- сотрудник может вернуть диалог в очередь; блокировка учётной записи возвращает её открытые диалоги в очередь;
|
||||
- закрытие браузера не снимает назначение;
|
||||
- возврат к AI — только явным действием человека.
|
||||
|
||||
### Атомарный перехват у AI
|
||||
|
||||
1. блокировка диалога; 2. блокировка отправки новых AI-ответов; 3. отмена незавершённой генерации; 4. режим `HUMAN`; 5. назначение сотрудника; 6. системное событие; 7. резюме сотруднику.
|
||||
|
||||
Запрос онлайн-звонка — человеческое действие: из режима `AI` команда звонка сначала выполняет этот перехват и только после успеха создаёт `CallSession`; при конфликте claim приглашение клиенту не отправляется.
|
||||
|
||||
Отклонены: обязательная проверка человеком каждого AI-ответа (узкое место); автоматический возврат к AI по таймеру (неожиданный ответ робота); round-robin распределение (избыточно для масштаба).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Двойной ответ клиенту исключён; очередь работает с любым числом сотрудников; передача проверяема.
|
||||
- Нужны транзакционная блокировка и проверка режима перед каждой отправкой.
|
||||
- Автоматического распределения диалогов по сотрудникам нет.
|
||||
- Качество резюме зависит от конфигурации агента.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: "0003"
|
||||
title: Идентификация клиента и объединение контактов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Один человек может написать через Telegram, MAX, ВКонтакте, почту и веб-виджет; внешние идентификаторы разные. Автоматическое объединение по слабым признакам (имя, похожий адрес) смешивает данные разных людей, и ошибку потом нельзя обнаружить изнутри системы.
|
||||
|
||||
## Решение
|
||||
|
||||
- `Contact` — человек в организации;
|
||||
- `ConnectionIdentity` — устойчивая идентичность внутри конкретного подключения: Telegram user ID, MAX user ID, ID пользователя ВКонтакте, адрес отправителя письма, для веб-виджета — идентификатор сессии и высокоэнтропийный credential (на сервере только hash, клиенту — короткоживущий токен).
|
||||
|
||||
Сессия веб-виджета хранится браузером в контексте конкретного виджета и восстанавливает анонимную историю, но **не является подтверждённой личностью** и сама по себе не объединяет контакт с другими подключениями. Очистка browser storage создаёт новую анонимную идентичность.
|
||||
|
||||
Каждое подключение принадлежит одному агенту; агент диалога определяется по подключению до начала разговора.
|
||||
|
||||
Автоматическое объединение идентичностей разных подключений запрещено, кроме случая, когда само подключение даёт подтверждённый номер телефона и совпадение однозначно. Объединение по имени запрещено.
|
||||
|
||||
Ручное объединение и разъединение доступны владельцу и администратору: сотрудник может предложить объединение; инициатор видит сравнение; операция требует причины; переносятся идентичности и диалоги; операция полностью аудируется и обратима.
|
||||
|
||||
Отклонены: автообъединение по email или введённому телефону (клиент может ввести чужой адрес); одно общее подключение на все транспорты (риск неправильного агента и знаний).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Риск смешения персональных данных минимален; история сохраняется; ошибочное объединение откатывается.
|
||||
- Один человек может временно существовать как несколько контактов.
|
||||
- Требуется интерфейс предложения, объединения и разъединения.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: "0004"
|
||||
title: Согласие клиента и минимизация данных для LLM
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Платформа обрабатывает сообщения и контактные данные клиентов. Внешняя модель не должна получать идентификаторы и персональные сведения, не нужные для ответа. Клиент публичного веб-виджета должен видеть, на что соглашается, до того как сообщение уйдёт в модель.
|
||||
|
||||
## Решение
|
||||
|
||||
### Согласие принадлежит точке входа
|
||||
|
||||
Текст согласия и его версия хранятся в конфигурации веб-виджета. Виджет показывает приветствие и согласие до начала разговора. До принятия: сообщение не передаётся в LLM, диалог с AI не начинается, сохраняется только минимальный технический контекст (показ согласия, сессия, защита от злоупотреблений). Версия меняется вместе с текстом. Для мессенджеров и почты отдельного экрана согласия нет — клиент сам инициирует переписку.
|
||||
|
||||
### Минимизация данных
|
||||
|
||||
Перед отправкой в модель текст проходит redaction; по умолчанию не передаются адреса e-mail, телефоны, длинные числовые идентификаторы (карты, платежи, документы), токены, секреты и внутренние идентификаторы. Runtime формирует отдельное очищенное представление — исходный текст в истории не меняется. Персональные данные попадают в модель только через явно разрешённый инструмент.
|
||||
|
||||
### Удаление и обезличивание
|
||||
|
||||
Владелец может обезличить коммуникационные данные: связь с контактом заменяется техническим идентификатором, история остаётся как факт коммуникации. Сроки хранения задаёт организация.
|
||||
|
||||
Отклонены: передача модели полной карточки клиента; хранение только флага «согласие получено» (не доказать, какой текст был показан).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Объём персональных данных у провайдера минимален; текст согласия версионирован; удаление данных клиента не разрушает историю.
|
||||
- Нужен отдельный слой redaction и payload для модели.
|
||||
- Redaction по шаблонам не ловит произвольные упоминания ПДн в свободном тексте — осознанный компромисс.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: "0005"
|
||||
title: Структура репозитория и границы развёртывания
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Код, дизайн и служебные материалы должны иметь один канонический источник внутри репозитория. Публикуемый open-source репозиторий не должен содержать второго дерева с копиями макетов, второго production-манифеста или приватных материалов владельца.
|
||||
|
||||
## Решение
|
||||
|
||||
- Каноническая структура: `apps/backend` (Django: API, ASGI, домены, воркеры), `apps/internal-ui` (интерфейс организации и публичные порталы, выбор по HTTP Host), `apps/web-chat` (виджет, клиентская страница звонка, лоадер), `packages/ui`, `packages/contracts`, `packages/shared`, `design/baseline`, `deploy`, `scripts`, `tests`, `compose.yaml`, `compose.dev.yaml`, `Caddyfile`. Новые приложения и пакеты вне структуры — только отдельным решением; параллельные каталоги дизайна и дублирование макетов запрещены.
|
||||
- `apps/backend` — единственный источник доменной логики; фоновые обработчики — процессы из того же кода.
|
||||
- Публичный адрес — свойство установки, а не константа репозитория; домены в код, конфигурацию и документацию не зашиваются. Публичные пути: `/` (интерфейс), `/api/`, `/ws/`, `/chat/`, `/calls/`, `/chat-widget.js`.
|
||||
- Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Секреты и реквизиты внешних сервисов в репозитории не хранятся.
|
||||
- Проектная документация владельца приватна и в публикацию не входит; публично поставляется `README.md` репозитория.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Код, дизайн и документация живут в одном контуре; дублирование макетов и манифестов исключено; локальная разработка не зависит от серверного развёртывания.
|
||||
- Любое новое приложение требует отдельного решения.
|
||||
- Публичная документация пишется отдельно и не появляется сама из проектных документов.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "0006"
|
||||
title: Каналы уведомлений сотрудников
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Контакт-центр — рабочая система: если клиент ждёт человека, а сотрудник закрыл вкладку, событие не должно потеряться. Клиентские боты организации не должны использоваться как служебный канал для сотрудников — разные аудитории, токены и права.
|
||||
|
||||
## Решение
|
||||
|
||||
**Способы доставки:** центр уведомлений в интерфейсе; звуковой сигнал в чате; browser notifications; письмо через почту установки; служебный Telegram- или MAX-бот.
|
||||
|
||||
**Разделение ботов.** Служебный бот — отдельная интеграция с назначением «уведомления»: не привязывается к агенту и не принимает клиентские диалоги; токены, аудит и права клиентского и служебного контуров разделены. Сотрудник привязывает себя сам: в профиле получает одноразовый код с коротким TTL и открывает бота по deep-link; привязка хранит внешний ID чата и набор типов уведомлений для мессенджера.
|
||||
|
||||
**Типы событий** (базовые): `DIALOG_WAITING` — диалог ждёт человека; `DIALOG_NEW_MESSAGE` — новое сообщение в диалоге под управлением человека; `INTEGRATION_ERROR` — подключение или провайдер недоступны. Уведомление адресуется всем, владельцу, работающим с диалогами сотрудникам или конкретному пользователю и содержит диплинк на объект. Новый тип — запись в реестре типов, а не новый механизм доставки.
|
||||
|
||||
**Настройки.** Сотрудник настраивает доступные способы и типы в пределах ограничений роли.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Рабочие события не теряются при закрытом браузере; Telegram и MAX равноправны; служебное не смешивается с клиентским; новые способы доставки добавляются без изменения доменных событий.
|
||||
- Служебный бот требует отдельной регистрации; каждая доставка идемпотентна; недоступность одного способа не блокирует остальные.
|
||||
- Пока почта установки не настроена, приглашения и сброс пароля уходят в лог.
|
||||
- Поведение прочтения уведомлений — SPEC-0017.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0007"
|
||||
title: "Технология retrieval знаний: pgvector и FTS PostgreSQL"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Агенту нужен поиск по прикреплённым материалам, находящий и точные формулировки, и близкие по смыслу. Требования: работать в уже используемой инфраструктуре, не вводить отдельный сервис хранения, поддерживать лексический и семантический поиск и фиксировать, какие фрагменты попали в ответ.
|
||||
|
||||
## Решение
|
||||
|
||||
Retrieval реализуется в PostgreSQL: семантический поиск — `pgvector` (образ `pgvector/pgvector:pg16`, расширение включается миграцией); полнотекстовый — встроенный FTS.
|
||||
|
||||
- Содержимое источника разбивается на фрагменты (`KnowledgeFragment`): текст и эмбеддинг. Размерность колонки не фиксируется: эмбеддинги считает адаптер провайдера (демо — детерминированный малой размерности, иначе провайдер организации).
|
||||
- Источников фрагмента два — знание библиотеки и статья портала (ADR-0017); «ровно один источник» закреплён CHECK-ограничением.
|
||||
- Retrieval объединяет лексических и семантических кандидатов среди материалов, доступных агенту, ранжирует их; идентификаторы использованных фрагментов сохраняются в журнале вызова модели.
|
||||
- На текущем объёме допускается последовательное сканирование без ANN-индекса. При росте объёма добавляется HNSW/IVFFlat с фиксированной размерностью выбранной embedding-модели — отдельной миграцией, без изменения интерфейса retrieval.
|
||||
|
||||
Отклонены: внешнее векторное хранилище (лишний сервис); только FTS (нет семантики); фиксированная размерность сразу (ломает совместимость провайдеров).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Один компонент инфраструктуры для лексики и семантики; интерфейс retrieval скрывает реализацию.
|
||||
- Без ANN-индекса поиск линейный; ANN потребует фиксации размерности и переиндексации при смене модели.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: "0008"
|
||||
title: "Интеграции: провайдеры и подключения"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Подключение к внешним системам было неявным: ключ провайдера задавался переменной окружения, транспорт предполагался как «один бот на всё». Владелец должен заводить и проверять подключения в интерфейсе, экземпляров одного типа может быть несколько, секреты должны храниться зашифрованными, а не в окружении.
|
||||
|
||||
## Решение
|
||||
|
||||
**Интеграция — единица внешнего подключения**, заводится и проверяется в «Настройки → Интеграции». Два рода:
|
||||
|
||||
- **LLM-провайдер** — OpenRouter, произвольный OpenAI-совместимый endpoint (ADR-0014), демо-провайдер без ключей;
|
||||
- **Подключение (транспорт)** — Telegram-бот, MAX-бот, сообщество ВКонтакте, веб-виджет, почтовый ящик (ADR-0015). Экземпляров много; каждое подключение принадлежит **одному** агенту.
|
||||
|
||||
Платформенных credentials и managed-режима нет (ADR-0020).
|
||||
|
||||
**Маршрутизация провайдера** — только через явно выбранную интеграцию агента; «первая попавшаяся интеграция организации» и глобальная подмена выбора владельца запрещены.
|
||||
|
||||
**Каталог моделей** адаптер отдаёт через backend; allowlist не ведётся. Модель выбирается поисковым списком; backend проверяет её наличие в каталоге интеграции; исчезнувшая модель не подменяется автоматически, конфигурация помечается как требующая выбора. Исключение — Custom: каталога нет, модель вводится текстом. Браузер никогда не получает credentials.
|
||||
|
||||
**Хранение и проверка:** секреты шифруются в БД; у интеграции есть статус, время и текст последней ошибки; «Проверить» выполняет реальную проверку (провайдер — health и capability, бот — `getMe`, ВКонтакте — ключ и настройки Long Poll сообщества, почта — IMAP и SMTP); `is_active` отделён от статуса проверки. Ошибка проверки объясняется словами, а не кодом; полный ответ провайдера — только в журнал. Исходящие запросы представляются User-Agent `Chatballs/<версия>`.
|
||||
|
||||
**Приём и отправка:** входящие — идемпотентно через inbox (уникальность по источнику и внешнему id), нормализация в доменное сообщение; исходящие — через outbox с ретраями; поллинг в воркере или вебхук. После сбоя опроса подключение пропускается с растущей паузой, курсор не двигается, в журнал — только первый сбой, выход на максимальную паузу и восстановление.
|
||||
|
||||
**Границы:** интеграция — чистый транспорт или доступ к провайдеру; не хранит знания, инструкции, диалоги и конфигурацию агента. Что разрешено в точке входа (голосовые, звонки) — свойство интеграции.
|
||||
|
||||
Отклонены: ключ провайдера только в окружении; один бот на всё.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Провайдеры и боты управляются и проверяются в интерфейсе; модель выбирается из актуального каталога; несколько ботов одного типа из коробки; секреты зашифрованы; приём и отправка идемпотентны.
|
||||
- Нужен жизненный цикл и ротация секретов; для вебхуков нужен публичный адрес, по умолчанию — поллинг; ошибки внешних API требуют внимания владельца.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0009"
|
||||
title: Модель агента и знаний без версий и релизов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Первая итерация строила AI-конфигурацию на трёх уровнях: библиотека версионируемых документов, агент и неизменяемый релиз. Чтобы поправить одну фразу, владелец проходил четыре шага (черновик версии → публикация версии → сборка релиза → публикация релиза), а в интерфейсе жили три источника истины.
|
||||
|
||||
## Решение
|
||||
|
||||
**Знание — плоская сущность с вложениями.** `Knowledge`: заголовок, краткое описание, текст (Markdown), признак «включено»; версий нет. Вложения: оригинальное имя сохраняется и уникально в рамках знания (текст ссылается по имени); из md, txt, pdf, docx извлекается текст для индексации; у вложения публичная ссылка с непредсказуемым UUID — агент передаёт её клиенту в любом подключении. Защита ссылки — только непредсказуемость UUID (осознанный компромисс). Фрагменты и эмбеддинги перестраиваются при каждом изменении содержимого или вложений.
|
||||
|
||||
**Агент — одна сущность без релизов:** модель и параметры генерации; инструкции из трёх частей — Персонализация, Тон общения, Инструкции; прикреплённые знания и статьи портала; дневной лимит стоимости; статус. Системный промпт: Персонализация → Тон → Инструкции → каталог прикреплённых материалов (заголовок, описание, ссылки вложений); содержимое подтягивается retrieval'ом.
|
||||
|
||||
Изменения агента и знаний применяются в runtime **сразу**.
|
||||
|
||||
Отклонены: сохранить релизы, упростив документы (главная сложность — многошаговая публикация); версионируемые вложения (файл заменяется загрузкой с тем же именем).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Правка в один шаг, одна точка настройки, вложения можно передать клиенту.
|
||||
- Нет атомарной публикации и отката; нет истории версий; расследование ответа опирается на журнал использованных фрагментов.
|
||||
- Публичные ссылки вложений доступны любому, у кого есть URL.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: "0010"
|
||||
title: P2P-онлайн-звонки в диалогах
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Часть обращений быстрее закрыть голосом. Переход из переписки в звонок не должен создавать внешнюю конференцию, терять связь с диалогом или требовать от клиента установки чего-либо. Интеграции с готовыми платформами видеосвязи не принимаются. Запись звонков сознательно отложена.
|
||||
|
||||
## Решение
|
||||
|
||||
- **Звонок — часть диалога.** `CallSession` связан с существующим диалогом; в ленту попадают только системные события: запрос, принятие, отклонение, начало, завершение, пропуск, ошибка.
|
||||
- **Участники:** один сотрудник с доступом к диалогу и один клиент; один незавершённый звонок на диалог; аудио и видео, разговор продолжается при выключенной камере. AI не участвует. Групповые звонки, второй сотрудник, screen sharing не входят.
|
||||
- **Медиамаршрут:** WebRTC напрямую; при невозможности — собственный Coturn как relay, который ничего не записывает.
|
||||
- **Собственный signaling:** backend создаёт сессию и приглашение, проверяет доступ, ведёт состояния, передаёт SDP и ICE между разрешёнными участниками, таймауты, аудит. SDP, ICE и медиа не сохраняются ни в БД, ни в логах.
|
||||
- **Доставка приглашения:** в веб-чате — realtime-событием в сессию; в Telegram, MAX и ВКонтакте — кнопкой на защищённую страницу `/calls/<токен>`. Токен высокоэнтропийный, привязан к приглашению, идентичности и организации, ограничен по сроку, не даёт доступа к истории и не работает после завершения.
|
||||
- **Состояния:** `REQUESTED · RINGING · ACCEPTED · CONNECTING · ACTIVE · DECLINED · CANCELLED · MISSED · ENDED · FAILED · EXPIRED`.
|
||||
- **Развёртывание:** Coturn — отдельный Docker-сервис, который **поднимается вместе со стеком** (с релиза 1.8.0; ранее включался профилем `calls`): host network, порт 3478 TCP/UDP, relay-диапазон 49160–49999/udp, TURN-over-TLS по умолчанию выключен. Порты публикуются напрямую, HTTP-шлюз TURN не проксирует. Адреса relay и STUN вычисляются от адреса установки, вписанные вручную побеждают. Backend выдаёт только краткоживущие credentials.
|
||||
- **Запись отложена:** ни аудио, ни видео не записываются. Будущая запись — только аудио, отдельным сервисом, отдельным решением о хранении, доступе и сроках.
|
||||
|
||||
Отклонены: готовый Video SDK (LiveKit, Jitsi, Zoom); серверный медиасервер/SFU; запись через браузер сотрудника; TURN как механизм записи.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Звонок остаётся частью диалога; нет зависимости от внешней платформы; при прямом соединении медиа не проходит через backend.
|
||||
- Качество зависит от сети и браузеров; при relay сервер несёт медиатрафик; на файрволе нужно открыть `3478/udp`, `3478/tcp` и `49160–49999/udp`.
|
||||
- Нужно отдельное тестирование мобильных браузеров, NAT и восстановления соединения.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: "0011"
|
||||
title: Единый контур развёртывания и установка без конфигурации
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Пока локальная разработка, production владельца и установка у пользователя описывались разными манифестами и командами, они расходились: порядок миграций, health checks, шлюз и bootstrap жили своей жизнью. Для open-source поставки пользователь должен получить ровно тот путь установки, который владелец ежедневно проверяет.
|
||||
|
||||
## Решение
|
||||
|
||||
- **Один production-контур** для первичной установки, обновления, production владельца, staging и восстановления.
|
||||
- **Один канонический `compose.yaml`**: топология, зависимости, health checks, тома, образы. `compose.dev.yaml` может подменять образ сборкой, команды — dev-серверами и монтировать исходники, но не описывает вторую топологию.
|
||||
- **Установка без конфигурации.** Файла `.env` нет. Секреты инстанса (ключ подписи, ключ шифрования полей, пароли ролей БД, секрет TURN) генерирует одноразовый сервис `secrets` в тома `chatballs-secrets`, `chatballs-secrets-platform`, `chatballs-secrets-schema` (ADR-0026); скрипт идемпотентен и не меняет пароли работающей базы. Адрес, почта, хранилище, TURN, интеграции и AI-провайдер — в интерфейсе. Переменная окружения выше файла секрета — для оркестраторов.
|
||||
- **`release.env`** — только версия и неизменяемые ссылки на образы по digest; без него стек собирается из исходников. `latest` источником версии не является.
|
||||
- **One-shot `init`** — только миграции; не создаёт организацию, владельца, демо-данные; безопасен при повторе.
|
||||
- **Мастер первого запуска** вместо bootstrap-переменных: пока организаций нет, установка показывает мастер; после создания владельца он закрывается навсегда. Адрес, на котором прошли мастер (включая нестандартный порт), запоминается как публичный адрес установки.
|
||||
- **Deployment CLI** `./chatballs` без Python и Node на хосте: реализованы `doctor`, `deploy`, `status`, `logs`; `install`, `update`, `backup`, `restore`, `rollback` — следующий этап. CI вызывает тот же entrypoint.
|
||||
- **Шлюз внутри Docker (Caddy)**: без зашитых хостов; http отвечает на любом адресе и порту; сертификат on-demand для хостов, одобренных backend; принудительного редиректа на https нет. За прокси панели шлюз принимает `X-Forwarded-Proto`.
|
||||
- **Runtime-конфигурация frontend**: домен не компилируется в образ, используются same-origin относительные URL.
|
||||
- **Разделение bootstrap-данных**: миграции и справочники — при каждом deploy; организация и владелец — один раз через мастер; демо-данные — опционально, со своим жизненным циклом, не профиль Compose и не переменная.
|
||||
|
||||
Отклонены: отдельный box-installer поверх CI-деплоя (два источника истины); конфигурация экземпляра в `.env`; демо-данные как профиль или переменная.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Установка пользователя и production владельца идут одним кодовым путём; образ frontend не зависит от домена; не нужны host nginx и переменные.
|
||||
- Caddy — часть поддерживаемой инфраструктуры; откат ограничен совместимостью миграций.
|
||||
- Часть команд CLI не реализована — их роль выполняют ручные операции и `deploy`.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: "0012"
|
||||
title: Мультитенантность, membership и изоляция данных
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Одна установка должна обслуживать несколько изолированных организаций. Прежняя модель связывала пользователя ровно с одной организацией и хранила в одной записи роль, должность, блокировку и MFA; блокировка деактивировала глобального пользователя. Один пропущенный фильтр по `organization_id` открывает чужие данные — нужна защита в несколько слоёв.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Organization — граница владения**, авторизации, аудита и удаления. Неизменяемый `public_id` (UUID) для API и маршрутов; внутренний ключ — для связей и RLS. Каждая сущность — platform-owned, tenant-owned или публичные данные точки входа; неопределённая принадлежность запрещена.
|
||||
2. **Identity и membership.** `HumanUser` — глобальная учётная запись; `OrganizationMembership` — роль, должность, статус, блокировка; `unique(user, organization)`. Блокировка membership закрывает доступ только к своей организации. Владелец и администратор не меняют чужой глобальный пароль, TOTP и сессии. В организации ровно один активный владелец; передача владения — атомарная аудируемая операция. TOTP принадлежит пользователю; требование MFA — политика организации. Приглашение — отдельная сущность (ADR-0025).
|
||||
3. **Tenant context** передаётся явно: `/api/v1/organizations/{organization_public_id}/...`. Порядок: аутентификация → разрешение организации → транзакция и контекст → активная membership → роль и видимость → данные. Последняя выбранная организация — предпочтение интерфейса, не авторизация (разные организации в разных вкладках).
|
||||
4. **Изоляция в приложении:** каждый endpoint, selector, сервис и репозиторий принимает проверенный контекст; объект загружается вместе с организацией; массовые операции, management-команды и импорт соблюдают те же правила.
|
||||
5. **Row-Level Security:** `ENABLE`/`FORCE`, `USING` и `WITH CHECK`, runtime-роль не superuser, без `BYPASSRLS`, не владеет таблицами; роль миграций отдельна. Контекст — `SET LOCAL`; session-level `SET` запрещён. Отсутствующий контекст строк не открывает. Platform-операции — своя ограниченная граница и аудит.
|
||||
6. **Ключи и связи:** корневые tenant-сущности и таблицы с независимым доступом, воркерами, своей RLS-политикой или экспортом имеют прямой `organization_id`. Cross-organization связи запрещены на всех слоях, включая constraint triggers.
|
||||
7. **Точки входа:** HTTP — организация в маршруте, единообразный отказ; WebSocket — проверка membership и принадлежности до подписки; фоновые задачи несут организацию и актора, воркер перепроверяет; публичные точки входа выводят организацию server-side; ключи хранилища начинаются с идентификатора организации.
|
||||
8. **Platform administration:** администратор установки — глобальный признак (`HumanUser.is_instance_admin`, ADR-0024), не membership во всех организациях; Django superuser — только break-glass. Доступ к данным организации — через явную support-сессию с организацией, причиной, сроком, полномочиями и аудитом.
|
||||
9. **Аудит субъекта** различает глобального пользователя, membership, platform-оператора, AI-агента и машинный credential; попытки cross-tenant доступа сохраняются отдельно.
|
||||
|
||||
Отклонены: одна организация на пользователя; активная организация только в сессии; только фильтры в приложении; только RLS; схема или база на организацию; отключение RLS в одиночной установке; platform-администратор как владелец каждой организации.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Один пользователь безопасно работает в нескольких организациях; ошибка в коде перестаёт быть единственным барьером.
|
||||
- RLS требует транзакционного контекста во всех контурах; аналитике по установке нужна отдельная граница; пропущенный tenant-ключ обнаруживается только тестом изоляции.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0013"
|
||||
title: Поверхности приложения, адреса и административный доступ
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
В одной установке живут рабочее приложение организаций, control plane установки и публичные порталы. Объединение их на одном host и в одной сборке повышает риск раскрытия платформенных функций и смешения cookie, маршрутов и авторизации. Django admin — break-glass инструмент и не должен публиковаться в интернете.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Адрес — свойство установки.** Зашитых доменов нет; свежая установка открывается по IP или выбранному домену; адрес запоминается на хосте, где прошли мастер. Порталы — на собственных хостах (ADR-0016). Платформенная поверхность включается отдельным доменом; пока он не задан, её матчер ни с чем не совпадает.
|
||||
2. **Разделение поверхностей:** приложение организации (рабочее пространство, tenant API, публичные точки входа); platform control plane (отдельный домен); Django admin (только loopback). Публичная поверхность определяется по HTTP Host **до** разбора маршрута; неизвестный путь публичного хоста — публичный `404`, а не форма входа.
|
||||
3. **Раздельные точки входа backend:** `backend-app`, `backend-platform`, `backend-admin` — один образ, свои URLConf и минимальные наборы маршрутов. Учётные данные БД разделены по процессам (ADR-0026).
|
||||
4. **Django admin только локально** — loopback, доступ через SSH-туннель; публичный reverse proxy для admin запрещён без нового решения. Ежедневное управление — через интерфейс.
|
||||
5. **Сессии и cookie:** раздельные host-only сессии приложения и платформы; общая cookie на домен запрещена; без `Domain`, `HttpOnly`, подходящий `SameSite`. По https — префикс `__Host-`, `Secure` и HSTS; по http — обычные имена.
|
||||
6. **Границы API:** tenant API — UUID организации, membership, роль, видимость, RLS; платформенное API — отдельное пространство имён и аудит, к данным организации — только через support-сессию. Настройки установки — `/api/v1/instance/` (ADR-0024). Видимость маршрута во frontend авторизацией не является.
|
||||
|
||||
Отклонены: одна общая поверхность; admin по публичному адресу; защита admin только правилом шлюза; общая cookie на домен; отдельный домен для API; домены, зашитые в образ.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Управление установкой отделено от рабочего приложения; admin вне поверхности атаки; установка переезжает с IP на домен без пересборки.
|
||||
- Три процесса backend; раздельные cookie, hosts и CSRF origins.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "0014"
|
||||
title: Произвольный OpenAI-совместимый провайдер
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Правило «адаптер под каждого провайдера и запрет свободного ввода модели» (ADR-0008) означало закрытый список провайдеров: свой inference-endpoint или менее известного провайдера нельзя было подключить без правки кода. Для self-hosted продукта выбор провайдера — дело пользователя.
|
||||
|
||||
## Решение
|
||||
|
||||
Организация подключает произвольный OpenAI-совместимый endpoint тремя значениями: **endpoint** (базовый URL), **API key** (шифруется в контуре организации), **модель** (свободный текст).
|
||||
|
||||
Исключения из общих правил интеграций для этого режима: модель вводится текстом; любой OpenAI-совместимый endpoint принимается без адаптера; каталог не требуется. Остальное сохраняется: маршрутизация через интеграцию агента, запрет подмены выбора, шифрование, статус проверки, границы организации, серверные credentials.
|
||||
|
||||
Контракт: OpenAI Chat Completions — `POST /chat/completions`, `Authorization: Bearer <key>`, ответ с `choices[0].message.content` и `usage`; переиспользуется HTTP-слой OpenRouter. Endpoint другой формы требует отдельного адаптера.
|
||||
|
||||
Поле модели читается в runtime; валидации против провайдера нет — ответственность на владельце.
|
||||
|
||||
Отклонены: OpenRouter, поглощающий generic-режим (теряется каталог); сохранить запрет свободного ввода; требовать адаптер для любого провайдера.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Любой OpenAI-совместимый провайдер без правки кода; OpenRouter остаётся готовой интеграцией с каталогом.
|
||||
- Модель не проверяется до первого вызова; ошибка идентификатора проявляется как ошибка провайдера в статусе интеграции.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0015"
|
||||
title: Email-подключение (IMAP/SMTP)
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Часть клиентов пишет на почту поддержки, и эта переписка жила вне системы: без агента, передачи человеку и истории по контакту. Почта самой установки (приглашения, сброс пароля) — транзакционная и диалоговым транспортом не является.
|
||||
|
||||
## Решение
|
||||
|
||||
- Почтовый ящик — подключение рода «мессенджер» наравне с ботами: ящиков несколько, каждый привязан к одному агенту, заводится и проверяется в «Интеграциях».
|
||||
- Секрет — пароль ящика (обычно пароль приложения), один для IMAP и SMTP. Несекретно: адрес (он же логин), хосты, порты, флаги SSL. OAuth2 в первой итерации не поддерживается.
|
||||
- **Приём:** поллинг IMAP в воркере; курсор `uidvalidity:last_uid`, смена `UIDVALIDITY` сбрасывает курсор; при первом подключении курсор ставится на текущий максимум — история ящика не импортируется; идемпотентность по `Message-ID`; идентичность — адрес отправителя в нижнем регистре; `text/plain` всегда, для HTML — дополнительно санитизированный HTML со структурным allowlist; собственные письма ящика пропускаются; вложения в первой итерации не принимаются, в текст добавляется пометка.
|
||||
- **Отправка:** через outbox по SMTP, `Re: <тема>`, `In-Reply-To` и `References` на последнее входящее; диалог хранит транспортную мету. Исходящие диалоги, начатые сотрудником, не создаются.
|
||||
- **Проверка** проходит только при успехе обеих сторон: IMAP (соединение, вход, папка) и SMTP (соединение, `EHLO`, вход).
|
||||
- **Границы:** не заменяет почту установки, не используется для писем аутентификации, не канал уведомлений сотрудников, не для рассылок.
|
||||
|
||||
Отклонены: OAuth2 в первой итерации; IMAP IDLE вместо поллинга; отдельный род интеграции «почта».
|
||||
|
||||
## Последствия
|
||||
|
||||
- Почта становится полноценными диалогами; переиспользуются поллер, inbox/outbox, шифрование и проверка.
|
||||
- Задержка в пределах интервала воркера; вложения входящих теряются (с пометкой); ящик, который параллельно читают люди, может давать двойные ответы — рекомендуется выделенный ящик.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0016"
|
||||
title: Публичные порталы поддержки
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Организации нужен публичный Help Center: база статей, которую читает клиент, с возможностью тут же написать в поддержку. Порталов может понадобиться несколько — для разных брендов или направлений.
|
||||
|
||||
## Решение
|
||||
|
||||
- **Портал — объект организации.** Список, создание, настройка, контент, публикация и архив — в одном разделе; отдельных прав портала нет, действуют права роли.
|
||||
- **Адрес — отдельный публичный хост**: `https://<portal-key>.<базовый help-домен>/` и `/articles/<slug>`. Можно подключить свой домен: он нормализуется и проверяется (не совпадает с доменом приложения и адресом портала); подтверждения владения нет, только техническая проверка, что домен ведёт на сервер. До активации работает адрес по умолчанию. Поверхность определяется по Host до разбора маршрута; неизвестный путь — публичный `404`. Шлюз выпускает сертификаты для доменов порталов по одобрению backend.
|
||||
- **Без ограничений:** лимитов и тарифных условий нет; черновик не блокирует создание следующего портала.
|
||||
- **Контент:** категории, статьи и неизменяемые редакции; публикация выбирает одну редакцию. Портал и библиотека знаний не объединяются; агент может ссылаться на статью (ADR-0017).
|
||||
- **Общий визуальный язык:** управление материалами портала — доменная адаптация библиотеки знаний, а не второй интерфейс. Компоненты дерева, таблицы, панели действий, категорий, редактора и подтверждений лежат в общем модуле и импортируются обеими функциями; копирование разметки под другим префиксом переиспользованием не считается.
|
||||
- **Веб-чат портала** — конкретный опубликованный виджет (ADR-0018), разрешающий origin портала и ведущий в активного агента. Только штатный лоадер; свой launcher, iframe или вторая реализация чата запрещены.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Портал не появляется в глобальной навигации установки; у каждого — свой адрес и тема (ADR-0022).
|
||||
- Публичный Help Center не загружает внутренний интерфейс как fallback.
|
||||
- Архивный портал — только чтение и восстановление.
|
||||
- Изменение адресного контракта требует отдельного решения.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
id: "0017"
|
||||
title: Статьи портала как источник знаний агента
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
У агента поддержки и публичного Help Center один предмет. Раньше статью писали в портал, а для агента повторяли знанием — копия расходится с оригиналом при первой правке. Фрагмент индекса обязательно принадлежал знанию, и retrieval физически не видел статью.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Агент ссылается на статью, а не на копию.** Вводится связь агента со статьями; зеркалирование отвергнуто (второй источник правды, синхронизация, нередактируемые записи). Правка статьи сразу меняет ответы агента.
|
||||
2. **Один индекс фрагментов на два источника**: знание либо статья, «ровно один источник» — CHECK-ограничение. Retrieval и учёт фрагментов — один запрос и одно пространство идентификаторов.
|
||||
3. **Индексируется только опубликованная редакция**: фрагменты строятся при публикации и снимаются при архивации; черновик не индексируется никогда. Связь агента со статьёй при архивации сохраняется.
|
||||
4. **Доступность — в границах организации**; в runtime проверяется, что статья опубликована, а портал не архивирован. Массовая операция над смешанной выборкой не падает: недоступные элементы пропускаются и возвращаются списком.
|
||||
5. **Состав знаний агента меняется только из библиотек** — массовыми действиями в разделах знаний и порталов. Карточка агента показывает нередактируемый список. Мастер создания агента знания не выбирает.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Нет двойного ввода; статья — единственный источник правды; агент может дать клиенту ссылку на статью Help Center.
|
||||
- Код, читающий источник фрагмента, обязан учитывать оба варианта.
|
||||
- Архивация и снятие публикации немедленно убирают статью из ответов.
|
||||
- Карточка агента перестаёт быть местом изменения состава знаний.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: "0018"
|
||||
title: Веб-виджет как самостоятельная точка входа
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Веб-чат используется в нескольких независимых местах: на портале, на сайте организации, на разных доменах и страницах. Раньше визуальный экземпляр виджета, веб-подключение и агент отождествлялись, а лоадер адресовал чат кодом агента. Нельзя было выбрать один из нескольких виджетов агента, держать разные списки доменов и оформление, достоверно записать источник обращения.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **`WebChatWidget` — самостоятельный объект**: `integration` (1:1 с WEB-подключением), `code` (неизменяем в организации), `public_key` (неизменяем, глобально уникален, не секрет), `name`, `status` (`DRAFT | PUBLISHED | DISABLED`), `allowed_origins`, `presentation_config`, `consent_config`, `anti_abuse_config`. `public_key` можно передавать в HTML; он не credential и не заменяет проверку origin и сессионный credential.
|
||||
2. **Разные ответственности:** агент — обработка (инструкции, знания, модель, группа); виджет — только вход (место установки, адресация, origins, оформление, согласие, anti-abuse). `Агент 1 — N WEB-подключение 1 — 1 WebChatWidget`. Выбор агента внутри виджета не поддерживается.
|
||||
3. **Вход только анонимный**; «режима» у виджета нет (ADR-0023). Агент виджета должен разрешать анонимные сессии.
|
||||
4. **Публичная маршрутизация — по ключу виджета**; клиент не выбирает и не подменяет агента.
|
||||
5. **Анонимная сессия привязана к виджету**: credential одного виджета не принимается другим; очистка хранилища или другое устройство — новая анонимная идентичность без автообъединения.
|
||||
6. **Источник обращения сохраняется**: диалог фиксирует виджет, из которого начался.
|
||||
7. **Конфигурация и origin принадлежат виджету**; origin — обязательная дополнительная проверка, а не единственная.
|
||||
|
||||
Отклонены: один код агента = один виджет; несколько веб-подключений с поиском по коду агента; один виджет с выбором агента.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Несколько виджетов одного агента — штатный сценарий; портал и сайт используют один контракт входа.
|
||||
- Виджет с историей не удаляется, а отключается; преемственность анонимной истории между виджетами намеренно отсутствует.
|
||||
- Одновременный показ нескольких launcher'ов на странице требует отдельного дизайн-решения.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: "0019"
|
||||
title: "Контакт-центр: удаление продаж и отделов, объединение агента и канала"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Chatballs развивался как операционная CRM: отделы продаж и поддержки, учёт внешних продаж через Product Sales API, атрибуция, коммерческий каталог, метрики выручки, capability/scope-авторизация с отделами и department-видимостью знаний.
|
||||
|
||||
Практика показала два расхождения. **Продажи не являются ценностью продукта**: организации нужен контакт-центр, а не учёт чужих коммерческих фактов. **Тройка «подключение → канал → агент» и отделы непосильны для администратора**: новая организация должна освоиться за минуты, а не изучать внутреннюю архитектуру.
|
||||
|
||||
## Решение
|
||||
|
||||
Это основание текущей продуктовой модели.
|
||||
|
||||
1. **Chatballs — платформа клиентских коммуникаций (контакт-центр).** Продажи, сделки, воронки, заказы, счета, атрибуция и выручка не входят ни в каком виде.
|
||||
2. **Домен продаж удалён полностью** без сохранения данных: приложения `sales` и `orders`, коммерческий слой каталога (`Offer`, `Price`, `MarketplacePublication`), метрики выручки и конверсии, налоговый профиль организации, разделы «Продажи» и «Продукты», capabilities `sales.*` и `products.*`.
|
||||
3. **Отделы (`Department`) упразднены.** Структура групп впоследствии заменена настраиваемыми группами (ADR-0021).
|
||||
4. **Агент и канал — одна сущность для пользователя.** Агент содержит имя, инструкции, модель и провайдера, знания, подключения и признак активности. Подключение — транспорт (ADR-0008), привязано к агенту и управляется только из карточки агента; разделов «Каналы» и «Подключения» в интерфейсе нет. В схеме БД сохраняется пара `Channel` — `AIAgent` 1:1.
|
||||
5. **Модель доступа** — роли вместо capability registry, access profiles и department scopes; deny-by-default в backend сохраняется. Действующие правила ролей — SPEC-0004.
|
||||
6. **Один рабочий экран сотрудника — чат**; командный центр удалён (сводка может вернуться отдельным решением).
|
||||
7. **`Product`** был скрыт из интерфейса; впоследствии удалён полностью (ADR-0023).
|
||||
8. **Знания — общая библиотека организации**, области видимости упразднены; агент использует только явно выбранные и включённые знания.
|
||||
9. **P2P-звонки сохраняются** (ADR-0010).
|
||||
10. **Онбординг за минуты**: создать агента → подключить точку входа → пригласить сотрудников → работать в чате. Любое решение, удлиняющее путь обязательными шагами, требует обоснования.
|
||||
|
||||
Тарифная привязка «один агент = одно оплаченное место» и сохранение коммерческого контура отменены (ADR-0020); фиксированные группы «Операторы»/«Поддержка», обязательная группа агента и её правила видимости заменены (ADR-0021).
|
||||
|
||||
## Последствия
|
||||
|
||||
- Продукт проще: меньше сущностей, разделов и понятий.
|
||||
- Демо-датасет и seed очищены от коммерческих данных.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
id: "0020"
|
||||
title: Open-source распространение и удаление тарифного контура
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Переход в контакт-центр (ADR-0019) сохранил коммерческий контур платформы: тарифы, подписки, entitlements, quotas, managed AI-кредиты и оплату через Точку. Владелец принял стратегию: продукт публикуется как open source для набора аудитории и известности, монетизация — отдельный облачный сервис voice-to-voice звонков. Тарифная модель в этой стратегии не нужна и удорожает продукт и порог входа.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Open source — основной способ распространения** (self-hosted, docker compose). Мультитенантность сохраняется. Managed-облако как сервис не входит.
|
||||
2. **Тарифный контур удалён полностью**: планы, подписки, entitlements, quotas, usage-биллинг, managed AI-кредиты, интеграция с Точкой, все тарифные ограничения (лимиты диалогов, подключений, «места» агентов). Остаются только rate limits, защита от злоупотреблений и технический учёт AI-потребления для статистики. Количество агентов не ограничено.
|
||||
3. **AI — только через провайдера организации** (ADR-0008, ADR-0014); managed AI и платформенные credentials удалены.
|
||||
4. **Монетизация — облачный сервис voice-to-voice звонков с ИИ**: закрытый, со своим названием и поминутными тарифами, открыт для любых клиентов. Телефония, медиа, голосовые модели, тарифы и учёт минут живут только в нём. Chatballs подключает его как интеграцию по API-ключу; в открытом репозитории — только клиентская часть (ключ, привязка номеров к агентам, приём событий звонка: контекст агента, инструменты, транскрипты, передача оператору). Без оплаченного ключа интеграция ничего не делает. В открытой части остаются P2P-звонки и голосовые сообщения.
|
||||
5. **Имя продукта — Chatballs**; прежние имена не используются ни в интерфейсе, ни в текстах, ни в идентификаторах. Зашитых доменов нет.
|
||||
6. **Проектная документация — приватный контур** владельца, исключена из репозитория; в open-source входит отдельная публичная документация.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Снесены `subscriptions`, managed AI и связанные platform-разделы; tenant provisioning создаёт организацию без подписки и usage period.
|
||||
- Демо-датасет и seed очищены от тарифных данных.
|
||||
- Интеграция с облачным сервисом voice-to-voice пока не реализована (T-006).
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: "0021"
|
||||
title: Настраиваемые группы организации и маршрутизация диалогов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Отделы были заменены двумя фиксированными группами «Операторы» и «Поддержка», что предполагало универсальность деления на «новых» и «существующих» клиентов. Решение владельца: у каждой организации свои отделы, платформа не навязывает структуру. При этом группы не должны быть обязательным шагом запуска.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Группы задаются организацией**: tenant-сущность `EmployeeGroup` (имя, цвет, состав). Создаёт, переименовывает и удаляет администратор или владелец; количество не ограничено; групп может не быть. Группа — только граница видимости диалогов: без прав, знаний, навигации, должностей и иерархии. Сотрудник может состоять в нескольких группах.
|
||||
2. **Группа агента необязательна**: агент с группой — его новые диалоги попадают в неё; без группы — диалоги видны всем.
|
||||
3. **Диалог маршрутизируется независимо**: наследует группу агента, но его можно перенести в другую группу или убрать из группы; у диалога есть необязательный ответственный. Перенос и смена ответственного доступны администратору, владельцу и сотрудникам, видящим диалог.
|
||||
4. **Видимость:** владелец и администратор — все диалоги; сотрудник — диалоги своих групп + без группы + где он ответственный (независимо от группы).
|
||||
5. **Онбординг без групп** — полноценный сценарий: все видят всё; группы включаются, когда организация дорастает.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Модель: `EmployeeGroup` (organization, name), M2M «сотрудник—группа», nullable группа агента, nullable `Conversation.group` (наследуется, переопределяется), nullable `Conversation.assignee`.
|
||||
- Проверка видимости — deny-by-default во всех HTTP, WebSocket и фоновых операциях.
|
||||
- UI: «Группы» в настройках; выбор группы в карточке агента; перенос и ответственный в диалоге; фильтр списка диалогов.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0022"
|
||||
title: Визуальные темы порталов поддержки
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Публичный Help Center собран в `apps/internal-ui` и рисовался токенами дизайн-системы рабочего пространства (`--n-*`, `--surface-*`). Организации нужен другой облик публичной страницы, разработчикам — возможность добавлять оформления, не трогая бэкенд и чужие файлы. Требования: добавление темы без изменений БД, API и реестров; тема не может сломать вёрстку; отсутствие темы в сборке не ломает сохранённые порталы.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Тема — папка** `apps/internal-ui/src/features/help-center/themes/<id>/` с `manifest.ts` (имя, описание, схемы, цвета превью) и `theme.css`. Реестр — автообнаружением (`import.meta.glob`); id совпадает с именем папки (проверяет тест).
|
||||
2. **Контракт токенов** `themes/contract.css` объявляет все `--help-*` со значениями по умолчанию. Вёрстка Help Center читает **только** их; `--n-*`, `--surface-*` и литеральные цвета в help-CSS запрещены. Свобода темы: переопределение токенов; собственные правила под `html[data-portal-theme="<id>"]`; подмена компонентов и JS не поддерживаются. Тест каталога проверяет скоуп, префикс `@keyframes` и то, что объявлены только токены контракта.
|
||||
3. **Бэкенд не знает каталог тем**: `SupportPortal.theme` (идентификатор), `theme_scheme` (`LIGHT | DARK | SYSTEM`), `theme_settings` (JSON, зарезервирован). Проверяется только формат id. Неизвестная тема деградирует до `classic` на публичной странице и показывается как недоступная в настройках — без молчаливой подмены выбора.
|
||||
4. **Цветовая схема** — атрибут `data-theme` на `<html>`, тот же механизм, что у интерфейса; `SYSTEM` следует `prefers-color-scheme`; неподдерживаемая темой схема не предлагается, сохранённая деградирует до основной.
|
||||
5. **Загрузка:** манифесты статичны, CSS темы ленив; до применения темы портал держит boot-загрузчик — без вспышки оформления.
|
||||
|
||||
Тема по умолчанию `classic` повторяет базовое оформление и ничего не переопределяет.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Публичный манифест `/api/v1/help/` отдаёт `theme`, `themeScheme`, `themeSettings`.
|
||||
- Вёрстка Help Center переведена на `--help-*` без визуальных изменений.
|
||||
- Сами темы не разрабатываются этим решением; визуальные решения за владельцем. Инструкция разработчику — документ «Разработка визуальной темы портала» («portal-theme-development.md»).
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: "0023"
|
||||
title: Удаление сущности Product и авторизованного in-product чата
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
После перехода в контакт-центр `Product` остался в схеме как скрытая техническая привязка, но скрытым не стал: в настройках портала жил раздел «Продукты и поддержка», в списке порталов — колонка «Продукты». На `Product` держался целый контур: контракты идентификации клиента поддержки, снимки личности, Product Support Token, режим виджета `AUTHENTICATED_PRODUCT`, признак канала `requires_authenticated_product_identity`, XOR-инвариант identity диалога, `mode=support` лоадера и отдельное приложение виджета. Контакт-центр не знает о продуктах клиента — у контура нет владельца в продуктовой модели.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **`Product` удалён полностью** (приложение `products`, таблица `identity_product`); ссылки сняты с канала, учёта AI и портала.
|
||||
2. **Авторизованный in-product чат удалён**: приложение `support`, режим `support` веб-чата, `ChatballsChat.init` с token provider, поле `WebChatWidget.mode` и ключ `mode` в ответах API. Внешний продукт приводит клиента в поддержку обычным анонимным виджетом.
|
||||
3. **У диалога один источник identity — контакт**; диалоги из снимков личности получили контакты.
|
||||
4. **Политика канала** — только `allow_anonymous_sessions` и `allow_self_reported_contact`; коммерческие флаги и инварианты политики удалены (`channels/0008`); удалены остатки налогового профиля организации (`identity/0032`).
|
||||
5. **Портал не знает о продуктах**: раздел, колонка, связь и endpoint продуктов удалены; выбор виджета — `GET /portals/{id}/widgets/`, только анонимные виджеты.
|
||||
6. **Данные не экспортируются** — решение владельца; таблицы сняты миграциями, следы удалённых приложений вычищены из миграций уцелевших.
|
||||
|
||||
## Последствия
|
||||
|
||||
- У виджета нет понятия «режим»; возврат авторизованного входа потребует нового проектирования без сущности «продукт».
|
||||
- Дизайн-baseline «Порталы» приведён в соответствие.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0024"
|
||||
title: Администратор установки и настройки инсталляции
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Настройки установки — публичный адрес, почта, TURN, хранилище, язык по умолчанию — общие для всех организаций, но жили под путём организации и редактировались любым с `company.manage` хотя бы в одной организации: владелец одной организации переключал SMTP, домен и бакет всем остальным. Роль platform administrator была заявлена, но не существовала; мастер выдавал владельцу `is_superuser` — break-glass доступ к Django admin, а не продуктовую роль.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **`HumanUser.is_instance_admin`** — глобальный булев признак; не выводится из членств и не даёт прав внутри организаций. Первым получает владелец из мастера; существующим установкам выдан миграцией по `is_superuser`. Передача — вне интерфейса: `set_instance_admin --email … --grant|--revoke` и Django admin; последнего активного администратора отозвать нельзя. Экрана управления администраторами нет (нет в baseline).
|
||||
2. **Настройки установки — путь без организации** `/api/v1/instance/`: `settings/`, `settings/email-check/`, `storage/`, `storage/check/`, `storage/migrate/`. Без tenant middleware. Чтение — менеджеру любой организации (для карточки TURN), изменение — только администратору установки. Прежние пути отвечают 404.
|
||||
3. **Аудит и события уровня установки** — с `organization_id IS NULL`, как platform-события.
|
||||
4. **Интерфейс:** «Платформа» и «Хранилище файлов» в «Настройках» видны только администратору установки; сессия отдаёт `isInstanceAdmin`; остальным менеджерам relay только на чтение.
|
||||
5. **Создание организаций из интерфейса** (с 1.6.0): кнопка «Добавить организацию» в переключателе видна администратору установки и тем, кто где-либо владелец или администратор; создавший становится владельцем. После входа пользователь нескольких организаций выбирает, с какой начать.
|
||||
|
||||
Отклонены: переиспользовать `is_superuser` (открывает admin и обход проверок); оставить настройки под организацией с проверкой признака.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `is_superuser` — только break-glass Django admin.
|
||||
- SMTP, TURN и хранилище общие на установку — по решению.
|
||||
- Экран управления организациями у администратора установки отсутствует (T-005).
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: "0025"
|
||||
title: Приглашения в организацию
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Учётная запись глобальна, членство — на организацию, но попасть во вторую организацию было нельзя: форма создания сотрудника отвечала «e-mail занят» и заодно раскрывала чужим организациям, какие адреса зарегистрированы. Приглашение владельца из провижининга существовало, но письмо никто не отправлял, а регистрации по ссылке не было.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Членство — только с согласия человека.** Существующая учётная запись никогда не присоединяется молча; администратор лишь выписывает приглашение.
|
||||
2. **Форма создания сотрудника не меняется.** Новый адрес — учётная запись с паролем первичного доступа. Адрес активной учётной записи из другой организации — приглашение с той же ролью, должностью, телефоном и группами и событие на отправку письма. В своей организации (и для деактивированной учётной записи) адрес «занят». Членство собирается из полей приглашения при принятии; повторное приглашение заменяет прежнее.
|
||||
3. **Письмо отправляет воркер, токен выпускается при отправке** — открытый токен нигде не хранится. Ссылка `/join?token=…`; язык — получателя, иначе организации. Тот же обработчик шлёт приглашение владельца из провижининга.
|
||||
4. **Ссылка `/join`:** предпросмотр по токену; есть учётная запись — вход и автоматическое принятие; нет — форма имени и пароля из элементов мастера (`register`, для существующего адреса — `account_exists`). Для владельца принятие активирует организацию (`PENDING_OWNER → ACTIVE`). Приглашение до входа находится через каталог входа (исправлено в 1.6.0).
|
||||
5. **Ожидающие приглашения в списке сотрудников** — строки «Приглашён» из baseline; фильтры действуют; из меню — «отправить ещё раз» (срок продлевается) и «отозвать». Требуют `employees.manage` и права выдать роль.
|
||||
|
||||
Отклонены: присоединять существующую учётную запись сразу; хранить открытый токен; отдельный экран приглашений.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Аудит: `identity.employee_invited`, `identity.invitation_accepted`, `identity.invitation_revoked`.
|
||||
- Ответ создания сотрудника может содержать `employee: null`, `invited: true`.
|
||||
- Остаточная различимость: администратор видит, что ушло приглашение, то есть узнаёт о существовании учётной записи. Полное закрытие требует перевода и новых сотрудников на приглашения, что меняет кадры baseline и требует отдельного решения.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "0026"
|
||||
title: Минимальные привилегии runtime-процессов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Аудит поддержки нескольких организаций показал, что граница изоляции прочна в базе, но размыта на уровне процессов: платформенный API создания организаций не работал в поставке (роль platform не имела прав на свои таблицы, тесты ходили владельцем кластера); публичный `backend-app` держал platform-соединение, а в его файловой системе лежали пароли всех ролей, включая роль миграций; роль app читала таблицу организаций без ограничений.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Гранты платформенной роли** на таблицы оператора, токена, провижининга (без DELETE) и приглашений под tenant-политикой. Провижининг проверяется тестом под реальной runtime-ролью.
|
||||
2. **`backend-app` работает только ролью app.** Каталоги входа (`chatballs.*_directory`) — security-barrier вьюхи с SELECT для app. Организацию роль app вставляет только в её собственном контексте (`id = chatballs.current_organization_id()`): id резервируется из последовательности заранее (`tenancy.lookup.reserve_organization_id`) — так работают и мастер первого запуска, и «Добавить организацию». Алиас `platform` есть только у `backend-platform` и воркеров (`CHATBALLS_DB_PLATFORM_ALIAS=1`); в остальных процессах обращение к нему падает сразу.
|
||||
3. **Три тома секретов:** `chatballs-secrets` (ключ подписи, ключ шифрования, пароль app, секрет TURN — все сервисы); `chatballs-secrets-platform` (пароль platform — secrets, postgres, backend-platform, воркеры); `chatballs-secrets-schema` (пароли migration и владельца кластера — secrets, postgres, init, backend-admin). Генератор переносит файлы существующих установок (копия, сверка, удаление), пароли не меняет. `doctor` проверяет, что `backend-app` не видит паролей platform и migration. Вариант `volume.subpath` отклонён (требует Docker 26 и Compose 2.24).
|
||||
4. **Строка организации видна app только в её контексте**; входы без контекста находят id через `chatballs.organization_directory` и читают строку внутри `tenant_atomic`.
|
||||
5. **nginx разрешает upstream на каждом запросе** через `resolver` на DNS Docker — иначе после пересоздания контейнера WebSocket уходил в чужой сервис.
|
||||
|
||||
Отклонены: платформенные учётные данные в `backend-app` «на всякий случай»; INSERT организаций для app без ограничения; расширение каталога организаций именем и логотипом.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Резервная копия включает три тома секретов.
|
||||
- Откат на релиз до разделения томов требует вернуть файлы в общий том (команда — в истории релизов).
|
||||
- Тесты бэкенда в dev-стеке — из контейнера `backend-admin`.
|
||||
- `backend-admin` по-прежнему обходит RLS ролью миграций; его защищает привязка к loopback.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: "0027"
|
||||
title: Обновление установки из интерфейса
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Установка обновлялась только из консоли сервера; администратор не узнавал о новых версиях иначе как со страницы релизов, самообслуживание без SSH было невозможно. `backend-app` работает с минимальными правами (ADR-0026), и давать ему управление Docker нельзя.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Канал релизов — GitHub Releases** репозитория поставки (`CHATBALLS_UPDATE_REPO`, по умолчанию `dartdavros/chatballs`). Версия зашивается в образ бэкенда (`CHATBALLS_VERSION`; у dev-сборки `dev` обновлений нет). Релиз принимается только с ассетом `compose.yaml` по адресу `…/releases/download/v<версия>/compose.yaml`; версии сравниваются семантически. Проверка — из воркера раз в `CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS` (15 минут) и по кнопке; результат — в одной строке `UpdateState` вне организаций; ошибка канала ничего не роняет.
|
||||
2. **Отдельный сервис `updater`** (образ на `docker:cli` с двумя скриптами) — единственный с `/var/run/docker.sock`. Обмен с `backend-app` — файлами в томе `chatballs-updates`: `heartbeat` (каждые 5 с; старше 90 с — «обновление недоступно»), `request.json`, `status.json` (`running`/`done`/`failed` со стадиями), `apply.log`. Updater принимает только цифровую версию, адрес со страницы релизов своего репозитория и `compose.yaml`, где все образы пришпилены `@sha256` из репозитория поставки; проект, каталог и том узнаёт из меток своего контейнера.
|
||||
3. **Установка в отдельном контейнере** `chatballs-updater-apply` (тот же образ, `docker run -d --rm`, собственная входная точка установки): `config -q` → `pull` → `up -d --wait`. После успеха новый `compose.yaml` копируется в каталог установки. Второй запрос во время установки отклоняется; установка, оборвавшаяся без результата, помечается неудачной.
|
||||
4. **Интерфейс:** баннер администратору установки «Доступна версия X» со ссылкой «Что нового», кнопкой «Обновить» и подтверждением; баннер переживает перезапуск бэкенда и показывает стадию, итог или причину сбоя. То же и кнопка «Проверить» — в карточке «Обновления» раздела «Платформа». Аудит `updates.install_requested`. Читать — владелец любой организации, проверять и устанавливать — администратор установки.
|
||||
|
||||
Отклонены: Docker-сокет у `backend-app`; Watchtower и аналоги; проверка версии из браузера напрямую в GitHub.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `docker.sock` у `updater` равен root на хосте; поверхность сведена к минимуму. Без сервиса интерфейс показывает «обновление недоступно», CLI-путь остаётся.
|
||||
- Откат — вручную (`compose.yaml` предыдущего релиза); автоматического отката нет — миграции не возвращаются.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: "0028"
|
||||
title: "Отделение хода AI от приёма сообщений: сервис worker-events"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Агент с несколькими подключениями (сайт и мессенджер) отвечал с задержками или молчал. Ответ считался прямо в приёме сообщения: пока модель думала над одним вопросом, ни одно другое входящее по всей установке не забиралось; один ход мог занять до трёх минут. Отозванный ключ одной организации мог погасить AI у остальных.
|
||||
|
||||
## Решение
|
||||
|
||||
- Приём (`worker --role=poller`) только записывает сообщение; ход AI, расшифровка голосовых, доставка уведомлений и приглашений на звонки — событиями outbox, которые обрабатывает отдельный сервис `worker-events` (`run_worker --role=events`).
|
||||
- `worker` (опрос) — строго один экземпляр: курсоры и паузы после сбоя живут в памяти процесса.
|
||||
- `worker-events` масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2); события разбираются с `skip_locked`; ходы одного диалога идут строго по очереди (`claim_next_outbox_event`); ход упавшего процесса возвращается в очередь. Реплик держать немного — они ходят к провайдеру одним ключом организации.
|
||||
- У сообщения есть состояние хода AI; ответ ограничен сроком: не успел или пролежал в очереди слишком долго — диалог уходит оператору, клиент получает понятный текст.
|
||||
- Отказ провайдера по существу запроса (неверный ключ, несуществующая модель, слишком длинный запрос) не повторяется; сбои провайдера считаются по каждой организации отдельно.
|
||||
- Подключение с сорвавшимся опросом возвращается в работу через минуту.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Каналы не ждут друг друга; разносить подключения по агентам ради обхода залипания не нужно.
|
||||
- В установке появился сервис `worker-events`; миграция `conversations/0026`.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: "0029"
|
||||
title: Модели ответов и расшифровки голосовых принадлежат агенту
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Речь в текст умеет не всякая модель ответов: у части провайдеров (например, Anthropic, Yandex Foundation Models) эндпоинта расшифровки нет вовсе. Кроме того, на одном ключе провайдера живут разные агенты, и модель им нужна разная, а модель задавалась на интеграции.
|
||||
|
||||
## Решение
|
||||
|
||||
- На карточке агента две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых. Расшифровку можно направить в любую другую интеграцию организации (OpenAI, Groq, собственный Whisper), ответы при этом пишет основной провайдер.
|
||||
- Модель принадлежит агенту, а не ключу. Пустое поле означает «как в интеграции»; подсказка показывает значение по умолчанию.
|
||||
- Модель расшифровки: своя на агенте → из интеграции расшифровки → `whisper-1`. Пустой выбор интеграции расшифровки — «как у ответов».
|
||||
- Ошибка расшифровки не показывает оператору ответ чужого API: в ленте — фраза о том, что произошло и где чинить; ответ провайдера — в журнал.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Миграции `ai/0020`, `ai/0021`; `ai/0022` освободила поле модели у агентов, чья модель совпадала с моделью интеграции, — они следуют за настройкой ключа.
|
||||
- Маршрутизация по-прежнему только через явно выбранные интеграции агента (ADR-0008).
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: "0030"
|
||||
title: Данные с сайта — недоверенные поля контакта в контексте агента
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Организациям нужно, чтобы оператор и AI-агент видели в веб-чате то, что сайт уже знает о посетителе: ID клиента, номер и статус заказа, признак активного заказа. Значения меняются по ходу диалога.
|
||||
|
||||
Данные приходят из браузера посетителя через публичный виджет: их может подменить любой, кто откроет консоль. ADR-0004 требует минимизировать данные, передаваемые LLM. ADR-0003 определяет идентификацию клиента и объединение контактов. Встроенные поля контакта (имя, почта, телефон) оператор правит вручную, и эти правки нельзя затирать.
|
||||
|
||||
Варианты: (1) писать всё в поля Contact или в JSON на контакте; (2) отдельное хранилище значений со схемой в настройках подключения; (3) подписанный токен от сервера сайта (JWT) — надёжно, но требует бэкенда у сайта и ломает запуск за минуты.
|
||||
|
||||
## Решение
|
||||
|
||||
1. Схема своих полей — в конфигурации WEB-подключения (`integration.config.fields`): ключ, подпись, тип (string, number, boolean, datetime, enum, email, phone, url), значения списка с цветом, признак «Видит AI», порядок. Не больше 30 полей. Ключи `name`, `email`, `phone` зарезервированы.
|
||||
2. Значения — в отдельной таблице `contact_field_values (organization, contact, integration, key, value jsonb, updated_at)`: одно последнее значение на ключ, с RLS как у остальных tenant-таблиц. Историю изменений хранит лента диалога (системные события), а не эта таблица.
|
||||
3. `name`, `email`, `phone` пишутся в контакт, только если поле в контакте пустое или прошлое значение пришло с того же подключения. Ручные правки оператора не затираются.
|
||||
4. Данные с сайта недоверенные. Они не участвуют в авторизации, идентификации и объединении контактов (ADR-0003). В UI помечены «с сайта» и доступны только для чтения. Неизвестные ключи и значения неверного типа отбрасываются молча для сайта, с предупреждением в журнал.
|
||||
5. В системный промпт агента попадают только поля с включённым «Видит AI». Подаются отдельным блоком как сведения от сайта, не как инструкции. Это явное согласие администратора в духе ADR-0004.
|
||||
6. Удаление поля из схемы значения не удаляет, но перестаёт их показывать и передавать AI.
|
||||
7. Подписанная передача (JWT от сервера сайта) — возможное развитие, в этот объём не входит.
|
||||
|
||||
## Последствия
|
||||
|
||||
Плюсы: оператор и AI видят контекст сайта без интеграций на стороне бэкенда сайта: достаточно одного вызова JS. Схема живёт рядом с виджетом и не требует миграций на каждое новое поле. Карточка контакта не засоряется, ручные правки защищены.
|
||||
|
||||
Минусы и риски: посетитель может подменить значения, поэтому AI может ответить по ложному статусу. Администратор должен понимать это, включая «Видит AI». Значения одного контакта из разных подключений хранятся раздельно по `integration`. Поиск и фильтрация контактов по этим полям в объём не входят. Появляется ещё одна tenant-таблица, для неё нужны тесты изоляции.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Архитектура Chatballs
|
||||
|
||||
## 1. Принципы
|
||||
|
||||
- **Одна кодовая база, одна правда.** От одиночной установки до многоорганизационной работают один backend, один frontend, один `compose.yaml` и один порядок обновления. Отдельных редакций нет.
|
||||
- **Модульный монолит.** Домены — логические модули одного Django-приложения. Микросервисов и второй базы нет. Фоновые обработчики — отдельные процессы из того же кода.
|
||||
- **Organization — граница изоляции.** Все клиентские данные принадлежат организации прямо или через однозначную цепочку владения. `organization_id`, переданный клиентом, никогда не основание доступа (ADR-0012).
|
||||
- **Один пользователь — несколько организаций.** Учётная запись глобальна, связь с организацией — через membership.
|
||||
- **Простота для администратора.** Понятий минимум: агент, подключение внутри агента, сотрудник, группа, контакт, диалог, знание, портал.
|
||||
- **Никакой коммерции.** Ни продаж, ни заказов, ни платежей, ни собственного биллинга (ADR-0020).
|
||||
- **Ничего не спрашивать до запуска.** Установка не требует переменных окружения; всё, что зависит от площадки, задаётся в интерфейсе и хранится в БД (ADR-0011).
|
||||
|
||||
## 2. Верхнеуровневая модель
|
||||
|
||||
```text
|
||||
Platform
|
||||
├── Platform layer
|
||||
│ ├── HumanUser, Organization, OrganizationMembership
|
||||
│ ├── InstanceSettings — свойства самой установки
|
||||
│ ├── tenant provisioning
|
||||
│ └── platform administration
|
||||
└── Organization workspace
|
||||
├── сотрудники, роли и группы
|
||||
├── агенты и их подключения
|
||||
├── контакты, диалоги, сообщения, шаблоны ответов
|
||||
├── звонки
|
||||
├── знания и retrieval
|
||||
├── порталы поддержки
|
||||
└── уведомления и аудит
|
||||
```
|
||||
|
||||
## 3. Структура репозитория
|
||||
|
||||
```text
|
||||
code/chatballs/
|
||||
├── apps/
|
||||
│ ├── backend/ Django: API, ASGI, домены, фоновые воркеры
|
||||
│ ├── internal-ui/ рабочее пространство организации и публичные порталы (по HTTP Host)
|
||||
│ └── web-chat/ публичный веб-виджет, клиентская страница звонка, лоадер
|
||||
├── packages/
|
||||
│ ├── ui/ общие компоненты и токены
|
||||
│ ├── contracts/ общие типы и контракты API
|
||||
│ └── shared/ общая TypeScript-логика
|
||||
├── design/baseline/ утверждённые макеты
|
||||
├── deploy/ cli, docker, nginx, postgres, secrets
|
||||
├── scripts/ запуск и проверки
|
||||
├── tests/ сквозные тесты и тесты CLI
|
||||
├── compose.yaml единственный production-манифест
|
||||
├── compose.dev.yaml override для разработки
|
||||
└── Caddyfile
|
||||
```
|
||||
|
||||
Решение — ADR-0005.
|
||||
|
||||
## 4. Модули backend
|
||||
|
||||
| Модуль | Ответственность |
|
||||
|---|---|
|
||||
| `identity` | пользователи, организации, membership, роли, группы, приглашения, мастер первого запуска, настройки установки, аудит, демо-датасет |
|
||||
| `tenancy` | tenant-контекст, транзакции с RLS, маршрутизация ролей БД, каталоги входа, учёт объёма хранилища |
|
||||
| `channels` | якорь обработки: группа диалогов агента, ссылка на провайдер-интеграцию |
|
||||
| `ai` | агент, знания, категории, вложения, фрагменты и retrieval, учёт вызовов LLM, расшифровка голосовых |
|
||||
| `integrations` | LLM-провайдеры (OpenRouter, Custom, Demo) и подключения-транспорты (Telegram, MAX, VK, WEB, EMAIL), секреты, проверки |
|
||||
| `conversations` | контакты, идентичности подключений, диалоги, сообщения, метки, шаблоны ответов, транспорты приёма и отправки |
|
||||
| `calls` | P2P-звонки: сессии, приглашения, участники, метрики |
|
||||
| `webchat` | веб-виджет: конфигурация, публичный лоадер, анонимные сессии |
|
||||
| `support_portals` | порталы, категории, статьи и редакции, публичная выдача, обратная связь |
|
||||
| `notifications` | уведомления, прочтения, привязки служебных ботов |
|
||||
| `platform` | platform-операторы, токены, provisioning организаций |
|
||||
| `events` | inbox/outbox, идемпотентная доставка, очередь хода AI |
|
||||
| `updates` | состояние обновлений установки |
|
||||
| `health`, `http`, `api` | health-эндпоинты и общая инфраструктура HTTP |
|
||||
|
||||
### Агент и канал
|
||||
|
||||
Пользователь видит один объект — **Агента**. В схеме это пара `Channel` — `AIAgent` один к одному: канал держит группу видимости и ссылку на провайдер-интеграцию, агент — имя, инструкции, модель, знания и статьи. Подключения привязаны к каналу. Это осознанный остаток объединения (ADR-0019): слияние выполнено в продуктовой модели и UI, но не в схеме БД. В API и UI «канал» как самостоятельный объект не появляется.
|
||||
|
||||
## 5. Пользователи и доступ
|
||||
|
||||
`HumanUser` — глобальная identity (email, аутентификация, MFA, глобальный статус, признак `is_instance_admin`). `OrganizationMembership` — участие в организации: роль, должность, статус, блокировка; `unique(user, organization)`.
|
||||
|
||||
```text
|
||||
OWNER — все права; не может быть удалён или заблокирован
|
||||
ADMIN — те же права, включая управление администраторами
|
||||
EMPLOYEE — только чат; видимость диалогов по группам и назначению
|
||||
```
|
||||
|
||||
Видимость диалога для сотрудника: диалоги его групп + без группы + где он ответственный (ADR-0021). Проверки — deny-by-default в backend во всех HTTP-, WebSocket- и фоновых операциях.
|
||||
|
||||
Администратор установки — глобальный признак, не выражаемый через membership и не дающий прав внутри организаций (ADR-0024). Доступ оператора платформы к данным организации — только через аудируемую support-сессию.
|
||||
|
||||
## 6. Tenant context и изоляция
|
||||
|
||||
```text
|
||||
аутентифицированный HumanUser + активная membership + выбранная организация = проверенный tenant context
|
||||
```
|
||||
|
||||
Канонический маршрут tenant API: `/api/v1/organizations/{organization_public_id}/...`. Настройки установки — вне организаций: `/api/v1/instance/...`.
|
||||
|
||||
Слои изоляции: проверка membership, роли и видимости в сервисах → валидация принадлежности в моделях → ограничения БД (включая constraint triggers) → PostgreSQL RLS → регрессионные тесты изоляции.
|
||||
|
||||
RLS: `ENABLE` и `FORCE ROW LEVEL SECURITY`, политики с `USING` и `WITH CHECK`, runtime-роль без `BYPASSRLS` и без владения таблицами; контекст — только `SET LOCAL` в транзакции. Строка организации видна роли app только в её контексте; входы без контекста идут через security-barrier каталоги (ADR-0026).
|
||||
|
||||
Ключи объектов в хранилище: `organizations/{organization_public_id}/...`.
|
||||
|
||||
## 7. Коммуникации
|
||||
|
||||
Диалог — основная единица; состояние разделено на оси: жизненный цикл, режим управления, ожидаемый отвечающий (ADR-0001). AI ведёт первым, человек перехватывает атомарно (ADR-0002). Диалог наследует группу агента, но маршрутизируется независимо.
|
||||
|
||||
Приём и отправка — через идемпотентные inbox/outbox. Приём только записывает сообщение; ход AI считается отдельно процессами `worker-events`, по очереди внутри диалога (ADR-0028).
|
||||
|
||||
P2P-звонок — дочерняя сессия диалога; медиа идёт WebRTC напрямую либо через Coturn и не сохраняется (ADR-0010).
|
||||
|
||||
## 8. AI
|
||||
|
||||
Текстовый режим — только через провайдера организации: OpenRouter или любой OpenAI-совместимый endpoint; плюс демо-провайдер без ключей и сети. На агенте две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых (ADR-0029).
|
||||
|
||||
Знания — общая библиотека организации с деревом категорий, без версий и релизов (ADR-0009). Retrieval — в PostgreSQL: `pgvector` + встроенный FTS (ADR-0007). Статья портала — второй источник фрагментов (ADR-0017). Изменения агента и знаний применяются сразу.
|
||||
|
||||
## 9. Поверхности приложения
|
||||
|
||||
```text
|
||||
backend-app — tenant API, публичные API, realtime
|
||||
backend-platform — platform control plane (отдельный домен)
|
||||
backend-admin — Django admin, только на loopback
|
||||
```
|
||||
|
||||
У каждой поверхности свой URLConf, свои cookie и host-политика (ADR-0013). Публичные порталы определяются по HTTP Host до разбора маршрута.
|
||||
|
||||
## 10. Развёртывание
|
||||
|
||||
```text
|
||||
secrets · postgres · redis · init · backend-app · backend-platform · backend-admin
|
||||
worker · worker-events · updater · frontend · gateway · coturn
|
||||
```
|
||||
|
||||
- `secrets` — одноразовая генерация секретов в три тома (`chatballs-secrets`, `-platform`, `-schema`);
|
||||
- `init` — одноразовые миграции;
|
||||
- `worker` — опрос подключений и периодические работы, строго один экземпляр;
|
||||
- `worker-events` — ход AI, доставка уведомлений и приглашений, масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2);
|
||||
- `updater` — единственный сервис с `docker.sock`, применяет обновления из интерфейса;
|
||||
- `frontend` — nginx со статикой internal-ui и веб-чата;
|
||||
- `gateway` (Caddy) — единственная публичная HTTP/HTTPS-граница, без зашитых хостов, сертификаты on-demand для одобренных backend хостов;
|
||||
- `coturn` — relay для звонков, поднимается вместе со стеком (host network, порт 3478, UDP 49160–49999, TURN-over-TLS по умолчанию выключен);
|
||||
- PostgreSQL (`pgvector/pgvector:pg16`) и Redis портов на хост не публикуют.
|
||||
|
||||
Подробности — SPEC-0005.
|
||||
|
||||
## 11. Источники истины
|
||||
|
||||
| Область | Источник |
|
||||
|---|---|
|
||||
| Пользователи, организации, membership | platform layer |
|
||||
| Адрес установки, почта, TURN, хранилище, язык по умолчанию | `InstanceSettings` |
|
||||
| Секреты интеграций | зашифрованные поля БД организации |
|
||||
| Контакты, диалоги, агенты, знания, порталы | organization workspace |
|
||||
| Файлы и вложения | локальный диск установки или S3-совместимое хранилище |
|
||||
|
||||
Часовой пояс и локаль — настройки организации.
|
||||
|
||||
## 12. Надёжность и безопасность
|
||||
|
||||
Обязательны: transactional outbox/inbox и идемпотентные обработчики, retry с backoff, сохранение необработанных событий, резервное копирование и проверяемое восстановление, регрессионные тесты изоляции, шифрование и ротация секретов, минимизация payload для LLM (ADR-0004), краткоживущие call/TURN credentials.
|
||||
|
||||
## 13. Границы
|
||||
|
||||
Входит: open-source self-hosted поставка; несколько организаций в установке; контакты и история; AI-first диалоги; агенты с подключениями Telegram, MAX, ВКонтакте, веб-виджет и email; группы, перенос диалогов и ответственный; голосовые сообщения; P2P-звонки; знания и retrieval; порталы с темами; шаблоны ответов; уведомления; аудит; раздельные app/platform поверхности и local-only admin; обновление из интерфейса.
|
||||
|
||||
Не входит без отдельного решения: тарифы, подписки, квоты, биллинг; managed AI и AI-кредиты; продажи, заказы, каталог; лиды, сделки, воронки, канбан; должности и capability-модель как наборы прав; командный центр; managed-облако; групповые звонки, SFU, screen sharing и запись медиа.
|
||||
|
||||
## Правила и ограничения
|
||||
|
||||
### Изоляция и безопасность
|
||||
1. Каждый endpoint, selector, сервис и репозиторий принимает проверенный tenant-контекст; объект загружается по идентификатору **вместе** с организацией. Отсутствие контекста — отказ, а не запрос по всей базе.
|
||||
2. `organization_id` из тела, query string или клиентских метаданных — не доказательство доступа. Публичные точки входа выводят организацию server-side из credential или ключа виджета.
|
||||
3. Session-level `SET` запрещён; tenant-контекст только через `SET LOCAL` в транзакции (`tenant_atomic`). Для WebSocket, воркеров и обработчиков событий контекст ставится на каждую операцию.
|
||||
4. `backend-app` работает только ролью app; алиас `platform` — только у `backend-platform` и воркеров. Docker socket в контейнеры приложения не монтируется (исключение — `updater`).
|
||||
5. Cross-organization связи запрещены и защищаются всеми слоями; пропущенный tenant-ключ ловится тестом изоляции под реальной runtime-ролью.
|
||||
6. В прикладных логах и БД запрещено хранить raw audio, SDP, ICE candidates, секреты провайдеров, токены сессий и приглашений. Секретные параметры URL не попадают ни в журнал, ни в поле последней ошибки подключения.
|
||||
7. Провайдер AI разрешается только через явно выбранную интеграцию агента; «первая попавшаяся интеграция» и глобальная подмена выбора владельца запрещены. Браузер никогда не получает credentials провайдера.
|
||||
8. Django admin — только loopback, без публичного проксирования. Cookie host-only, без `Domain`, `HttpOnly`; жёсткость (`__Host-`, `Secure`, HSTS) — по факту TLS.
|
||||
9. Домены в код, конфигурацию и образы не зашиваются; frontend использует same-origin относительные URL.
|
||||
|
||||
### Поставка
|
||||
10. Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Второй production-манифест запрещён. Новые приложения и пакеты вне структуры репозитория — только отдельным решением.
|
||||
11. Файла `.env` у продукта нет; новые настройки площадки задаются в интерфейсе и хранятся в БД. `release.env` содержит только версию и ссылки на образы по digest.
|
||||
12. `init` выполняет только миграции: не создаёт организацию, владельца и демо-данные.
|
||||
|
||||
### UI (из AGENTS.md проекта)
|
||||
13. **Макет — закон.** Экраны, блоки, тексты, порядок и состояния — ровно как в `design/baseline/<фича>/*.dc.html`. Отклонение — только по прямому указанию владельца.
|
||||
14. **Один стандарт на элемент.** Ссылки — класс `.link` из `shared/links.css` с модификаторами `.is-strong`, `.is-neutral`, `.is-muted`, `.is-mono`, `.has-icon`; действие — `<button className="link">`. Выпадающие меню — только antd `Dropdown` с `overlayClassName="app-dropdown"` и триггером `.row-menu-button`. Перед написанием элемента искать существующий в `shared/` и соседних фичах. Порталы и База знаний используют общие компоненты библиотеки, а не копии.
|
||||
15. **Никаких служебных записок в продукте.** На экране запрещены коды спек и решений, номера правил, слова «инвариант», «констрейнт», «миграция», имена таблиц, полей и enum-значений. Пользователю — следствие и способ исправить.
|
||||
16. Все цвета — только через CSS-токены и тему antd; сырые hex в компонентах запрещены. Вёрстка Help Center читает только токены `--help-*`.
|
||||
|
||||
### Язык интерфейса (i18n)
|
||||
17. Текста, который видит человек, в коде нет: строка живёт в словаре и подставляется через `t(...)` (фронтенд — `src/i18n` приложения, `packages/ui` — свой словарь; бэкенд — `chatballs/i18n`). Русский каталог — источник ключей, английский обязан его повторить.
|
||||
18. Ключ — от смысла (`settings.storage_bucket_name`). Числа склоняет `tn(...)`, даты, размеры и разряды форматирует `fmt`; прибитые `"ru-RU"` запрещены.
|
||||
19. Записанное в базу хранит код, фразу собирает бэкенд на языке читателя. На бэкенде `t(...)` не вызывается на уровне модуля.
|
||||
20. Текст наружу (клиенту, письмо, кнопка виджета) — на языке организации: `t(..., language=customer_language(organization))`. Язык запроса: профиль → организация → установка → браузер. Системный промпт агента не переводится.
|
||||
21. Демо-набор — по языкам в `demo_seed/data/<язык>/` с одинаковыми наборами ключей.
|
||||
|
||||
### Код
|
||||
22. DRY, KISS, SRP, separation of concerns, **NO GOD FILES**: компонент больше ~200 строк или смешивающий layout, state, data и subviews — разделить; файл больше ~300 строк из-за текущей работы — вынести части. Page-компоненты — преимущественно composition-only.
|
||||
23. Если реализация конфликтует с этими правилами — остановиться и сообщить blocker.
|
||||
|
||||
### Процесс
|
||||
24. Без явного указания владельца («делай») ничего не менять. Не менять, не сбрасывать и не пересоздавать учётные данные, пароли, TOTP, сессии и права; `bootstrap_owner`, seed пользователей, смена ролей — только с отдельного подтверждения.
|
||||
25. Любое изменяющее действие в production — только после согласования точного плана миграции с владельцем.
|
||||
26. Тесты — только относящиеся к изменению текущего шага, с `--reuse-db`; полный прогон (~20 минут) — только по явному указанию. Два прогона на одной тестовой базе одновременно нельзя. Тесты бэкенда в dev-стеке запускаются из контейнера `backend-admin`.
|
||||
27. Если инструмента (Python, PHP) нет в окружении — использовать docker проекта. Использовать `gh`/`glab`, соблюдать инструкцию по коммитам.
|
||||
|
||||
### Документация
|
||||
28. Документ описывает то, что есть. Разошлись код и документ — чинится одно из двух; известные расхождения выносятся в отдельный раздел.
|
||||
29. Отменённое удаляется целиком; одно решение — один документ.
|
||||
30. Облачный сервис voice-to-voice здесь не описывается — только интеграция с ним.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Chatballs — бриф
|
||||
|
||||
## Что это
|
||||
|
||||
Chatballs — open-source платформа клиентских коммуникаций: контакт-центр, в котором AI-агенты и сотрудники организации ведут переписку и разговоры с клиентами. Платформа объединяет организации, сотрудников и их группы, AI-агентов, клиентские подключения, контакты, диалоги, звонки, знания и порталы поддержки.
|
||||
|
||||
Продукт **не является CRM продаж**: сделки, воронки, заказы, счета, каталог и учёт выручки в модель не входят и без отдельного решения не будут добавлены (ADR-0019).
|
||||
|
||||
## Для кого
|
||||
|
||||
Организации любого размера, которым нужен контакт-центр на собственной инфраструктуре: принимать обращения из мессенджеров (Telegram, MAX, ВКонтакте), почты и сайта, отвечать AI первым и передавать сложное сотрудникам.
|
||||
|
||||
Одна установка может обслуживать несколько изолированных организаций; один человек может состоять в нескольких организациях с разными ролями.
|
||||
|
||||
## Как распространяется
|
||||
|
||||
- Open source, self-hosted, разворачивается одной командой docker compose (ADR-0020).
|
||||
- Все функции доступны в любой инсталляции: тарифов, подписок, квот и платных ограничений нет. Действуют только технические rate limits и защита от злоупотреблений.
|
||||
- Монетизация — отдельный закрытый облачный сервис voice-to-voice телефонных звонков с ИИ со своими поминутными тарифами. Chatballs подключает его как интеграцию по API-ключу. В открытой части остаются P2P-звонки сотрудник–клиент и голосовые сообщения.
|
||||
- Обновление установки — из интерфейса по кнопке или вручную через `compose.yaml` релиза (ADR-0027).
|
||||
|
||||
## Главный принцип — запуск за минуты
|
||||
|
||||
Человек поднимает контейнеры, открывает установку в браузере и попадает в мастер первого запуска: название организации, своё имя, e-mail, пароль, переключатель демо-данных. После «Начать» он внутри как владелец. Файла `.env` нет, задавать переменные негде: секреты генерирует первый старт, всё зависящее от площадки настраивается в интерфейсе.
|
||||
|
||||
Дальше — три шага чек-листа «Запуск»: создать агента → подключить точку входа (Telegram-бот или код веб-виджета) → пригласить сотрудников. Группы необязательны: без них все сотрудники видят все диалоги.
|
||||
|
||||
Любое проектное решение, удлиняющее этот путь обязательными шагами, требует отдельного обоснования.
|
||||
|
||||
## Ключевые понятия
|
||||
|
||||
- **Организация** — владелец, администраторы, сотрудники и их группы; агенты и подключения; контакты; диалоги и звонки; библиотека знаний; порталы; AI-провайдер.
|
||||
- **Роли**: Владелец (всё; нельзя удалить и заблокировать), Администратор (те же права, включая управление администраторами), Сотрудник (только чат: видимые ему диалоги).
|
||||
- **Группа** — только граница видимости диалогов; прав, знаний, должностей и иерархии не несёт.
|
||||
- **Агент** — центральный объект: одновременно AI-собеседник и точка входа. Имя, инструкции, модель, знания, подключения (Telegram, MAX, ВКонтакте, веб-виджет, почтовый ящик), необязательная группа, признак активности. Количество агентов не ограничено.
|
||||
- **Диалог** — основная единица коммуникации; внутри — голосовые сообщения, P2P аудио/видеозвонок, в перспективе — телефонный разговор с AI через облачный сервис.
|
||||
- **Знания** — общая библиотека организации с деревом категорий; агент использует только явно прикреплённые и включённые материалы; статья портала может быть прикреплена как источник без копирования.
|
||||
- **Портал поддержки** — публичная база статей с поиском, оценками и встроенным веб-чатом; порталов несколько, у каждого свой адрес и тема.
|
||||
|
||||
## Рабочие экраны
|
||||
|
||||
Сотрудник работает в одном окне — чате. Навигация владельца и администратора: Чат, Контакты, Агенты, Сотрудники, Порталы, База знаний, Настройки.
|
||||
|
||||
## AI
|
||||
|
||||
AI-first: новый диалог ведёт агент, отвечает по своим инструкциям и знаниям, не выдумывает фактов и передаёт диалог человеку по детерминированным правилам. После перехвата AI молчит, пока сотрудник явно не вернёт управление. AI работает только через провайдера организации (OpenRouter или любой OpenAI-совместимый endpoint); стоимость организация платит провайдеру напрямую. Для знакомства есть демо-провайдер без ключей и сети.
|
||||
|
||||
## Уведомления
|
||||
|
||||
О новых диалогах, запросах человека и ошибках подключений — в интерфейсе, звуком, письмом и через служебный Telegram- или MAX-бот.
|
||||
|
||||
## Что даёт продукт
|
||||
|
||||
Контакт-центр, который запускается за минуты: агенты принимают обращения из мессенджеров, почты и сайта, AI отвечает первым, сотрудники работают в едином чате, владелец видит всю картину — на своей инфраструктуре, без тарифов и внешней зависимости.
|
||||
|
||||
## Не входит
|
||||
|
||||
Тарифы, подписки, квоты, биллинг; managed AI и AI-кредиты; продажи, заказы, коммерческий каталог; лиды, сделки, воронки и канбан; настраиваемые наборы прав; командный центр; managed-облако как сервис; групповые звонки, SFU, screen sharing и запись медиа.
|
||||
@@ -0,0 +1,2 @@
|
||||
default_agent: codex
|
||||
permission_mode: auto
|
||||
@@ -0,0 +1,136 @@
|
||||
# Разработка визуальной темы портала
|
||||
|
||||
Для разработчика, который добавляет новое оформление публичных страниц Help Center. Основание — ADR-0022, SPEC-0014.
|
||||
|
||||
Не входит: оформление интерфейса организации (персональная тема и акцент, `shared/appearance.ts`), вёрстка новых блоков портала, изменение состава страниц.
|
||||
|
||||
## 0. Что такое тема
|
||||
|
||||
Папка в репозитории, переопределяющая CSS-переменные публичной части портала. Без JS, без подмены компонентов; разметка и правила Help Center общие, тема меняет значения токенов и при необходимости добавляет правила в своём скоупе. Владелец выбирает тему: `Порталы → <портал> → Настройки → Оформление`. Портал хранит только id темы — изменений в БД, API и миграциях не нужно.
|
||||
|
||||
## 1. Где что лежит
|
||||
|
||||
```text
|
||||
apps/internal-ui/src/features/help-center/
|
||||
├── themes/
|
||||
│ ├── README.md краткая памятка
|
||||
│ ├── contract.css все токены --help-* и значения по умолчанию
|
||||
│ ├── types.ts тип манифеста
|
||||
│ ├── registry.ts автообнаружение тем, выбор темы и схемы
|
||||
│ ├── usePortalTheme.ts применение темы к документу
|
||||
│ ├── registry.test.ts тесты каталога = линтер правил тем
|
||||
│ └── classic/ тема по умолчанию (шаблон)
|
||||
│ ├── manifest.ts
|
||||
│ └── theme.css
|
||||
├── styles-layout.css вёрстка Help Center: только --help-*
|
||||
├── styles-home.css
|
||||
├── styles-article.css
|
||||
└── styles-responsive.css
|
||||
```
|
||||
|
||||
Регистрировать тему где-либо ещё не нужно — достаточно папки.
|
||||
|
||||
## 2. Быстрый старт
|
||||
|
||||
1. `cp -r apps/internal-ui/src/features/help-center/themes/classic apps/internal-ui/src/features/help-center/themes/aurora`
|
||||
2. Заполнить `aurora/manifest.ts`; `id` равен имени папки.
|
||||
3. Переопределить токены в `aurora/theme.css` под `html[data-portal-theme="aurora"]`.
|
||||
4. `npm --workspace @chatballs/internal-ui run test`
|
||||
5. Выбрать тему в настройках портала и посмотреть публичную страницу.
|
||||
|
||||
## 3. Манифест
|
||||
|
||||
```ts
|
||||
import type { PortalThemeManifest } from "../types";
|
||||
|
||||
export const manifest: PortalThemeManifest = {
|
||||
id: "aurora", // == имя папки, [a-z0-9-]
|
||||
name: "Аврора", // видит владелец портала
|
||||
description: "Светлое оформление с синим акцентом и крупными скруглениями.",
|
||||
schemes: ["light", "dark"], // только реально проверенные схемы
|
||||
preview: { bg: "#f7f8fc", ink: "#1a1c25", accent: "#4c5fd7" },
|
||||
author: "Имя или команда", // необязательно
|
||||
};
|
||||
```
|
||||
|
||||
- `name` и `description` — пользовательский текст без служебной лексики; `description` показывается под заголовком «Оформление».
|
||||
- Нет тёмных значений — оставить `["light"]`: тёмная схема не предлагается, сохранённая деградирует до светлой.
|
||||
- `preview` — подложка, текст, акцент.
|
||||
|
||||
## 4. Файл стилей
|
||||
|
||||
```css
|
||||
html[data-portal-theme="aurora"] {
|
||||
--help-bg: #f7f8fc;
|
||||
--help-surface: #ffffff;
|
||||
--help-accent: #4c5fd7;
|
||||
--help-accent-ink: #ffffff;
|
||||
--help-radius-lg: 18px;
|
||||
}
|
||||
html[data-portal-theme="aurora"][data-theme="dark"] {
|
||||
--help-bg: #12131a;
|
||||
--help-surface: #191b23;
|
||||
--help-accent: #7d8cf0;
|
||||
}
|
||||
html[data-portal-theme="aurora"] .help-category-icon {
|
||||
box-shadow: 0 6px 18px rgba(76, 95, 215, 0.28);
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
html[data-portal-theme="aurora"] .help-hero { margin-top: 24px; }
|
||||
}
|
||||
```
|
||||
|
||||
Правила скоупа: каждое правило начинается с `html[data-portal-theme="<id>"]`, включая внутри `@media` и `@supports`; `@keyframes` с префиксом id темы; `@font-face` допустим, файл шрифта в репозитории.
|
||||
|
||||
## 5. Справочник токенов
|
||||
|
||||
**Типографика:** `--help-font-body` (`Inter, "Segoe UI", Helvetica, Arial, sans-serif`; основной шрифт), `--help-font-heading` (`var(--help-font-body)`; логотип-название и заголовки), `--help-font-mono` (`var(--font-mono)`; код), `--help-heading-weight` (`650`).
|
||||
|
||||
**Поверхности:** `--help-bg` (`var(--surface-card)`; подложка, boot-загрузчик, «портал не найден»), `--help-surface` (`var(--surface-card)`; карточки разделов, панель поддержки, кнопки), `--help-surface-soft` (`var(--n-9)`; ховеры, шапки таблиц), `--help-surface-raised` (`var(--n-10)`; компактный поиск, шиммер), `--help-surface-strong` (`var(--n-7)`; ховер строк поддержки), `--help-skeleton` (`var(--n-8)`; плейсхолдеры статьи).
|
||||
|
||||
**Текст:** `--help-ink` (`var(--n-1)`; заголовки, названия, точки загрузчика), `--help-text` (`var(--n-2)`; иконка поиска, кнопки оценки), `--help-text-muted` (`var(--n-3)`; описания, breadcrumbs, содержание, подвал), `--help-text-subtle` (`var(--n-4)`; счётчики, дата, фокус поиска).
|
||||
|
||||
**Линии:** `--help-line` (`var(--n-6)`; разделители, рамки таблиц и кнопок), `--help-line-soft` (`var(--n-7)`; шапка, подвал, панель поддержки).
|
||||
|
||||
**Акцент:** `--help-accent` (`var(--n-1)`; плитка иконки раздела, плавающая кнопка поддержки), `--help-accent-hover` (`color-mix(in srgb, var(--help-accent) 85%, black)`), `--help-accent-ink` (`var(--surface-card)`; содержимое на акценте).
|
||||
|
||||
**Код:** `--help-code-bg` (`var(--n-1)`, в тёмной `var(--n-10)`), `--help-code-ink` (`var(--n-9)`, в тёмной `var(--n-1)`), `--help-code-inline-bg` (`var(--n-9)`).
|
||||
|
||||
**Форма и тень:** `--help-radius-xs` 5px, `-sm` 7px, `-md` 10px, `-lg` 12px, `-xl` 16px, `-2xl` 18px, `-pill` 999px; `--help-shadow-panel` (`var(--shadow-lg)`).
|
||||
|
||||
## 6. Тёмная схема
|
||||
|
||||
Схема выбирается владельцем портала (Светлая / Тёмная / Как в системе) и применяется `data-theme` на `<html>` — тем же механизмом, что у интерфейса; второй механизм вводить нельзя. Тёмные значения — под `html[data-portal-theme="<id>"][data-theme="dark"]`. Тема на значениях по умолчанию получает тёмную схему бесплатно; тема с фиксированными цветами обязана прописать тёмные значения полностью.
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
```bash
|
||||
npm --workspace @chatballs/internal-ui run test
|
||||
npm --workspace @chatballs/internal-ui run typecheck
|
||||
npm --workspace @chatballs/internal-ui run build
|
||||
```
|
||||
|
||||
Сборка кладёт CSS темы в отдельный ленивый чанк `assets/theme-*.css`. `registry.test.ts` не пропустит: несовпадение id и папки, дубликаты; пустые `name`/`description`, пустой или неизвестный `schemes`; отсутствие `theme.css`; правило вне скоупа (в том числе в `@media`); `@keyframes` без префикса; токен вне контракта.
|
||||
|
||||
Ручная проверка: обе схемы, главная, статья, поиск, пустой результат, загрузка, мобильная ширина; консоль без ошибок и предупреждений.
|
||||
|
||||
## 8. Что теме нельзя
|
||||
|
||||
JS и подмена компонентов; глобальные правила; правка чужих файлов (`contract.css`, `styles-*.css`, другие темы — кроме §9); свои переменные вне контракта; внешние ресурсы (CDN); собственная тёмная тема через `@media (prefers-color-scheme)`.
|
||||
|
||||
## 9. Если рычага не хватает
|
||||
|
||||
Нет нужного токена — дефект контракта: добавить токен в `contract.css` со значением, равным текущему поведению; заменить литерал в `styles-*.css` на токен; проверить, что вёрстка не содержит `--n-*`, `--surface-*` и литеральных цветов; дополнить справочник и `themes/README.md`.
|
||||
|
||||
## 10. Жизненный цикл темы
|
||||
|
||||
Удалённая из сборки тема деградирует до `classic`, в настройках показывается недоступной. Переименование папки — новая тема; при необходимости — миграция данных по согласованию с владельцем. `classic` ничего не переопределяет; менять её вид — отдельное решение владельца.
|
||||
|
||||
## 11. Чек-лист перед коммитом
|
||||
|
||||
- id совпадает с папкой; `name` и `description` без служебной лексики.
|
||||
- `schemes` проверены в браузере.
|
||||
- Все правила скоупнуты, `@keyframes` с префиксом, только токены контракта.
|
||||
- `test`, `typecheck`, `build` проходят.
|
||||
- Публичная страница проверена в схемах, на десктопе и мобильной ширине; консоль чистая.
|
||||
- Другие темы, контракт и вёрстка не тронуты (или §9 выполнен целиком).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: M01
|
||||
title: Страница веб-подключения
|
||||
order: 1
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Веб-подключение открывается отдельной страницей с субменю вместо модалки. На этой странице дальше появятся разделы «Данные с сайта», «Форма перед чатом» и «Оформление».
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Веб-подключение редактируется на странице /settings/integrations/{id} с разделами «Основные» и «Удалить подключение», код вставки есть в шапке; остальные провайдеры и создание работают через модалку, как раньше.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: M02
|
||||
title: Данные с сайта
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Сайт передаёт поля о клиенте через Chatballs.setFields(), оператор видит их в карточке контакта в реальном времени, AI учитывает разрешённые поля (SPEC-0019, ADR-0030).
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Выполнены критерии приёмки SPEC-0019: 4 поля создаются в W1, setFields даёт значения у оператора, смена статуса обновляет бейдж без перезагрузки, пишет событие в ленту и учитывается AI; мусорные данные не сохраняются; изоляция организаций подтверждена тестом.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: M03
|
||||
title: Форма перед чатом
|
||||
order: 3
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Администратор включает форму перед чатом, клиент видит её вместо экрана согласия с предзаполненными данными сайта (SPEC-0020).
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Выполнены критерии приёмки SPEC-0020: выключенная форма не меняет поведение, предзаполнение «с сайта», блокировка «Начать чат» без обязательных полей, значения попадают в контакт и данные сайта, смена текста согласия показывает форму снова.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: M04
|
||||
title: Оформление веб-виджета
|
||||
order: 4
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Администратор меняет цвет, иконки, положение, размер и форму кнопки и задаёт свой CSS; изменения применяются на сайте без перевставки кода (SPEC-0021).
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Выполнены критерии приёмки SPEC-0021: цвет, позиция и форма применяются без перевставки кода, SVG очищается от скриптов, свой CSS действует только внутри окна чата, предупреждение о контрасте работает.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: "0001"
|
||||
title: Веб-чат
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Организации нужна постоянная точка входа на сайте и в порталах поддержки, работающая через общий контур коммуникаций наравне с мессенджерами и почтой: тот же контакт, идентичность, диалог, сообщение и inbox сотрудника. Модель виджета — SPEC-0002; основания — ADR-0018, ADR-0003, ADR-0004. Данные с сайта, форма перед чатом и оформление описаны в отдельных спецификациях: «Данные с сайта в веб-чате», «Форма перед чатом веб-виджета», «Оформление веб-виджета».
|
||||
|
||||
Отвечает за: запуск по публичному ключу, опубликованную конфигурацию, согласие, сообщения, вложения и голосовые, отображение состояния AI и сотрудника, приглашение на звонок и страницу звонка, восстановление истории, понятную недоступность.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. **Подключение к сайту**: `<script src="https://<адрес установки>/chat-widget.js" data-widget-key="<ключ>" async></script>` — ключ единственный параметр; агента и подключение backend разрешает сам.
|
||||
2. **Первый визит**: launcher → приветствие и согласие (или форма перед чатом, если включена) → принятие → диалог с AI (история, свободный ввод, быстрые ответы, файл, голосовое, «печатает», явное обозначение виртуального помощника).
|
||||
3. **Передача сотруднику**: сообщение «подключается человек, история ему доступна» → диалог с сотрудником в той же ленте.
|
||||
4. **Звонок**: входящее приглашение → принять/отклонить → страница звонка → возврат к переписке.
|
||||
5. **Недоступность**: понятное сообщение и резервные контакты, без ложного подтверждения отправки.
|
||||
6. **Возврат**: переходы, перезагрузка, повторный визит восстанавливают текущий диалог.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Фрагмент не содержит секретов; backend проверяет origin; конфигурация отдаётся только для опубликованного виджета и разрешённого origin; ошибка конфигурации не ломает страницу; сбой виджета изолирован от сайта.
|
||||
- R-2. Публичная конфигурация содержит только публичные поля: `available`, `widgetKey`, `features`, `title`, `accent`, `greeting`, `consent {text, version}`, `quickReplies`, `fallback`, `fields` (схема своих полей без признака передачи AI), `preChat`, `appearance`. Неразрешённый origin получает отказ с причиной. Внутренние идентификаторы и технические флаги не отдаются.
|
||||
- R-3. Резервные контакты собираются из других подключений того же агента.
|
||||
- R-4. Запрещённые в подключении голосовые и звонки прячутся, а не показываются неработающими кнопками.
|
||||
- R-5. До принятия согласия модель не вызывается и диалог не начинается.
|
||||
- R-6. При первом открытии создаются контакт-гость, идентичность и непрозрачный высокоэнтропийный токен сессии (на сервере — hash). Токен живёт в контексте конкретного виджета и не принимается другим.
|
||||
- R-7. Закрытие панели и уход со страницы не закрывают диалог; после закрытия диалога новое сообщение создаёт новый, связанный с предыдущим (ADR-0001).
|
||||
- R-8. Текст, изображения, файлы, голосовые, быстрые ответы, системные события; видны автор, время, состояние отправки, ошибка; порядок устойчив к повторной доставке. Голосовое выглядит как в чате оператора: воспроизведение, волна, длительность.
|
||||
- R-9. Тип и размер вложения проверяются до загрузки, есть прогресс и отмена; все клиентские ограничения дублируются сервером.
|
||||
- R-10. Сообщение сохраняется сервером до подтверждения; launcher показывает непрочитанное; отсутствие свободного сотрудника не блокирует ответ AI.
|
||||
- R-11. Передача бесшовна: тот же диалог, атомарная отмена генерации, AI не пишет после перехвата (ADR-0002).
|
||||
- R-12. Защита: HTTPS, origin и список доменов, rate limit по сессии и адресу, ограничения длины и размера, защита от повторной и параллельной отправки и от перебора. Токен звонка проверяется до запроса камеры и не даёт доступа к переписке. Токены, SDP, ICE в журналы не пишутся.
|
||||
- R-13. Payload модели проходит redaction; токен сессии и внутренние id модели не передаются.
|
||||
- R-14. На десктопе — компактная панель поверх страницы с кнопкой разворота (окно почти вдвое шире); открытие и закрытие — анимация «джин» из кнопки, в том числе при первом открытии. На телефоне — окно на весь экран с учётом безопасных зон и клавиатуры, кнопка скрыта, закрытие кнопкой «—» в шапке, кнопки разворота нет.
|
||||
- R-15. Шапка и launcher показывают знак агента или иконку, загруженную в оформлении виджета; над сообщением агента — имя и аватар; поле ввода без рамки: скрепка слева, запись голосового справа, при наборе — отправка. Внизу подпись «Работает на Chatballs».
|
||||
- R-16. Доступность: клавиатура, видимый фокус, подписи, масштабирование; свободный ввод доступен всегда. Форма перед чатом необязательна и по умолчанию выключена; её включает администратор подключения.
|
||||
- R-17. Удалённый владельцем диалог обнуляет переписку в виджете у клиента.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Клиентская учётная запись, тикеты, отдельная очередь и модель диалога, оплата, хранение знаний, дерево сценариев; запись, групповые звонки, screen sharing; видеосообщения, реакции, стикеры; редактирование отправленного; преемственность анонимной истории между устройствами; продолжение диалога по почте; визуальный конструктор сценариев.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: подключение одним фрагментом; неразрешённый домен не получает конфигурацию; модель не вызывается до согласия; история переживает переходы; закрытие панели не закрывает диалог; перехват без нового чата и двойного ответа; текст, изображение, файл и голосовое отправляются; при недоступности — резервные контакты; два виджета одного агента независимы; сообщения веб-чата видны в общем списке рядом с остальными каналами.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: "0002"
|
||||
title: Виджеты веб-чата как точки входа
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Организации нужно несколько точек входа веб-чата у одного агента — с разными доменами, оформлением и согласием, без неоднозначности входа и с достоверным источником обращения (ADR-0018). Поведение чата для клиента — SPEC-0001; данные с сайта, форма перед чатом и оформление — отдельные спецификации.
|
||||
|
||||
Термины: **виджет** — опубликованная точка входа; **ключ виджета** — публичный неизменяемый идентификатор, не credential; **веб-подключение** — транспортная привязка; **агент** — контекст обработки; **место установки** — использование виджета на странице.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт виджет, выбирая существующий совместимый агент (агент автоматически не создаётся), настраивает origins, оформление и согласие, публикует.
|
||||
2. На двух сайтах стоят два виджета одного агента с разными ключами — обращения идут в одну очередь, но источник различается.
|
||||
3. Администратор меняет оформление опубликованного виджета — фрагмент на сайте менять не нужно.
|
||||
4. Виджет с историей отключается: новые сессии не принимаются, история остаётся.
|
||||
5. Администратор открывает веб-подключение в настройках и попадает на его страницу с разделами «Основные», «Данные с сайта», «Форма перед чатом», «Оформление», «Удалить подключение».
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Модель `WebChatWidget`: `integration` (1:1 с WEB), `code`, `public_key`, `name`, `status` (`DRAFT | PUBLISHED | DISABLED`), `allowed_origins`, `presentation_config` (заголовок, приветствие, акцент, быстрые ответы, оформление, схема своих полей, форма перед чатом), `consent_config` (текст и версия), `anti_abuse_config`. Источник значений — `integration.config`, виджет получает их при сохранении подключения. Секретов и credentials сессий в записи нет.
|
||||
- R-2. Виджет, подключение и агент — одной организации; подключение провайдера `WEB` привязано ровно к одному агенту и обслуживает один виджет; агент может иметь несколько виджетов; агент виджета разрешает анонимные сессии. Понятия «режим» нет.
|
||||
- R-3. Виджет с историей не удаляется — отключается; публичный ключ не переиспользуется.
|
||||
- R-4. Список виджетов: название и код, статус, агент, подключение, состояние origins, дата изменения.
|
||||
- R-5. После появления трафика неизменяемы код, ключ, подключение и агент; название, статус, origins и конфигурации меняются с аудитом.
|
||||
- R-6. Публикация отклоняется, если агент или подключение неактивны, production-origins пусты или некорректны, нет конфигурации согласия, либо ключ создаёт неоднозначность.
|
||||
- R-7. Публичный вход: ключ → опубликованный виджет → origin → подключение и агент → выдача или восстановление анонимной сессии. Агент и технические параметры со страницы не принимаются. Пустой список origins допустим только в явно обозначенном контуре разработки.
|
||||
- R-8. Каждый экземпляр виджета на странице изолирован: свой идентификатор, DOM и события. Страница показывает одну кнопку; её положение (слева или справа), размер и форму задаёт оформление виджета.
|
||||
- R-9. Для нового диалога сохраняются подключение, виджет, агент, анонимная идентичность и минимальные метаданные места установки; токен сессии и чувствительные браузерные данные не сохраняются.
|
||||
- R-10. Безопасность: точное сравнение origin без подстрок; rate limit по виджету, сессии и адресу; hash credential с ротацией и отзывом; токены не в URL и журналах; аудит публикации, отключения и изменений.
|
||||
- R-11. Веб-подключение настраивается на странице `/settings/integrations/{id}` с субменю, как настройки портала: «Основные» (название, разрешённые домены, код вставки), «Данные с сайта», «Форма перед чатом», «Оформление», «Удалить подключение». В шапке страницы — код вставки с кнопкой «Копировать». Создание подключения и остальные провайдеры по-прежнему используют модалку.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Выбор агента внутри виджета; авторизованный вход с проверенной личностью; одновременный показ нескольких кнопок.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: два виджета одного агента с разными ключами, origins и оформлением работают независимо; одинаковые коды разных организаций не влияют на вход; credential одного виджета не принимается другим; агента нельзя подменить; отключённый виджет не принимает сессии; несовместимая публикация отклоняется; ответы не раскрывают секреты и технические коды; веб-подключение открывается страницей с разделами.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "0003"
|
||||
title: P2P-онлайн-звонки
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Сотрудник должен запросить у клиента аудио- или видеозвонок из текущего диалога; разговор идёт один на один через собственный WebRTC-контур, жизненный цикл сохраняется в истории диалога (ADR-0010).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Сотрудник открывает диалог и запрашивает звонок. Backend проверяет права, режим управления и отсутствие другого незавершённого звонка. Если диалогом управляет AI — атомарный перехват; при неуспешном claim звонок не создаётся и приглашение не уходит.
|
||||
2. Создаются сессия и приглашение, в ленте — системное событие. Клиент веб-чата получает приглашение realtime-событием; в Telegram, MAX и ВКонтакте — кнопку-ссылку на защищённую страницу. Страница проверяет токен **до** запроса камеры и микрофона; недействительный токен — терминальное состояние без данных диалога.
|
||||
3. После принятия обе стороны проходят соединение; любая сторона завершает звонок; в ленту — результат и длительность.
|
||||
4. Клиент кладёт трубку — у оператора закрывается экран разговора и показывается итоговый экран с «Закрыть».
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Инициировать может сотрудник, которому диалог виден (SPEC-0004), без блокировки и при отсутствии другого незавершённого звонка. AI не создаёт, не принимает звонок и не получает медиа.
|
||||
- R-2. Модель: `CallSession` (организация, диалог, инициатор, вид, статус, времена, кто завершил, причина ошибки, подключение доставки; длительность — от соединения до завершения), `CallInvite` (идентичность клиента, hash токена, срок, время открытия и ответа, статус доставки), `CallParticipant` (сторона, вход, выход, состояние соединения), технические метрики.
|
||||
- R-3. Жизненный цикл `REQUESTED → RINGING → ACCEPTED → CONNECTING → ACTIVE → ENDED`; `DECLINED`, `CANCELLED`, `MISSED`, `EXPIRED` из ранних и `FAILED` из активных. Один незавершённый звонок на диалог; повторное завершение идемпотентно; истёкшее или отменённое приглашение принять нельзя; `ACTIVE` — только после подтверждённого соединения обеих сторон; обрыв — переподключение, после grace period — ошибка; закрытие вкладки — серверный таймаут; завершённый звонок не переоткрывается. «Завершить» у оператора заканчивает звонок из любой живой фазы.
|
||||
- R-4. API: `POST /calls/conversations/{id}/`, `GET /calls/conversations/{id}/active/`, `GET /calls/{id}/`, `POST /calls/{id}/cancel/`, `POST /calls/{id}/access-token/`; публичные по токену: `POST /calls/invites/resolve/`, `GET /calls/access/state/`, `POST /calls/access/accept|decline|end/`.
|
||||
- R-5. До соединения: предпросмотр камеры, состояния отсутствия разрешения или устройства, переключатели камеры и микрофона, действие и отказ, сообщение об истёкшем приглашении. Во время: удалённое и локальное видео, состояния камер и микрофонов, завершение, индикаторы соединения и переподключения, работа на мобильном. Отсутствие камеры — не ошибка.
|
||||
- R-6. Signaling по WSS только для двух участников; события: приглашение, принятие, отклонение, отмена, завершение, offer, answer, ICE, состояние медиа и соединения. Ключ идемпотентности у каждой команды; поздние события завершённого звонка игнорируются; переподключение WS не создаёт звонок. Источник истины — PostgreSQL.
|
||||
- R-7. Одно соединение; видео отключается без завершения; прямой маршрут предпочтителен, TURN — запасной. Backend не получает декодированное медиа и не хранит RTP, SDP и ICE.
|
||||
- R-8. Coturn поднимается со стеком: порт 3478 TCP/UDP, relay-диапазон 49160–49999/udp, TURN-over-TLS по умолчанию выключен (включается отдельным compose-override), краткоживущие credentials от backend, запрет анонимного и внутрисетевого relay, healthcheck. Адреса relay и STUN вычисляются от адреса установки; вписанные вручную в «Настройки → TURN для звонков» побеждают.
|
||||
- R-9. Только HTTPS и WSS; токен звонка короткоживущий, не даёт доступа к переписке и не попадает в аналитику, referrer и журналы; страница звонка задаёт ограничительную политику разрешений. Медиа не записывается.
|
||||
- R-10. Системные события не дублируются при повторах и не меняют жизненный цикл диалога.
|
||||
- R-11. Ошибки различаются: отказ в доступе к устройствам, отсутствие или занятость устройства, нет WebRTC, недоступен signaling, нет прямого маршрута при недоступном TURN, уход участника, переподключение, истёкшее приглашение. SDP, ICE, внутренние id и секреты пользователю не показываются.
|
||||
- R-12. Приглашение на звонок по почте не отправляется.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Запись аудио и видео, групповые звонки, SFU, screen sharing, телефония и SIP, звонки по инициативе клиента, участие AI в медиасессии.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: запрос из интерфейса; перехват из режима AI, при конфликте приглашения нет; веб-чат — в сессии, мессенджеры — кнопка; истёкший и повторный токен не работают; устройства не запрашиваются до проверки токена; прямой маршрут и через Coturn; выключение камеры не завершает звонок; завершение идемпотентно; события без дублей и с длительностью; SDP, ICE и медиа отсутствуют в БД и журналах; второй звонок в диалоге создать нельзя; мобильный клиент проводит звонок целиком.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "0004"
|
||||
title: Сотрудники, роли и группы
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Нужна модель сотрудника, ролей и групп, управление сотрудниками и передача владения в установке, где один человек может работать в нескольких организациях. Основания — ADR-0012, ADR-0021, ADR-0025.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт сотрудника с новым адресом — появляется учётная запись с паролем первичного доступа.
|
||||
2. Администратор вводит адрес, принадлежащий учётной записи из другой организации, — уходит приглашение; в списке строка «Приглашён»; человек принимает его под своим входом.
|
||||
3. Администратор блокирует сотрудника — доступ закрыт только к этой организации, сессии инвалидированы, открытые диалоги вернулись в очередь.
|
||||
4. Владелец передаёт владение другому сотруднику.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. `HumanUser` — глобальная учётная запись (e-mail, аутентификация, имя, TOTP, глобальный статус). `OrganizationMembership`: user, organization, role (`OWNER | ADMIN | EMPLOYEE`), position_title, phone, totp_required, blocked_at, created_at. На уровне БД: `unique(user, organization)` и ровно один владелец в организации.
|
||||
- R-2. Должность прав не выдаёт; роль не выводится из должности. Отдельных профилей доступа и наборов полномочий нет.
|
||||
- R-3. OWNER — все права, не может быть удалён или заблокирован; ADMIN — те же права, включая управление администраторами; EMPLOYEE — только чат.
|
||||
- R-4. Группа: имя (уникально в организации без учёта регистра), цвет, состав. Только граница видимости. Сотрудник может быть в нескольких группах; групп может не быть.
|
||||
- R-5. Видимость: владелец и администратор — все диалоги; сотрудник — своих групп + без группы + где он ответственный.
|
||||
- R-6. Создание: имя, e-mail, роль, должность, группы (телефон). Новый адрес — учётная запись с паролем первичного доступа; адрес активной учётной записи другой организации — приглашение (ADR-0025); адрес своей организации или деактивированной учётной записи — «занят». Владелец через общий поток не создаётся.
|
||||
- R-7. Операции: просмотр списка, создание и изменение, сброс пароля и завершение сессий, блокировка, изменение администратора — OWNER и ADMIN; над владельцем — только передача владения; передать владение — только OWNER. Запреты проверяет backend, прямой идентификатор их не обходит.
|
||||
- R-8. Сотрудник, участвовавший в диалогах, физически не удаляется — блокируется с сохранением ссылочной целостности.
|
||||
- R-9. Передача владения в одной транзакции: блокировка записей участия, цель становится владельцем, прежний получает выбранную роль, ровно один владелец, событие аудита. Цель — активный сотрудник той же организации.
|
||||
- R-10. Администратор не меняет чужой глобальный пароль, TOTP и глобальные сессии. Организация может требовать TOTP. Блокировка запрещает новые HTTP- и realtime-действия и инвалидирует сессии. В payload сотрудника нет паролей, TOTP-секретов и токенов.
|
||||
- R-11. Аудит: создание, изменение данных и роли, блокировка и разблокировка, завершение сессий, сброс пароля, передача владения, отказ в привилегированном действии, изменения групп, приглашения — актор, цель, организация, действие, изменения, без секретов.
|
||||
- R-12. Пока почта установки не настроена, письмо приглашения уходит в лог — это видно в «Платформе».
|
||||
|
||||
## Out of scope
|
||||
|
||||
Кадровый учёт, штатное расписание, отпуска, иерархия подчинённости, права по должности, несколько владельцев.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: участие в нескольких организациях с разными ролями; блокировка не закрывает другие организации; второго владельца создать нельзя; действия над владельцем отклоняет backend; видимость соблюдается в HTTP, WebSocket и фоне; организация без групп работоспособна.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: "0005"
|
||||
title: Установка, развёртывание и жизненный цикл экземпляра
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Нужен единый технический и пользовательский контракт установки, развёртывания, первого запуска и обновления, одинаковый для установки пользователя, production владельца и staging (ADR-0011). Демо-данные — SPEC-0006; обновление из интерфейса — ADR-0027.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. `git clone … && ./scripts/start.sh` (Windows: `.\scripts\start.ps1`). Если рядом `release.env` — образы по digest, иначе сборка из исходников. Задавать нечего.
|
||||
2. Человек открывает установку по IP (любой порт) по http и видит мастер первого запуска; после него — в приложении владельцем, мастер больше недоступен.
|
||||
3. Установка переезжает на домен и https без правки конфигурации; за прокси панели работает при передаче `X-Forwarded-Proto`.
|
||||
4. Обновление: резервная копия → образы целевого выпуска → `deploy` с миграциями → проверка здоровья.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Хост: Linux x86_64 (первая цель — Debian 13) или Docker Desktop; Docker Engine и Compose plugin; открытые 80 и 443; для звонков — 3478 TCP/UDP и 49160–49999/udp. Не требуются nginx, Python, Node.js, Git, PostgreSQL, Redis, доступ к AI.
|
||||
- R-2. Секреты: одноразовый `secrets` генерирует `secret_key`, `field_encryption_key`, `postgres_app_password`, `turn_secret` (том `chatballs-secrets`), `platform/postgres_platform_password` (`chatballs-secrets-platform`), `schema/postgres_migration_password`, `schema/postgres_password` (`chatballs-secrets-schema`). Идемпотентен, переносит файлы старых установок, выставляет права на каждом запуске. Переменная окружения выше файла.
|
||||
- R-3. `release.env` — только версия и ссылки на образы по digest; `latest` не источник версии.
|
||||
- R-4. Настройки площадки — в одной строке настроек установки, в «Настройки → Платформа»: публичный адрес (хост, схема, порт — запоминается при мастере), почтовый сервер (пока не задан — письма в лог), TURN-серверы и срок credentials, язык по умолчанию. Хранилище — «Хранилище файлов». Изменяет только администратор установки. Промах по адресу перечитывает настройки сразу (не чаще раза в секунду на процесс).
|
||||
- R-5. Сервисы: `secrets`, `postgres` (pgvector/pgvector:pg16, без портов на хост), `redis` (без портов), `init`, `backend-app`, `backend-platform`, `backend-admin` (127.0.0.1:18001), `worker` (опрос, один экземпляр), `worker-events` (события и ход AI, реплики), `updater`, `frontend`, `gateway` (Caddy), `coturn`. Роли PostgreSQL: владелец кластера, `migration`, runtime `app` и `platform` без обхода RLS.
|
||||
- R-6. Шлюз без зашитых хостов: `:80` отвечает по http на любом адресе без принудительного редиректа; https — для хостов, одобренных backend (домен установки и порталов), сертификаты по мере надобности; платформенная поверхность — на своём домене; admin и Coturn через шлюз не идут. Порт сохраняется в адресе для CSRF. По https — `__Host-`, `Secure`, HSTS.
|
||||
- R-7. `init` только мигрирует; безопасен при повторе; статика собрана в образе.
|
||||
- R-8. Мастер первого запуска: `organizationName`, `fullName`, `email`, `password`, `installDemo`. В одной транзакции: запомнить адрес; advisory-блокировка; проверить отсутствие организации; валидировать пароль против имени и e-mail; создать организацию (id резервируется заранее, запись ролью app в контексте новой организации — ADR-0026), системную категорию знаний, пользователя-владельца (с `is_instance_admin`) и membership; аудит; при выборе демо — поставить задачу, не ожидая её. После — мастер закрыт навсегда. Пароль владельца нигде, кроме формы, не фигурирует.
|
||||
- R-9. CLI `./chatballs` без Python и Node: реализованы `doctor`, `deploy`, `status`, `logs`. Требования: fail-fast, ненулевой код при ошибке, без утечки секретов, блокировка параллельных операций, `--non-interactive`, идемпотентный `deploy`.
|
||||
- R-10. Порядок развёртывания: проверить хост → образы → postgres и redis до здоровья → `init` → остальные сервисы → здоровье. Порядок не дублируется в CI, runbook'ах и ручных командах.
|
||||
- R-11. Health: `/api/v1/health/live/`, `/api/v1/health/ready/`. Пока организации нет, установка технически здорова и показывает мастер.
|
||||
- R-12. Обновление только вперёд; не меняет секреты и не переустанавливает демо. Откат после необратимой миграции — только восстановлением из копии.
|
||||
- R-13. Резервная копия: дамп PostgreSQL, медиа, три тома секретов, метаданные выпуска; копия каталога работающего PostgreSQL не считается; Redis не входит. Восстановление — после проверки совместимости, с подтверждением и остановкой пишущих сервисов.
|
||||
- R-14. Безопасность: секреты читаемы только нужным пользователям; CLI не принимает пароли аргументами; в журналы не попадают дампы, ключи и пароли; docker socket — только у `updater`; деструктивные команды требуют подтверждения, кроме авторизованного CI.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Kubernetes и Helm, multi-node HA, managed-облако, автоустановка локальных моделей, лицензирование, телеметрия. Модель `InstallationState` и одноразовый setup-токен не реализуются: признак незавершённой установки — отсутствие организации.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Команды CLI `install`, `update`, `backup`, `restore`, `rollback` не реализованы — T-001, T-002.
|
||||
- В `README.md` и скриптах запуска встречается признак режима поставки `CLOUD` / `SELF_HOSTED`, в документации не описанный — T-003.
|
||||
- Критерии приёмки: чистая машина поднимает продукт одной командой без `.env`; открывается по IP и показывает мастер; после мастера — владелец, мастер недоступен; повторный запуск не меняет пароли и данные; образ frontend работает на двух адресах; переход на домен и https без правки конфигурации; демо только по выбору и не переустанавливается.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: "0006"
|
||||
title: Демонстрационные данные
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Человек должен увидеть работающий контакт-центр сразу после установки, а не пустые экраны. Демо-набор — не системный seed, не production-данные и не тестовые фикстуры.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. В мастере первого запуска включён переключатель «Установить демо-данные» — установка ставится в очередь, воркер выполняет её, форма не ждёт.
|
||||
2. В работающей системе «Настройки → Демо-данные» → установить.
|
||||
3. Там же — удалить набор одной кнопкой; реальные данные и владелец не затронуты.
|
||||
4. Разработка: `python manage.py seed_demo --organization <slug> --apply|--remove` (не входит в порядок развёртывания).
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Ставится только по явному выбору; не профиль Compose и не переменная; не переустанавливается при обновлении; не вызывает внешние сервисы; данные вымышленные.
|
||||
- R-2. Содержание — вымышленное ателье «Норд», повторяющее кадры дизайн-baseline v2. Манифесты по доменам на языке организации в `apps/backend/chatballs/identity/demo_seed/data/<язык>/` (`organization.json`, `channels_ai.json`, `conversations.json`, `support.json`, `operations.json`), наборы ключей совпадают; бинарные вложения общие, текстовые документы — свои на каждый язык.
|
||||
- R-3. Диалоги покрывают все состояния (ведёт AI, ждёт человека, ведёт сотрудник, закрыт, спам, архив); есть вложения, голосовые (Mozilla Common Voice, CC0), метки, приоритеты, заметки, шаблоны ответов (приветствие с переменной имени оператора); история AI за 30 дней. Набор покрывает каждую модель системы — проверяет тест сида.
|
||||
- R-4. Ставится в организацию установщика; своей организации и владельца не создаёт.
|
||||
- R-5. У демо-сотрудников общий публичный пароль из манифеста (`Chatballs-Demo-2026`) — осознанное отступление. Ограничения: не ставить на публичную боевую установку без понимания, что учётные записи активны; демо-сотрудники не получают роль владельца; удаляются вместе с набором.
|
||||
- R-6. Состояние `DemoDataset` на организацию: status (`INSTALLING | INSTALLED | REMOVING | FAILED`), requested_by, records_count, error, started_at, finished_at. `FAILED` виден с причиной.
|
||||
- R-7. Каждая созданная запись регистрируется в реестре (модель, PK, порядковый номер); удаление — только по реестру в обратном порядке. Запрещено удалять по совпадению имени или шаблона, очищать таблицы, сбрасывать последовательности, трогать владельца.
|
||||
- R-8. Пока набор установлен, интерфейс показывает признак демо-данных.
|
||||
- R-9. Даты — относительно момента установки (окно ~30 дней), абсолютных дат в манифестах нет.
|
||||
- R-10. При установке и удалении запрещены письма, сообщения в мессенджеры, вебхуки, обращения к AI, живые задачи исходящей доставки, действующие credentials интеграций. Демо-агенты используют демо-провайдер. Демо-подключения воркер не опрашивает.
|
||||
- R-11. Демо-данные входят в обычную резервную копию; реестр и состояние остаются согласованными после восстановления.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Переключатель между организациями в демо; отдельная копия демо.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: без выбора в мастере демо не ставится; `deploy` и обновление не создают его повторно; каждая запись в реестре; ни одного внешнего вызова; удаление снимает всё и сохраняет реальные данные и владельца; повторное удаление безопасно; тест покрытия моделей проходит.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: "0007"
|
||||
title: Интеграции AI-провайдеров
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Нужно зафиксировать поддерживаемые AI-провайдеры и правила их подключения при условии, что AI работает **только** через провайдера организации — без платформенных credentials, managed-режима и AI-кредитов (ADR-0008, ADR-0014, ADR-0020). Настройка поведения агента — SPEC-0008.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Организация создаёт интеграцию OpenRouter, вводит ключ, выбирает модель из каталога, нажимает «Проверить».
|
||||
2. Организация подключает свой OpenAI-совместимый endpoint (Custom): endpoint, ключ, модель текстом — и получает ответы агента без правки кода.
|
||||
3. Сразу после установки агент отвечает через демо-провайдер без ключа.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Провайдеры (род «LLM-провайдер», «Настройки → AI-провайдер»): **OpenRouter** — ключ организации, endpoint по умолчанию (редактируется), модель из каталога адаптера; **Custom** — ключ, произвольный endpoint, модель текстом, без каталога; **Демо** — без ключа, endpoint и модели.
|
||||
- R-2. Демо-провайдер: без ключей и сети; детерминированно выбирает предложения знаний с наибольшим пересечением слов с вопросом; без основания или по просьбе человека — токен передачи оператору; собственные эмбеддинги малой размерности; явно помечен в интерфейсе как демонстрационный. Не production-режим.
|
||||
- R-3. Контракт endpoint: OpenAI Chat Completions — `POST /chat/completions`, `Authorization: Bearer <key>`, `choices[0].message.content` и `usage`. OpenRouter и Custom используют один HTTP-слой. Исходящие запросы — User-Agent `Chatballs/<версия>`, собственный User-Agent конкретного запроса не перебивается.
|
||||
- R-4. Провайдер разрешается через интеграцию агента. Вызов без агента завершается управляемой ошибкой, трактуемой как «эмбеддингов нет» — лексический поиск продолжает работать. Выбор первой интеграции и глобальная подмена запрещены. Тестовый адаптер — только в отладке и тестах.
|
||||
- R-5. Секрет шифруется; интеграция хранит статус, время и текст последней ошибки и признак активности отдельно от проверки.
|
||||
- R-6. «Проверить» делает реальный запрос и возвращает единообразный результат: успех или причину словами (названная провайдером причина — сразу; HTML-страница защиты — в одну строку; молчаливый 401/403 — совет проверить ключ и доступность провайдера из сети). Не выбрасывает исключение наружу, не раскрывает секрет; полный ответ — в журнал.
|
||||
- R-7. Учёт каждого вызова: агент, модель, токены, стоимость, статус, использованные фрагменты. Для статистики, дневного лимита агента и расследования ответа. Тарификации нет.
|
||||
- R-8. Отказ провайдера по существу запроса не повторяется; сбои считаются по каждой организации отдельно (ADR-0028).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Настройка поведения агента; managed AI; тарификация.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: Custom с endpoint, ключом и моделью даёт ответы; поле модели Custom рабочее; OpenRouter сохраняет каталог и проверку; демо отвечает без ключей и сети и помечен; вызов всегда через интеграцию агента; секрет не появляется в API, интерфейсе и журналах.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: "0008"
|
||||
title: "Агент: конфигурация и ход ответа"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Агент — центральный объект: одновременно AI-собеседник и точка входа. Администратор должен настраивать его на одной карточке, а ответ клиенту — приходить вовремя и не блокировать другие каналы. Основания — ADR-0009, ADR-0029, ADR-0028, ADR-0002.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт агента мастером из одного шага; остальное настраивает в карточке.
|
||||
2. В карточке выбирает провайдера и модель ответов, отдельно — провайдера и модель расшифровки голосовых, размер контекста, группу; видит подключения и код виджета.
|
||||
3. Проверочный чат карточки отвечает с тем же окном истории, что и живой диалог.
|
||||
4. Клиент пишет в мессенджер — приём записывает сообщение, ход AI считается отдельно; пока идёт ход, виджет показывает «печатает».
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Карточка агента — единственный центр администрирования: имя и статус, группа (необязательна), инструкции, модель, знания и статьи (только чтение состава — SPEC-0013), подключения со статусом, код вставки виджета, дневной лимит стоимости.
|
||||
- R-2. Инструкции — три поля: Персонализация, Тон общения, Инструкции. Системный промпт: Персонализация → Тон → Инструкции → каталог материалов (знания со ссылками вложений и статьи с адресом Help Center); содержимое подтягивает retrieval. Системный промпт не переводится; язык ответа задаёт директива по полю `answer_language` (по умолчанию — как у клиента).
|
||||
- R-3. Две пары «провайдер + модель»: ответы и расшифровка голосовых. Пустая модель — «как в интеграции». Модель расшифровки: своя → из интеграции расшифровки → `whisper-1`. Пустая интеграция расшифровки — «как у ответов».
|
||||
- R-4. Размер контекста — число последних сообщений диалога для модели: 1–200, по умолчанию 20, в блоке «Модель». История выбирается в базе с ограничением.
|
||||
- R-5. Изменения агента применяются сразу, без публикации.
|
||||
- R-6. Ход AI выполняется в `worker-events`, ходы одного диалога — строго по очереди; ответ ограничен сроком — просрочка передаёт диалог оператору с понятным текстом клиенту.
|
||||
- R-7. Ошибка расшифровки показывается оператору фразой «что случилось и где чинить», без ответа чужого API.
|
||||
- R-8. Список агентов: имя, группа, статус, подключения, счётчик открытых диалогов. Количество агентов не ограничено.
|
||||
- R-9. Мастер создания агента знания не выбирает.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Версии и релизы конфигурации агента; выбор агента клиентом; автоматическое распределение диалогов по сотрудникам.
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: "0009"
|
||||
title: Email-подключение
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Клиентская переписка по почте должна становиться диалогами с агентом, передачей человеку и историей по контакту (ADR-0015).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт подключение «Email (IMAP/SMTP)», заполняет адрес, пароль приложения, IMAP и SMTP, выбирает агента и нажимает «Проверить».
|
||||
2. Клиент пишет письмо — оно становится сообщением диалога агента; AI или сотрудник отвечает письмом в тот же тред.
|
||||
3. Сотрудник читает HTML-письмо в изолированном контейнере; процитированная переписка свёрнута под «Показать предыдущие сообщения».
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Провайдер `EMAIL` рода «мессенджер»; ящиков несколько, каждый привязан к одному агенту. Назначение «уведомления» для email отклоняется валидацией.
|
||||
- R-2. Секрет — пароль ящика (один для IMAP и SMTP), шифруется. Конфигурация: `email` (обяз.), `imapHost` (обяз.), `imapPort` (993), `imapSsl` (true), `smtpHost` (обяз.), `smtpPort` (465), `smtpSsl` (true).
|
||||
- R-3. Форма: тип, название, адрес (моноширинным), пароль с подсказкой про пароль приложения, секции «Входящая почта · IMAP» и «Исходящая почта · SMTP», выбор агента. Поля LLM-провайдеров не показываются. Готовность: название, адрес, пароль (при создании), оба хоста.
|
||||
- R-4. Приём: SSL по флагу, вход, INBOX; курсор `uidvalidity:last_uid`; первый запуск и смена `UIDVALIDITY` ставят курсор на текущий максимум без импорта истории; id события — `Message-ID`, запасной — `uidvalidity:uid`; идентичность — адрес отправителя в нижнем регистре, имя из `From`; текст — первая `text/plain`, иначе из HTML (обязателен); HTML дополнительно санитизируется структурным allowlist без стилей, форм, скриптов, активного контента, обработчиков и небезопасных ссылок; при вложениях — пометка в тексте; письма самого ящика пропускаются; ошибки соединения логируются и не прерывают воркер.
|
||||
- R-5. Отправка: `From` — ящик, `To` — клиент, `Subject: Re: <тема>`, `In-Reply-To` и `References` на последнее входящее, `text/plain; charset=utf-8`, через outbox с ретраями, STARTTLS для порта 587. Диалог хранит тему и `Message-ID` последнего входящего. Приглашение на звонок по почте не отправляется.
|
||||
- R-6. Проверка проходит только при успехе IMAP (соединение, вход, INBOX в режиме чтения) и SMTP (соединение, `EHLO`, вход); ошибка называет сторону.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Почта установки (приглашения, сброс пароля); вложения входящих писем; OAuth2; исходящие диалоги, начатые сотрудником.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: создание формой с секциями и проверка обеих сторон; входящее письмо — сообщение диалога агента ящика; ответ уходит в тред; повторная доставка не дублирует; новый ящик не втягивает историю; HTML показывается без скриптов и внешних ресурсов; Telegram, MAX, ВКонтакте и веб-виджет работают без изменений.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: "0010"
|
||||
title: Подключение сообщества ВКонтакте
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Клиенты пишут в сообщество ВКонтакте; эти обращения должны попадать к агенту так же, как из Telegram и MAX, в том числе на установке за NAT без публичного адреса (ADR-0008).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт подключение «ВКонтакте», вставляет ключ доступа сообщества, выбирает агента и нажимает «Проверить» — в карточке появляются название и адрес сообщества.
|
||||
2. Клиент пишет в сообщество — отвечает агент; сотрудник может перехватить диалог и пригласить клиента на звонок кнопкой-ссылкой.
|
||||
3. Ключу не хватает прав — проверка объясняет, какие права включить.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Провайдер `VK` рода «мессенджер»; подключение принадлежит одному агенту.
|
||||
- R-2. Приём — через Bots Long Poll: публичный адрес и доступ извне не нужны. Поддерживается прокси подключения.
|
||||
- R-3. Идентификатор сообщества не вводится: его называет ключ; проверка кладёт название и адрес сообщества в карточку.
|
||||
- R-4. Проверка смотрит настройки сообщества: без включённого Long Poll и события о входящем сообщении приём невозможен, и об этом говорится сразу. Настройки чужого сообщества система не меняет.
|
||||
- R-5. Нехватка прав объясняется словами: ключу нужны права «Сообщения сообщества» и «Управление сообществом».
|
||||
- R-6. От клиента принимаются текст, фотографии, документы и голосовые, с подстановкой имени, логина и фото профиля отправителя. Ответ — текстом и файлами; приглашение на звонок — кнопкой-ссылкой.
|
||||
- R-7. Телефона и «поделиться контактом» нет — просьба о контакте уходит обычным сообщением. Голосовые оператор в этом канале не записывает (провайдер принимает только свой формат).
|
||||
- R-8. Ключ передаётся строкой запроса, поэтому секретные параметры URL не попадают ни в журнал, ни в поле последней ошибки подключения.
|
||||
- R-9. В ленте диалогов и в карточке контакта ВКонтакте показан своей маркой.
|
||||
- R-10. Под выбором типа подключения — ссылка на статью Центра помощи про этот тип (как и для Telegram, MAX, почты и веб-виджета).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Callback API (вебхуки) ВКонтакте; запись голосовых оператором; изменение настроек сообщества системой.
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: "0011"
|
||||
title: Библиотека знаний агентов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Организации нужна общая библиотека материалов для агентов — с деревом категорий, вложениями, которые можно отдать клиенту, и мгновенным применением правок (ADR-0009, ADR-0007). Статьи портала в знания не превращаются (SPEC-0013).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт категорию и знание, прикладывает PDF — текст извлекается и индексируется.
|
||||
2. Правит текст знания — фрагменты перестраиваются, агент сразу отвечает по-новому.
|
||||
3. Агент отправляет клиенту в мессенджер ссылку на вложение — она открывается без сессии.
|
||||
4. Массово перемещает материалы между категориями — состав знаний агентов не меняется.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. `KnowledgeCategory`: organization, parent (nullable), name, sort_order. Родитель той же организации; циклы запрещены; имя уникально среди детей; порядок — `sort_order`, затем имя; удаляется только пустая листовая; системная «Без категории» создаётся с организацией и не удаляется. Предела глубины нет.
|
||||
- R-2. `Knowledge`: organization, category (обязательна), title, description (видно агенту в каталоге), content (Markdown), is_enabled. Версий, релизов и областей видимости нет. Категория — только размещение.
|
||||
- R-3. `KnowledgeAttachment`: knowledge, public_id (непредсказуемый), file, original_name (уникально в знании), content_type, size, extracted_text. Ссылка скачивания абсолютна и строится от адреса установки; защита — непредсказуемость id.
|
||||
- R-4. Агент использует: явно прикреплено ∩ `is_enabled` ∩ та же организация.
|
||||
- R-5. Фрагменты и эмбеддинги перестраиваются при изменении содержимого или вложений; переиндексацию можно запустить явно.
|
||||
- R-6. API: `GET/POST /ai/knowledge/`; `GET/PATCH/DELETE /ai/knowledge/{id}/`; `POST …/{id}/reindex/`; `POST …/{id}/attachments/`; `DELETE …/{id}/attachments/{id}/`; `POST /ai/knowledge/bulk/move/`; `POST /ai/knowledge/import/` (SPEC-0012); `GET/POST /ai/knowledge/categories/`; `GET/PATCH/DELETE /ai/knowledge/categories/{id}/`; `POST /ai/knowledge/bulk/agent/`; `POST /ai/agents/{id}/knowledge/select-category/`.
|
||||
- R-7. Выбор категории целиком сохраняет конкретные идентификаторы; динамической подписки на категорию нет.
|
||||
- R-8. Интерфейс: дерево категорий слева (сворачивается, свёрнутое запоминается), поиск и фильтры, таблица с серверной пагинацией (20/50/100 на странице), массовые действия. Управление категориями — создание, переименование, перенос, порядок, безопасное удаление — теми же общими компонентами, что и портал.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Версии знаний; области видимости по подразделениям; импорт вложений через YAML.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: непустая категория и «Без категории» не удаляются; перемещение не меняет состав агентов; выключенное знание не попадает в ответы, оставаясь прикреплённым; правка перестраивает фрагменты без публикации; ссылка на вложение работает из мессенджера; знание другой организации недоступно ни напрямую, ни массово.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: "0012"
|
||||
title: Импорт знаний из YAML
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Владельцу нужно массово загружать знания: собрать материалы → прогнать через ИИ вне продукта → получить YAML по контракту → загрузить в «Базе знаний». Инструкции агента — три поля карточки, отдельного импорта нет.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Владелец выбирает YAML-файл, видит предпросмотр (включая сообщение о категориях, которые будут созданы) и импортирует.
|
||||
2. Повторный импорт того же файла ничего не дублирует.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. YAML разбирается в браузере и отправляется JSON: `POST /ai/knowledge/import/`, тело `{ "documents": [ ... ] }`.
|
||||
- R-2. Элемент: `title` (обязателен; ключ дедупликации — организация + заголовок), `description`, `categoryPath` (массив имён от корня), `content` (обязателен, Markdown; многострочное — блок-скаляр `|`). Областей видимости и кодов подразделений нет.
|
||||
- R-3. Новое знание создаётся и индексируется сразу; существующее без изменений остаётся; изменённое содержимое или описание обновляется, переиндексация — только при изменении индексируемого содержимого.
|
||||
- R-4. Категория меняется только при явном `categoryPath`; без пути новое знание попадает в «Без категории», существующее сохраняет категорию.
|
||||
- R-5. Недостающие уровни `categoryPath` создаются автоматически; существующие с тем же именем и родителем используются повторно. Создание категорий и документа — одна транзакция: ошибка документа откатывает его категории, остальные продолжают.
|
||||
- R-6. Пустой `title` попадает в ошибки, остальные документы импортируются. Предпросмотр не блокирует документы из-за отсутствующих категорий.
|
||||
- R-7. Ответ: `{ "created", "updated", "unchanged", "failed": [{"title", "detail"}] }`.
|
||||
- R-8. Импорт не меняет состав знаний агентов — прикрепление явное (SPEC-0013).
|
||||
|
||||
## Out of scope
|
||||
|
||||
CSV и другие форматы; генеративная часть; импорт вложений; management-команда произвольного импорта (предустановленные материалы — только в демо-наборе).
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: "0013"
|
||||
title: Прикрепление источников знаний к агенту
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Составом знаний агента нужно управлять одним способом — массово из библиотек, — включая статьи портала без копирования (ADR-0017). Модели библиотеки (SPEC-0011) и портала (SPEC-0014) не меняются.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. В библиотеке знаний администратор выделяет материалы → «Прикрепить к агенту» → выбирает агента и режим (прикрепить/открепить) → видит, сколько изменено и сколько пропущено.
|
||||
2. То же из материалов портала для статей.
|
||||
3. Статью архивируют — она исчезает из ответов, но остаётся прикреплённой; восстановление возвращает её без повторного прикрепления.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Агент связан со знаниями и статьями двумя M2M; уникальность — пара «агент и элемент»; обе стороны одной организации.
|
||||
- R-2. Фрагмент имеет ровно один источник (CHECK), уникальность — «источник и номер чанка».
|
||||
- R-3. Фрагменты статьи строятся при публикации редакции и снимаются при архивации; черновик не индексируется никогда.
|
||||
- R-4. Статья доступна агенту, если совпадает организация (проверка при прикреплении), статья опубликована и её портал не архивирован (проверка в runtime).
|
||||
- R-5. Поиск — по фрагментам обоих источников одним запросом; каталог промпта — две секции (знания со ссылками вложений, статьи с адресом Help Center); цитата использует заголовок своего источника.
|
||||
- R-6. API: `POST /ai/knowledge/bulk/agent/` и `POST /ai/portal-articles/bulk/agent/`; тело — агент, `knowledgeIds` либо `articleIds`, действие. Ответ — агент, действие, признак изменения, изменённые и пропущенные id, полный состав после операции.
|
||||
- R-7. Неизвестный или чужой id отклоняет запрос целиком; неизвестный агент — `404`; прикрепление пропускает недоступные и возвращает их списком, уже прикреплённые не считаются изменением; открепление доступность не проверяет; операция атомарна и блокирует агента; аудит с фактически изменёнными id.
|
||||
- R-8. Вкладка «Знания» в карточке агента — только чтение: две секции, элемент ведёт в свою карточку (статья — в публичный Help Center), выключенное знание и неопубликованная статья помечены статусом. Окно выбора знаний разложено по категориям и порталам, со счётчиками и «Выбрать все», материалы грузятся целиком.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Динамическая подписка агента на категорию; зеркалирование статей в знания.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: недоступный элемент не меняет состав и возвращается в пропущенных; повтор не создаёт дубль; открепление снимает связь независимо от доступности; публикация создаёт фрагменты, архивация удаляет; черновик никогда не в поиске; поиск возвращает оба источника; архивная статья исчезает из ответов, оставаясь прикреплённой; во вкладке агента нет операций изменения.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "0014"
|
||||
title: Порталы поддержки и публичная база знаний
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Организации нужны публичные Help Center с деревом разделов, статьями, редакциями, своим адресом, темой и встроенным веб-чатом, а внутреннее управление — в той же композиции, что и библиотека знаний (ADR-0016, ADR-0022).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор создаёт несколько порталов (черновик не блокирует следующий), наполняет разделы и статьи, публикует выбранную редакцию.
|
||||
2. Подключает свой домен — система проверяет, что он ведёт на сервер, и выпускает сертификат.
|
||||
3. В настройках портала выбирает тему и схему — публичная страница меняет оформление без вспышки.
|
||||
4. Клиент на портале читает статью, оценивает её и пишет в веб-чат.
|
||||
5. Архивирует портал с подтверждением — портал только для чтения, доступно восстановление.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Раздел «Порталы»: список и карточка; действие создания видно всегда. Карточка — единая библиотека материалов без вкладок; настройки — отдельным окном. Ошибки публикации, архива и сохранения — в контексте действия, без технического текста.
|
||||
- R-2. Управляющее API: `/support/portals/` (GET, POST), `/{id}/` (GET, PATCH, DELETE), `/{id}/status/`, `/{id}/widgets/` (только анонимные виджеты), `/{id}/domain/` (GET, PUT), `/{id}/domain/verify/`, `/{id}/categories/…`, `/{id}/articles/…`, `/{id}/articles/import/`, `/{id}/articles/{id}/publish/`, `/archive/`, `/revisions/`.
|
||||
- R-3. Композиция материалов: дерево разделов со счётчиками слева; поиск, фильтры языка и статуса, единая таблица справа; тот же паттерн управления разделами и редактора; видимая история неизменяемых редакций с отметкой опубликованной и выбором редакции при публикации; общие состояния загрузки, пустоты, ошибки, только-чтения и архива. Компоненты — из общего модуля. Фильтр родителя включает потомков, счётчик — всё поддерево.
|
||||
- R-4. Публичная поверхность: `GET /api/v1/help/`, `GET /api/v1/help/articles/`, `GET /api/v1/help/articles/{slug}/`, `POST /api/v1/help/articles/{slug}/feedback/`, `GET /api/v1/help/files/{public_id}/`. Портал — по HTTP Host до разбора маршрута; чтение в контексте организации с проверкой публикации; неизвестный путь — публичный `404`; переход на форму входа запрещён.
|
||||
- R-5. Список статей — без полного текста; счётчики вычисляет backend; список ограничен. Отзыв защищён rate limit и не растёт неограниченно.
|
||||
- R-6. Markdown — безопасный стандартный парсер с GFM (якоря, ссылки, изображения, вложенные списки, цитаты, код, таблицы, задачи); пользовательский HTML не исполняется.
|
||||
- R-7. Адрес: ключ портала (`[a-z0-9-]`) + базовый help-домен, интерфейс показывает URL целиком. Свой домен нормализуется, не совпадает с доменом приложения и адресами порталов, уникален; техническая проверка без подтверждения владения; до активации работает адрес по умолчанию.
|
||||
- R-8. Веб-чат портала — конкретный опубликованный виджет, разрешающий origin портала и ведущий к активному агенту; только штатный лоадер.
|
||||
- R-9. Темы: вёрстка читает только `--help-*`; правила темы скоупятся; тема без JS; внешние ресурсы не грузятся; портал хранит id темы, схему и параметры; отсутствующая тема деградирует до `classic` и показывается недоступной; схема через `data-theme`; тема применяется до первого кадра (загрузчик по центру сразу, стиль в общем CSS).
|
||||
- R-10. В футере порталов — «Работает на Chatballs» со ссылкой.
|
||||
- R-11. Лимитов и тарифных условий нет; скрытое отключение кнопки создания запрещено.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Продукты на портале и авторизованная поддержка из продукта (ADR-0023); подтверждение владения доменом.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: несколько порталов со своими адресами; адрес портала не показывает вход, адрес приложения — Help Center; своим доменом нельзя занять чужой; иерархия и счётчики поддерева работают; стандартный Markdown без исполнения HTML; смена темы без вспышки, удаление темы не ломает данные; архивный портал — только чтение; нет второго стандарта таблиц, статусов, меню, форм и редактора.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: "0015"
|
||||
title: Интерфейс организации
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Рабочее пространство организации (`apps/internal-ui`) должно строиться вокруг чата, быть минимальным по сущностям, информативным плотностью, быстрым для входа и с одним стандартом на каждый элемент. Визуальный стиль (палитра, графика, шрифты) этой спецификацией не определяется — его решает владелец; здесь структура, поведение и механизм темизации. Основания — ADR-0019, ADR-0021.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Владелец после мастера видит чек-лист «Запуск» из трёх шагов и проходит его без документации меньше чем за пять минут до тестового сообщения агенту.
|
||||
2. Сотрудник входит — сразу открывается чат; навигации нет, только логотип, дерево диалогов и меню профиля.
|
||||
3. Пользователь нескольких организаций после входа выбирает, с какой начать; переключает организации у логотипа; при праве — «Добавить организацию».
|
||||
4. Пользователь меняет тему и акцент в профиле — на всех устройствах и без перезагрузки.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Принципы: чат — главный экран; нет «каналов», «подключений», «отделов», «релизов»; состояние видно без кликов, декоративные плитки запрещены; один паттерн на задачу.
|
||||
- R-2. Роли в интерфейсе не различаются ничем, кроме бейджа роли и отсутствия «удалить»/«заблокировать» на карточке владельца (скрыты, не задизейблены).
|
||||
- R-3. Навигация владельца и администратора — семь плоских пунктов: Чат (бейдж очереди по границе видимости, без удалённых диалогов), Контакты, Агенты, Сотрудники, Порталы, База знаний, Настройки. Прямые URL для каждого раздела; deep-link ведёт в диалог. Нет командного центра, «Отделов», «Каналов», «Интеграций» и «Использования AI» первого уровня, «Продаж» и «Продуктов».
|
||||
- R-4. Чек-лист «Запуск»: создать агента, подключить точку входа, пригласить сотрудников; каждый шаг — одна кнопка-переход; отмечается автоматически по данным; исчезает после выполнения или «Скрыть». Кнопка «Начало работы» перетаскивается, место запоминается.
|
||||
- R-5. Пустые экраны отвечают на «что это, зачем, что нажать» короткой фразой и одной кнопкой, видимой только тому, кто может выполнить действие.
|
||||
- R-6. Подсказки — единый компонент, только владельцу и администратору; не блокируют, закрываются одним кликом, не более одной на экране, без туров; реестр в коде единым списком; факт закрытия — в браузере.
|
||||
- R-7. Темизация: цвета только через CSS-токены и тему antd; светлая и тёмная обязательны, переключатель Светлая / Тёмная / Как в системе (по умолчанию — система) через `data-theme`; настройка хранится на сервере. Акцент по умолчанию `#1677ff`, выбирается из пресетов или произвольно; производные оттенки и контраст вычисляются автоматически; статусные цвета от акцента не зависят. Цвет подключения живёт только в его бейдже (у каждого канала — своя марка). Плотность compact enterprise.
|
||||
- R-8. Чат: три колонки — список с фильтрами (группа, агент, состояние, «ждут человека», вкладки «Общая очередь» и «На мне») · лента · контекст-панель (контакт и история). Действия над диалогом — перехват, возврат AI, перенос в группу, ответственный, закрытие, метки — в одной строке над лентой; «Удалить диалог» — только владельцу и администратору (SPEC-0018). Шаблоны ответов — через «/» или кнопку «Шаблоны».
|
||||
- R-9. Контакты: таблица (контакт, подключения, последний диалог, открытые); карточка — профиль, идентификаторы, диалоги, аудит; без «лид/клиент». Фото контактов из мессенджеров хранятся в установке.
|
||||
- R-10. Агенты: SPEC-0008.
|
||||
- R-11. Сотрудники: таблица (человек, роль, группы, статус) включая строки «Приглашён»; приглашение — модальное окно; группы управляются здесь же.
|
||||
- R-12. Порталы и База знаний — одна композиция библиотеки из общего модуля.
|
||||
- R-13. Настройки — субменю слева, один раздел на экране: Организация (название, логотип PNG/JPEG/WebP/SVG, часовой пояс, валюта, язык), Группы, AI-провайдер, Интеграции, Шаблоны ответов (SPEC-0016), Голосовые и звонки / TURN для звонков, Хранилище файлов (только администратор установки), Платформа (адрес установки, почта, обновления; только администратор установки), Демо-данные. Профиль — отдельная страница из меню пользователя.
|
||||
- R-14. SVG-логотип проверяется при загрузке (скрипты, обработчики, `foreignObject`, внешние ссылки в `href` и `url()`, DOCTYPE и сущности отклоняются с понятной ошибкой) и отдаётся с CSP `sandbox` и `nosniff`. В сайдбаре — без обводки и подложки.
|
||||
- R-15. Списки (порталы, статьи, контакты, сотрудники, агенты, аудит) — страницами: `items`, `page`, `pageSize`, `total`, `pageCount`; поиск, фильтры и сортировка — параметры запроса; смена фильтра — на первую страницу; страница за пределами — последняя существующая; один подвал на всё приложение.
|
||||
- R-16. Ленты (список диалогов, сообщения) — окном по курсору: `items`, `hasMore`, `cursor`. Открытие диалога — хвост 50 сообщений; прокрутка вверх догружает с сохранением места; обновление — только новое; карточка диалога сообщений не несёт; список обновляет голову без сброса прокрутки.
|
||||
- R-17. Выпадающие выборы (ответственный, передача владения, фильтр агентов) — справочники (id, имя, аватар) с серверным отбором и поиском.
|
||||
- R-18. Оповещения — WebSocket-канал организации сообщает, что изменилось; данные забираются обычным HTTP с проверкой видимости; событие инбокса без идентификаторов; подписка на открытый диалог — с проверкой видимости; опрос — запасной путь.
|
||||
- R-19. Целевая ширина 1280–1600, минимум 1024 (контекст-панель складывается); мобильная версия — только чат. Фокус-стили, клавиатура в чате, контраст AA в обеих темах при любом акценте.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Визуальный стиль; адаптация админ-разделов под телефон; командный центр; экран управления администраторами установки.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: от первого входа до тестового сообщения ≤ 5 минут; семь пунктов навигации, у сотрудника навигации нет; ни одного сырого hex вне токенов и второго варианта таба, меню, ссылки; обе темы на всех экранах, смена без перезагрузки, настройка переживает перелогин и устройство; права владельца и администратора совпадают кроме удаления и блокировки владельца; ни один список не запрашивается целиком.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: "0016"
|
||||
title: Шаблоны ответов
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Операторам нужны готовые ответы, а администраторам — место, где их заводить и править. Раньше шаблоны можно было только вставить в диалог. Готовый текст не должен уйти клиенту с пустой подстановкой вроде «Здравствуйте, ,».
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор в «Настройки → Шаблоны ответов» добавляет шаблон, вставляет переменную «имя клиента» кнопкой «Вставить переменную».
|
||||
2. Оператор в диалоге набирает «/» или нажимает «Шаблоны», выбирает шаблон — переменные сразу заменены значениями, текст можно поправить.
|
||||
3. Гость на сайте не представился — переменная остаётся в тексте, над полем «Заполните в тексте: имя клиента», отправка заблокирована до замены.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Шаблоны общие на организацию: правят те, кому доступны настройки; пользуются все операторы.
|
||||
- R-2. Список: название, начало текста, дата изменения; «Добавить шаблон»; правка по щелчку на строке; удаление с подтверждением.
|
||||
- R-3. Название уникально в организации; занятое название — понятная ошибка.
|
||||
- R-4. Переменные: имя клиента, имя оператора, название организации; вставляются в позицию курсора.
|
||||
- R-5. При выборе шаблона переменные заменяются настоящими значениями; незаполненная переменная остаётся, показывается подсказка, и сообщение не отправляется, пока она не заменена.
|
||||
- R-6. Шаблон с неизвестной или опечатанной переменной не сохраняется — сервер называет эту переменную.
|
||||
- R-7. Шаблоны, заведённые до появления переменных, работают как есть.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Личные шаблоны оператора; шаблоны, привязанные к агенту или группе.
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: "0017"
|
||||
title: "Уведомления сотрудников: доставка и прочтение"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Рабочие события (клиент ждёт, новое сообщение, ошибка подключения) должны доходить до сотрудников, а счётчик непрочитанного — отражать реальность и не звать туда, где человек уже был (ADR-0006).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Клиент запросил оператора — сотрудники, которым виден диалог, получают уведомление в интерфейсе, звуком, в браузере, письмом и в привязанный служебный бот.
|
||||
2. Сотрудник открывает диалог — все уведомления по нему у этого сотрудника гаснут, счётчик сходится во всех его вкладках.
|
||||
3. Сотрудник привязывает служебный Telegram-бот: получает в профиле одноразовый код и открывает бота по deep-link.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Способы: центр уведомлений, звук в чате, browser notifications, письмо через почту установки, служебный Telegram- или MAX-бот (отдельная интеграция с назначением «уведомления», не привязана к агенту).
|
||||
- R-2. Типы — реестр: базовые `DIALOG_WAITING`, `DIALOG_NEW_MESSAGE`, `INTEGRATION_ERROR`; в интерфейсе также «новый диалог» и «долго ждёт». Адресация — всем, владельцу, работающим с диалогами или конкретному пользователю; диплинк на объект. Уведомления о диалогах — по границе видимости.
|
||||
- R-3. Открытие диалога гасит **все** уведомления о нём (новый диалог, запрос оператора, новое сообщение, долго ждёт), включая не поместившиеся в шторку (в неё грузятся последние 50).
|
||||
- R-4. Уведомления гасятся только у того, кто открыл диалог; у других сотрудников остаются.
|
||||
- R-5. Счётчик непрочитанных сходится во всех открытых вкладках сотрудника сразу.
|
||||
- R-6. Старый адрес уведомления распознаётся; нечисловой идентификатор отклоняется.
|
||||
- R-7. Каждая доставка идемпотентна; недоступность одного способа не блокирует остальные. Сотрудник настраивает способы и типы в пределах роли.
|
||||
- R-8. Уведомления удалённого диалога удаляются вместе с ним.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Использование клиентских ботов организации как служебного канала; email-подключение как канал уведомлений.
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: "0018"
|
||||
title: "Диалоги: управление, метки и удаление"
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Сотрудникам нужно управлять диалогом в одном месте — перехват, возврат AI, группа, ответственный, метки, закрытие, — а владельцу — полностью удалять диалог. Основания — ADR-0001, ADR-0002, ADR-0021.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Сотрудник в «Общей очереди» забирает диалог (claim) — он переходит во вкладку «На мне», AI замолкает.
|
||||
2. Сотрудник переносит диалог в другую группу и назначает ответственного.
|
||||
3. Сотрудник добавляет метку: вводит название и нажимает «+» или Enter.
|
||||
4. Владелец удаляет диалог — исчезают переписка, вложения, звонки и уведомления.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Действия над диалогом — в одной строке над лентой: перехват, возврат AI, перенос в группу (или из группы), назначение ответственного, закрытие, метки.
|
||||
- R-2. Claim атомарен; писать в режиме `HUMAN` может назначенный сотрудник; владелец и администратор перехватывают и переназначают; возврат к AI — только явным действием.
|
||||
- R-3. Перенос и смена ответственного доступны владельцу, администратору и сотрудникам, видящим диалог; назначение ответственным делает диалог видимым сотруднику.
|
||||
- R-4. Меню меток: поле названия не сжимается, без полосы прокрутки, новая метка — «+» или Enter; ошибка загрузки меток показывается.
|
||||
- R-5. «Удалить диалог» удаляет переписку, вложения, звонки и уведомления; право — только у владельца и администратора; писать в удалённый диалог нельзя; переписка в виджете у клиента обнуляется.
|
||||
- R-6. Точка режима диалога стоит в углу аватара в любой строке списка, независимо от меток и таймера ожидания.
|
||||
- R-7. Фото контактов из Telegram, MAX и ВКонтакте скачиваются и хранятся в установке, а не подтягиваются ссылкой с чужого домена.
|
||||
- R-8. Голосовое из MAX без тела в событии забирается отдельным запросом.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Автоматическое распределение диалогов; повторное открытие закрытого диалога.
|
||||
|
||||
## Open questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "0019"
|
||||
title: Данные с сайта в веб-чате
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Сайт уже знает о посетителе много полезного: кто он, есть ли у него активный заказ, какой у заказа номер и статус. Сейчас веб-чат начинается с анонимного гостя, поэтому оператор и AI-агент переспрашивают то, что сайт мог бы передать сам. Статус заказа меняется по ходу диалога, и об этом нужно узнавать без перезагрузки.
|
||||
|
||||
Основание — ADR «Данные с сайта — недоверенные поля контакта в контексте агента». Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадры W1, C1, M2; пересказ — README.md рядом). Кейс макета — столовая «Обед».
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор открывает веб-подключение → раздел «Данные с сайта» (W1), видит таблицу «Поля контакта» (Имя, Email, Телефон — только чтение) и добавляет свои поля: «ID клиента» (строка), «Активный заказ доставки» (да/нет), «Статус заказа» (список: Принят, Готовится, В пути, Доставлен, Отменён — с цветами), «Номер заказа» (строка). Для трёх последних включает «Видит AI». Копирует пример кода.
|
||||
2. Сайт вызывает `Chatballs.setFields({ name, email, user_id, has_active_order: true, order_status: "cooking", order_number: "10482" })`, даже до загрузки виджета. Клиент открывает чат и пишет.
|
||||
3. Оператор открывает диалог (C1): во вкладке «Контакт» появилась секция «Данные с сайта» с замком и отметкой «обновлено в HH:MM».
|
||||
4. Сайт вызывает `Chatballs.setFields({ order_status: "on_the_way" })`. У оператора без перезагрузки меняется бейдж и подсвечивается строка, в ленте появляется событие «Сайт обновил данные: статус заказа «Готовится» → «В пути»». AI в следующем ответе говорит, что заказ в пути (M2).
|
||||
5. Сайт присылает неизвестный ключ или строку в поле «да/нет»: виджет работает, лишнее не сохраняется.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Схема полей
|
||||
- R-1. Схема хранится в `integration.config.fields` WEB-подключения. Поле: `key` (`^[a-z][a-z0-9_]{0,39}$`, уникален в подключении, после создания не меняется), `label` (до 60 символов), `type` (`string | number | boolean | datetime | enum | email | phone | url`), `options` (только для enum: `value`, `label`, `color`), `aiVisible`, `order`.
|
||||
- R-2. Не больше 30 своих полей на подключение. Ключи `name`, `email`, `phone` зарезервированы и своими быть не могут. Сервер отклоняет нарушения с понятной ошибкой.
|
||||
- R-3. Раздел «Данные с сайта» (W1): таблица «Поля контакта» только для чтения; таблица «Свои поля» с перетаскиванием для порядка, выбором типа, чипами значений списка `label · value` и «+ значение», тумблером «Видит AI» и меню ⋯ (переименовать, удалить). Там же блок с примером кода передачи. Всё — ровно как в макете.
|
||||
- R-4. Удаление поля не удаляет сохранённые значения, но они перестают показываться оператору и передаваться AI.
|
||||
|
||||
### Передача с сайта
|
||||
- R-5. Лоадер `chat-widget.js` объявляет `window.Chatballs` с очередью: `setFields`, вызванный до загрузки лоадера, не теряется. Существующий `window.ChatballsChat` продолжает работать.
|
||||
- R-6. `setFields` — частичное обновление (слияние по ключам); `null` очищает значение. Вызовы склеиваются, данные отправляются не чаще раза в 500 мс.
|
||||
- R-7. Лоадер передаёт поля в iframe через `postMessage({ type: "chatballs-set-fields", fields })` только своему окну. До начала сессии iframe держит поля в памяти и отправляет их вместе со стартом сессии, после — `POST /api/v1/webchat/fields/` с токеном сессии.
|
||||
- R-8. Публичная конфигурация виджета отдаёт схему полей без признака `aiVisible`.
|
||||
|
||||
### Сервер
|
||||
- R-9. Валидация по схеме: неизвестные ключи и значения неверного типа отбрасываются, для сайта это не ошибка, в журнал — предупреждение без значения. Для enum принимаются только `value` из `options`, строки — до 500 символов.
|
||||
- R-10. `name`, `email`, `phone` записываются в контакт, только если там пусто или прошлое значение пришло с этого же подключения. Ручную правку оператора сайт не затирает.
|
||||
- R-11. Остальные значения хранятся в `contact_field_values` (организация, контакт, подключение, ключ, значение, время изменения), последнее значение на ключ. Таблица под RLS, есть тест изоляции.
|
||||
- R-12. Если значение изменилось и у контакта есть открытый диалог, в ленту пишется системное событие с кодом и параметрами (подпись, старое и новое отображаемое значение). Фразу собирает бэкенд на языке читателя. Событие пишется только для полей типа enum и boolean, чтобы не засорять ленту.
|
||||
- R-13. Оператору уходит событие по WebSocket: карточка и лента обновляются без перезагрузки.
|
||||
- R-14. Поля с `aiVisible` попадают в системный промпт агента отдельным блоком «Данные клиента с сайта» (`подпись: значение`, для enum — подпись значения, для boolean — да/нет). Блок помечен как сведения от сайта, а не инструкции. Остальные поля к модели не уходят.
|
||||
- R-15. Данные с сайта не используются для авторизации, идентификации и объединения контактов.
|
||||
- R-16. API диалога и контакта отдаёт `siteFields: [{ key, label, type, value, display, color?, updatedAt }]` в порядке схемы, без удалённых полей.
|
||||
|
||||
### Оператор
|
||||
- R-17. Во вкладке «Контакт» контекст-панели чата — секция «Данные с сайта» между карточкой контакта и блоком «Диалог» (C1). В шапке — замок и «обновлено в HH:MM». Секции нет, если значений нет.
|
||||
- R-18. Строка: подпись слева, значение справа. boolean — бейдж «Да» или «Нет», enum — бейдж цвета значения, ID и номера — моноширинно с «копировать», email, телефон и url — ссылка с «копировать», datetime — в формате организации.
|
||||
- R-19. Значение, изменённое меньше 10 минут назад, подсвечено, справа — время изменения. Редактирования нет.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Подписанная передача данных (JWT от сервера сайта).
|
||||
- Секция «Данные с сайта» в карточке раздела «Контакты» — второй этап, отдельным решением и макетом.
|
||||
- Поиск и фильтрация контактов по своим полям, история значений вне ленты диалога.
|
||||
- Своё поле в других подключениях (Telegram, MAX, VK, почта).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Правило системного события (R-12): в описании макета оно помечено «обсудить». Предложено: только enum и boolean, независимо от «Видит AI». Альтернатива — ещё и любые поля с «Видит AI».
|
||||
- Критерии приёмки: 4 поля (строка, да/нет, список, строка) создаются; `setFields` на сайте даёт значения в карточке оператора; смена `order_status` меняет бейдж без перезагрузки, пишет событие в ленту, и AI учитывает новый статус; неизвестный ключ и неверный тип не ломают виджет и не сохраняются; ручная правка имени оператором не затирается сайтом; значения одной организации недоступны другой.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: "0020"
|
||||
title: Форма перед чатом веб-виджета
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Некоторым организациям нужно знать, как зовут клиента и как с ним связаться, ещё до начала диалога. Сейчас перед чатом показывается только согласие. Спрашивать то, что сайт уже передал, нельзя — это раздражает клиента.
|
||||
|
||||
Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадры W2 и M1). Зависит от спецификации «Данные с сайта в веб-чате»: свои поля и `setFields`. Меняет требование SPEC-0001 R-16 («обязательной анкеты нет»): анкета остаётся выключенной по умолчанию.
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор в разделе «Форма перед чатом» (W2) включает форму, отмечает Имя, Email, Телефон и своё поле «Номер заказа», делает Имя и Email обязательными, задаёт заголовок и текст согласия.
|
||||
2. Сайт передал имя, почту и номер заказа. Клиент открывает чат (M1) и видит форму: эти поля заполнены и помечены «с сайта», телефон пустой. Клиент при желании поправляет значения и нажимает «Начать чат».
|
||||
3. Клиент стирает обязательное имя — «Начать чат» становится недоступной.
|
||||
4. Клиент вернулся через день в той же сессии — формы нет, открывается переписка. Администратор поменял текст согласия — форма показывается снова.
|
||||
5. Форма выключена — виджет ведёт себя как сейчас: согласие и «Начать чат».
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Настройка хранится в `integration.config.preChat`: `enabled`, `title`, `fields: [{ key, required }]`, где `key` — `name`, `email`, `phone` или ключ своего поля. По умолчанию форма выключена.
|
||||
- R-2. Раздел «Форма перед чатом» (W2): включатель, список полей с чекбоксами (Имя, Email, Телефон и «Своё поле из „Данных с сайта“»), тумблер «Обязательное», заголовок формы, текст согласия. Ровно как в макете.
|
||||
- R-3. Текст согласия — существующие `consentText` и `consentVersion`. При изменении текста сервер сам повышает версию.
|
||||
- R-4. Сервер отклоняет `preChat` со ссылкой на несуществующее своё поле. Удаление своего поля из схемы убирает его из формы.
|
||||
- R-5. Публичная конфигурация виджета отдаёт `preChat` и схемы полей, нужные форме.
|
||||
- R-6. В виджете при включённой форме экран согласия и нижняя кнопка «Начать чат» заменяются формой (M1): аватар агента, заголовок, карточка с полями, текст согласия, кнопка «Начать чат».
|
||||
- R-7. «Начать чат» недоступна, пока не заполнены обязательные поля или есть невалидные значения.
|
||||
- R-8. Значение, пришедшее через `setFields`, показывается заполненным с пометкой «с сайта», клиент может его изменить.
|
||||
- R-9. Контролы по типу поля: email и телефон — с проверкой формата (телефон — существующий `formatPhone`), boolean — переключатель, enum — выпадающий список, datetime — нативное поле даты и времени, остальное — текстовое поле.
|
||||
- R-10. Значения формы уходят в старт сессии вместе с принятием согласия и имеют приоритет над `setFields`. На сервере они проходят ту же валидацию и запись, что и данные с сайта: встроенные поля — в контакт, свои — в значения полей.
|
||||
- R-11. Если сессия есть и согласие этой версии уже дано, форма не показывается повторно.
|
||||
- R-12. Все тексты виджета — через словари `web-chat/src/i18n/ru.ts` и `en.ts`; подписи своих полей берутся из схемы.
|
||||
- R-13. До нажатия «Начать чат» модель не вызывается, диалог не начинается (SPEC-0001 R-5).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Условная логика полей, многошаговые анкеты, проверка почты кодом.
|
||||
- Форма в других подключениях.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: форма выключена — виджет работает как раньше; имя и почта пришли с сайта — предзаполнены с пометкой «с сайта»; без обязательных полей «Начать чат» недоступна; неверная почта не проходит; значения формы оказываются в контакте и в «Данных с сайта»; смена текста согласия показывает форму повторно.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: "0021"
|
||||
title: Оформление веб-виджета
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Виджет выглядит одинаково на всех сайтах: синяя круглая кнопка справа с нашим знаком. Цвет `accent` в конфигурации есть, но лоадер его не применяет: кнопка всегда `#1677ff`, а менять цвет в интерфейсе негде. Организациям нужно вписать виджет в свой сайт, не трогая вставленный код.
|
||||
|
||||
Утверждённый макет: `design/baseline/Веб-чат · поля и оформление/` (кадр W3 интерактивный; M1–M2 показывают результат).
|
||||
|
||||
## Scenarios
|
||||
|
||||
1. Администратор в разделе «Оформление» (W3) выбирает «Бирюзу». Предпросмотр справа и кнопка на сайте перекрашиваются без перевставки кода.
|
||||
2. Администратор вводит свой HEX `#ffd400` и видит предупреждение «Белый текст плохо читается · контраст …». Сохранить всё равно можно.
|
||||
3. Администратор загружает SVG-логотип для кнопки, переносит её влево, выбирает размер 64 и скруглённую форму. Панель открывается над кнопкой слева.
|
||||
4. Администратор вставляет свой CSS с классом `.cb-header`: меняется шапка внутри окна чата, страница сайта не меняется. «Сбросить» очищает CSS.
|
||||
5. Злоумышленник с правами администратора загружает SVG со `<script>`: сохранённый файл скрипта не содержит.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R-1. Настройки — в `integration.config.appearance`: `accent` (`#RRGGBB`, существующий `config.accent`), `launcherIcon`, `headerIcon` (URL; `null` у шапки — без иконки), `launcherPosition` (`left | right`, по умолчанию `right`), `launcherSize` (`48 | 56 | 64`, по умолчанию 56), `launcherShape` (`circle | rounded | square`: 50%, 30%, 10px), `customCss`.
|
||||
- R-2. Цвет: 8 пресетов — Синий `#1677ff`, Индиго `#4f46e5`, Бирюза `#0d8a7e`, Зелёный `#15803d`, Терракота `#c2410c`, Малина `#be123c`, Слива `#7e22ce`, Графит `#262626` — и поле «Свой HEX». Для своего цвета показывается контраст с белым: при значении меньше 4.5:1 — предупреждение, при неверном HEX — подсказка о формате. Сохранение не запрещается.
|
||||
- R-3. Иконки: загрузка SVG или PNG до 256 КБ, PNG не меньше 96×96 — `POST /api/v1/integrations/{id}/assets/` → `{ url }`. Файлы лежат в текущем хранилище организации (`organizations/{public_id}/...`, диск или S3) и отдаются публично по URL. По умолчанию — стандартный знак агента. Иконка шапки по умолчанию совпадает с иконкой кнопки.
|
||||
- R-4. SVG санитизируется на сервере до сохранения: удаляются `script`, атрибуты `on*`, `foreignObject`, внешние ссылки (`href` и `xlink:href` не на `#`), `javascript:`-адреса.
|
||||
- R-5. Лоадер `chat-widget.js` сам получает публичную конфигурацию и применяет цвет, иконку, положение, размер и форму кнопки. Панель открывается над кнопкой с той же стороны, анимация «джин» и мобильный полноэкранный режим (SPEC-0001 R-14) сохраняются. Цвет тени и фокуса — от выбранного цвета.
|
||||
- R-6. Публичная конфигурация отдаёт `appearance` целиком. Виджет применяет цвет и иконку шапки.
|
||||
- R-7. Свой CSS — до 10 КБ. Сервер вырезает `@import`, `url(...)` с внешними адресами и `expression`. Виджет вставляет его `<style>` внутри iframe чата, на страницу сайта он не попадает. Кнопка «Сбросить» очищает поле.
|
||||
- R-8. Ключевые элементы виджета получают стабильные классы: `cb-header`, `cb-body`, `cb-bubble`, `cb-bubble--client`, `cb-bubble--agent`, `cb-composer`, `cb-start-button`, `cb-form-field` и другие, перечисленные в справке. Базовые стили этих элементов не должны перебиваться только через `!important`: они выносятся в `<style>` с низкой специфичностью.
|
||||
- R-9. Список классов опубликован в справке, раздел «Оформление» ссылается на неё («Классы виджета»).
|
||||
- R-10. Раздел «Оформление» (W3) — ровно как в макете, с живым предпросмотром справа: цвет, кнопка, положение, размер, форма и иконки меняют предпросмотр сразу. Предпросмотр — упрощённая копия виджета, как в макете.
|
||||
- R-11. Изменения применяются на сайте без перевставки кода; кэш лоадера и конфигурации не задерживает их дольше 5 минут.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Тёмная тема виджета, свои шрифты через загрузку файлов, несколько кнопок на странице.
|
||||
- CSS, влияющий на страницу сайта (кнопку и тень на сайте рисует лоадер).
|
||||
- Предпросмотр живым компонентом виджета внутри админки.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Критерии приёмки: смена цвета, позиции и формы кнопки применяется на сайте без перевставки кода; SVG со `<script>` после загрузки не содержит скрипта; свой CSS меняет вид внутри чата и не влияет на страницу сайта; `@import` и внешние `url()` вырезаются; предупреждение о контрасте появляется для светлого цвета.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: T-001
|
||||
title: Команды CLI backup и restore
|
||||
status: todo
|
||||
depends_on: []
|
||||
spec: "0005"
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Реализовать в `./chatballs` резервное копирование и восстановление экземпляра вместо ручных `pg_dump` и архива медиа.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] `backup` создаёт копию: дамп PostgreSQL, архив медиа, три тома секретов и метаданные выпуска; копирование каталога работающего PostgreSQL не используется
|
||||
- [ ] Redis в копию не входит
|
||||
- [ ] `restore` проверяет совместимость выпуска и схемы, требует явного подтверждения (кроме авторизованного `--non-interactive`) и останавливает пишущие сервисы
|
||||
- [ ] Пароли не принимаются аргументами командной строки и не попадают в вывод
|
||||
- [ ] Команды не выполняются параллельно с другими операциями CLI; ошибка даёт ненулевой код возврата
|
||||
- [ ] После восстановления реестр и состояние демо-набора согласованы
|
||||
|
||||
## Заметки
|
||||
|
||||
Сейчас не реализовано (известное расхождение в спецификации установки). Порядок операций не должен дублироваться в CI и ручных инструкциях — CI вызывает тот же entrypoint.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: T-002
|
||||
title: Команды CLI install, update и rollback
|
||||
status: todo
|
||||
depends_on:
|
||||
- T-001
|
||||
spec: "0005"
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Реализовать в `./chatballs` первичную установку, обновление и откат по каноническому порядку развёртывания.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] `install` поднимает экземпляр на чистой машине без `.env` и заданных переменных
|
||||
- [ ] `update` делает резервную копию, получает образы целевого выпуска по digest, выполняет `deploy` с миграциями и проверяет здоровье; секреты и демо-данные не меняются
|
||||
- [ ] `rollback` восстанавливает предыдущий выпуск через восстановление из резервной копии и явно предупреждает, что откат необратимых миграций иначе невозможен
|
||||
- [ ] Все команды поддерживают `--non-interactive`, fail-fast и блокировку параллельных операций
|
||||
- [ ] Путь обновления совместим с обновлением из интерфейса (сервис `updater`) и не создаёт второй источник истины
|
||||
|
||||
## Заметки
|
||||
|
||||
Сейчас реализованы только `doctor`, `deploy`, `status`, `logs`. `rollback` зависит от `backup`/`restore`.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: T-003
|
||||
title: Разобраться с признаком режима поставки CLOUD / SELF_HOSTED
|
||||
status: todo
|
||||
depends_on: []
|
||||
spec: "0005"
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Устранить расхождение: в `README.md`, `scripts/start.sh`, `scripts/start.ps1` и бэкенде встречается признак режима поставки `CLOUD` / `SELF_HOSTED`, который не описан ни одним решением, а managed-облако в продукт не входит.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] Владелец решил, нужен ли признак после отказа от managed-облака
|
||||
- [ ] Признак либо описан в спецификации установки с назначением и влиянием на поведение, либо удалён из README, скриптов и кода
|
||||
- [ ] Установка по-прежнему не требует ни одной переменной окружения
|
||||
|
||||
## Заметки
|
||||
|
||||
Упоминания найдены в `apps/backend/chatballs_backend/settings_base.py`, `chatballs/platform/provisioning_models.py`, `chatballs/api/permissions.py`, `scripts/start.sh`, `scripts/start.ps1`. Источник провижининга `SELF_HOSTED_SETUP` используется при создании организаций. Решение за владельцем.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: T-004
|
||||
title: Публичная документация установки и конфигурации
|
||||
status: todo
|
||||
depends_on: []
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Проверить и довести публичную документацию open-source репозитория (README, установка, конфигурация) до соответствия текущей поставке.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] README описывает установку одной командой, требования к хосту и открываемые порты, включая 3478 TCP/UDP и 49160–49999/udp для звонков
|
||||
- [ ] Описаны сервисы стека, включая `worker-events`, `updater` и `coturn`, и три тома секретов в резервной копии
|
||||
- [ ] Описаны обновление из интерфейса и вручную, а также ручной откат
|
||||
- [ ] В публичной документации нет ссылок на приватные проектные документы и служебных кодов решений
|
||||
|
||||
## Заметки
|
||||
|
||||
Проектная документация приватна, публичная пишется отдельно. Релизы уже публикуются на GitHub, поэтому часть работы, вероятно, сделана — задача в первую очередь на сверку. Отдельный вопрос подготовки к публикации — чистый публичный репозиторий или чистка истории от ранее закоммиченных документов; текущий статус этого шага в источниках не указан.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: T-005
|
||||
title: Экран управления организациями для администратора установки
|
||||
status: todo
|
||||
depends_on: []
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Дать администратору установки экран со списком организаций установки и действиями над ними.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] Есть утверждённый макет экрана в `design/baseline/`; реализация повторяет его без отступлений
|
||||
- [ ] Экран виден только администратору установки; права проверяет backend
|
||||
- [ ] Администратор установки не получает доступа к данным организации, в которой не состоит; доступ к данным — только через аудируемую support-сессию
|
||||
- [ ] Все тексты — через словарь i18n, без служебной лексики
|
||||
|
||||
## Заметки
|
||||
|
||||
Создание организации из интерфейса уже есть («Добавить организацию» в переключателе, релиз 1.6.0). Экрана управления организациями нет; макета для него тоже нет — без макета не начинать. Экран управления самими администраторами установки в baseline тоже отсутствует и этой задачей не проектируется.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: T-006
|
||||
title: Клиентская часть интеграции voice-to-voice звонков
|
||||
status: todo
|
||||
depends_on: []
|
||||
created: 2026-09-28
|
||||
archived: true
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Реализовать в открытом репозитории интеграцию с закрытым облачным сервисом телефонных звонков с ИИ по API-ключу.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [ ] Интеграция заводится и проверяется в «Настройки → Интеграции»; ключ шифруется как остальные секреты
|
||||
- [ ] Номера телефонов привязываются к агентам
|
||||
- [ ] Приём событий звонка собирает контекст агента (инструкции, знания), обслуживает инструменты, сохраняет транскрипт в диалог контакта и умеет передать разговор оператору
|
||||
- [ ] Без оплаченного ключа интеграция ничего не делает и не ломает остальные функции
|
||||
- [ ] В открытом репозитории нет телефонии, медиа, голосовых моделей, тарифов и учёта минут
|
||||
|
||||
## Заметки
|
||||
|
||||
В коде интеграции пока нет: среди провайдеров интеграций такого типа нет. Документы противоречат друг другу: по концепции продукта и решению об open source клиентская часть живёт в открытом репозитории, а по архитектуре «в открытой сборке кода, экранов и API нет». Принято: клиентская часть — в открытом коде, сам сервис — нет. Перед стартом нужна спецификация API сервиса и макет экранов.
|
||||
@@ -231,7 +231,7 @@ Phone numbers, email addresses and long numeric identifiers are stripped from te
|
||||
<details>
|
||||
<summary><strong>The stack does not start: port 80 or 443 is busy</strong></summary>
|
||||
|
||||
The gateway publishes ports 80 and 443. Free them, or bind the gateway to a specific IP with the `CHATBALLS_WEB_LISTENING_IP` variable.
|
||||
The gateway publishes ports 80 and 443. Free them, bind the gateway to a specific IP with the `CHATBALLS_WEB_LISTENING_IP` variable, or use a [Compose override behind existing nginx](docs/deployment-overrides.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ Telegram, MAX, ВКонтакте, электронная почта и чат
|
||||
<details>
|
||||
<summary><strong>Стек не поднимается: порт 80 или 443 занят</strong></summary>
|
||||
|
||||
Шлюз публикует порты 80 и 443. Освободите их или привяжите шлюз к конкретному IP через переменную `CHATBALLS_WEB_LISTENING_IP`.
|
||||
Шлюз публикует порты 80 и 443. Освободите их, привяжите шлюз к конкретному IP через переменную `CHATBALLS_WEB_LISTENING_IP` или используйте [Compose override за существующим nginx](docs/deployment-overrides.ru.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCurrentLanguage } from "@chatballs/shared";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { foldQuotedHtml, splitQuotedEmail } from "./emailContent";
|
||||
|
||||
beforeEach(() => { setCurrentLanguage("ru"); });
|
||||
|
||||
describe("splitQuotedEmail", () => {
|
||||
it("preserves a message without quoted history", () => {
|
||||
expect(splitQuotedEmail("Первая строка\n\nВторая строка")).toEqual({
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCurrentLanguage } from "@chatballs/shared";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Employee } from "../../types";
|
||||
import { groupsLabel, roleAccessLabel } from "./model";
|
||||
|
||||
beforeEach(() => { setCurrentLanguage("ru"); });
|
||||
|
||||
const baseEmployee: Employee = {
|
||||
id: 1,
|
||||
email: "employee@example.test",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SiteField } from "@chatballs/contracts";
|
||||
import { setCurrentLanguage } from "@chatballs/shared";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fmt } from "../../../../i18n";
|
||||
import { SiteDataSection } from "./SiteDataSection";
|
||||
@@ -11,6 +12,7 @@ function field(type: SiteField["type"], value: SiteField["value"], extra: Partia
|
||||
return { key: type, label: type, type, value, display: String(value), updatedAt, ...extra };
|
||||
}
|
||||
|
||||
beforeEach(() => { setCurrentLanguage("ru"); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("SiteDataSection", () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { setCurrentLanguage } from "@chatballs/shared";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
beforeEach(() => { setCurrentLanguage("ru"); });
|
||||
|
||||
// Единственный подвал со страницами: он же в порталах, статьях, контактах,
|
||||
// сотрудниках, агентах и журнале аудита.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setCurrentLanguage } from "@chatballs/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CallInfo } from "../api";
|
||||
import {
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
resolveCallViewMode,
|
||||
} from "./model";
|
||||
|
||||
beforeEach(() => { setCurrentLanguage("ru"); });
|
||||
|
||||
const call: CallInfo = { callId: "call-1", status: "ACCEPTED", kind: "VIDEO", staffName: "Оператор" };
|
||||
const action = vi.fn();
|
||||
|
||||
|
||||
+7
-7
@@ -17,16 +17,16 @@ services:
|
||||
dockerfile: deploy/docker/postgres.Dockerfile
|
||||
ports:
|
||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||
# Dev держит состояние в рабочем каталоге: базу видно, её легко снести
|
||||
# и легко подсмотреть. В коробке это именованные тома (compose.yaml).
|
||||
# Основная копия сохраняет прежний ./data. Для задач scripts/task-dev.ps1
|
||||
# требует отдельный постоянный каталог вне любых worktree.
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- ${CHATBALLS_DEV_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
- ${CHATBALLS_DEV_DATA_DIR:-./data}/redis:/data
|
||||
|
||||
# Dev: migrate через init, без collectstatic (испечён только в prod-образе).
|
||||
init:
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
CHATBALLS_HELP_PUBLIC_PORT: ""
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./data/media:/app/apps/backend/media
|
||||
- ${CHATBALLS_DEV_DATA_DIR:-./data}/media:/app/apps/backend/media
|
||||
depends_on:
|
||||
init:
|
||||
condition: service_completed_successfully
|
||||
@@ -114,7 +114,7 @@ services:
|
||||
CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./data/media:/app/apps/backend/media
|
||||
- ${CHATBALLS_DEV_DATA_DIR:-./data}/media:/app/apps/backend/media
|
||||
|
||||
worker-events:
|
||||
build:
|
||||
@@ -125,7 +125,7 @@ services:
|
||||
CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./data/media:/app/apps/backend/media
|
||||
- ${CHATBALLS_DEV_DATA_DIR:-./data}/media:/app/apps/backend/media
|
||||
|
||||
# Dev: образ сервиса обновления собирается локально; сам сервис в dev
|
||||
# бесполезен (стек поднят из исходников), но должен собираться и стартовать.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[0922/134554.165:ERROR:gpu\command_buffer\service\shared_image\shared_image_manager.cc:386] SharedImageManager::ProduceMemory: Trying to Produce a Memory representation from a non-existent mailbox.
|
||||
[0922/134659.759:ERROR:gpu\command_buffer\service\shared_image\shared_image_manager.cc:386] SharedImageManager::ProduceMemory: Trying to Produce a Memory representation from a non-existent mailbox.
|
||||
@@ -6,6 +6,7 @@ FROM ${CHATBALLS_UPDATER_BASE_IMAGE}
|
||||
|
||||
COPY deploy/updater/chatballs-updater.sh /usr/local/bin/chatballs-updater.sh
|
||||
COPY deploy/updater/chatballs-updater-apply.sh /usr/local/bin/chatballs-updater-apply.sh
|
||||
COPY deploy/updater/chatballs-updater-compose.sh /usr/local/bin/chatballs-updater-compose.sh
|
||||
RUN chmod 0755 /usr/local/bin/chatballs-updater.sh /usr/local/bin/chatballs-updater-apply.sh
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "/usr/local/bin/chatballs-updater.sh"]
|
||||
@@ -10,11 +10,13 @@ DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
|
||||
REPO="${CHATBALLS_UPDATE_REPO:?}"
|
||||
PROJECT="${CHATBALLS_PROJECT:?}"
|
||||
WORKDIR="${CHATBALLS_WORKDIR:-}"
|
||||
OVERRIDE_FILES="${CHATBALLS_OVERRIDE_FILES:-}"
|
||||
VOLUME="${CHATBALLS_UPDATES_VOLUME:?}"
|
||||
SELF_IMAGE="${CHATBALLS_UPDATER_IMAGE:?}"
|
||||
STATUS="$DIR/status.json"
|
||||
LOG="$DIR/apply.log"
|
||||
FILE="$DIR/compose.$VERSION.yaml"
|
||||
. "$(dirname "$0")/chatballs-updater-compose.sh"
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) apply $VERSION: $*" | tee -a "$LOG"; }
|
||||
|
||||
@@ -47,14 +49,6 @@ if grep -E '^\s*image:\s*ghcr\.io/' "$FILE" | grep -vq "ghcr.io/$REPO/"; then
|
||||
fail "в compose.yaml есть образ из чужого реестра"
|
||||
fi
|
||||
|
||||
compose() {
|
||||
if [ -n "$WORKDIR" ]; then
|
||||
docker compose -p "$PROJECT" --project-directory "$WORKDIR" -f "$FILE" "$@"
|
||||
else
|
||||
docker compose -p "$PROJECT" -f "$FILE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Профиль звонков включён, если coturn уже работает в проекте.
|
||||
PROFILE_ARGS=""
|
||||
if docker ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "label=com.docker.compose.service=coturn" | grep -q .; then
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Первый Compose-файл заменяется новым релизом; остальные — конфигурация
|
||||
# владельца установки. Метка Compose хранит абсолютные пути в порядке применения.
|
||||
|
||||
updater_override_files() {
|
||||
case "${1:-}" in
|
||||
*,*) printf '%s' "${1#*,}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
compose() {
|
||||
compose_with_overrides "${OVERRIDE_FILES:-}" "$@"
|
||||
}
|
||||
|
||||
compose_with_overrides() {
|
||||
if [ -z "$1" ]; then
|
||||
shift
|
||||
if [ -n "$WORKDIR" ]; then
|
||||
docker compose -p "$PROJECT" --project-directory "$WORKDIR" -f "$FILE" "$@"
|
||||
else
|
||||
docker compose -p "$PROJECT" -f "$FILE" "$@"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
# Собираем аргументы с конца: -f сохраняют исходный порядок, а пути с
|
||||
# пробелами остаются одним аргументом. Пустой/пропавший override не игнорируем.
|
||||
override_file="${1##*,}"
|
||||
if [ ! -f "$override_file" ] || [ ! -r "$override_file" ]; then
|
||||
printf 'Compose override is missing or unreadable: %s\n' "$override_file" >&2
|
||||
return 1
|
||||
fi
|
||||
case "$1" in
|
||||
*,*) remaining_files="${1%,*}" ;;
|
||||
*) remaining_files="" ;;
|
||||
esac
|
||||
shift
|
||||
compose_with_overrides "$remaining_files" -f "$override_file" "$@"
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
# работу выполняет отдельный контейнер вне проекта (chatballs-updater-apply.sh):
|
||||
# он переживает пересоздание и дописывает статус в том до конца.
|
||||
set -eu
|
||||
. "$(dirname "$0")/chatballs-updater-compose.sh"
|
||||
|
||||
DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
|
||||
REPO="${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}"
|
||||
@@ -53,6 +54,8 @@ fi
|
||||
SELF_ID="$(cat /etc/hostname)"
|
||||
PROJECT="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' "$SELF_ID" 2>/dev/null || true)"
|
||||
WORKDIR="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$SELF_ID" 2>/dev/null || true)"
|
||||
CONFIG_FILES="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' "$SELF_ID" 2>/dev/null || true)"
|
||||
OVERRIDE_FILES="$(updater_override_files "$CONFIG_FILES")"
|
||||
UPDATES_VOLUME="$(docker inspect -f '{{ range .Mounts }}{{ if eq .Destination "'"$DIR"'" }}{{ .Name }}{{ end }}{{ end }}' "$SELF_ID" 2>/dev/null || true)"
|
||||
SELF_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$SELF_ID" 2>/dev/null || true)"
|
||||
if [ -z "$PROJECT" ] || [ -z "$UPDATES_VOLUME" ] || [ -z "$SELF_IMAGE" ]; then
|
||||
@@ -65,6 +68,40 @@ helper_running() {
|
||||
docker ps -q --filter "name=^chatballs-updater-apply$" | grep -q .
|
||||
}
|
||||
|
||||
start_helper() {
|
||||
set --
|
||||
# Относительные env_file/bind paths и .env должны разрешаться так же, как
|
||||
# при ручном запуске на хосте. Каталог установки доступен только на чтение.
|
||||
if [ -n "$WORKDIR" ]; then
|
||||
set -- "$@" --mount "type=bind,source=$WORKDIR,target=$WORKDIR,readonly"
|
||||
fi
|
||||
remaining="$OVERRIDE_FILES"
|
||||
while [ -n "$remaining" ]; do
|
||||
override="${remaining%%,*}"
|
||||
case "$override" in
|
||||
/*) ;;
|
||||
*) log "не абсолютный путь Compose override: $override"; return 1 ;;
|
||||
esac
|
||||
# --mount, в отличие от -v, не создаёт каталог вместо пропавшего файла.
|
||||
set -- "$@" --mount "type=bind,source=$override,target=$override,readonly"
|
||||
case "$remaining" in
|
||||
*,*) remaining="${remaining#*,}" ;;
|
||||
*) remaining="" ;;
|
||||
esac
|
||||
done
|
||||
docker run -d --rm --name chatballs-updater-apply \
|
||||
--entrypoint /bin/sh \
|
||||
-e CHATBALLS_UPDATE_REPO="$REPO" \
|
||||
-e CHATBALLS_PROJECT="$PROJECT" \
|
||||
-e CHATBALLS_WORKDIR="$WORKDIR" \
|
||||
-e CHATBALLS_OVERRIDE_FILES="$OVERRIDE_FILES" \
|
||||
-e CHATBALLS_UPDATES_VOLUME="$UPDATES_VOLUME" \
|
||||
-e CHATBALLS_UPDATER_IMAGE="$SELF_IMAGE" \
|
||||
-v "$SOCKET:$SOCKET" \
|
||||
-v "$UPDATES_VOLUME:$DIR" \
|
||||
"$@" "$SELF_IMAGE" /usr/local/bin/chatballs-updater-apply.sh "$version" "$expected"
|
||||
}
|
||||
|
||||
while true; do
|
||||
touch "$HEARTBEAT"
|
||||
# Помощник мог исчезнуть, не дописав статус (падение, kill, нехватка памяти).
|
||||
@@ -98,16 +135,7 @@ while true; do
|
||||
# пересоздание этого сервиса и дописывает статус до конца.
|
||||
# Входная точка образа — этот же сценарий, поэтому её обязательно подменить:
|
||||
# иначе помощник вместо установки запустит второй цикл ожидания запросов.
|
||||
if ! docker run -d --rm --name chatballs-updater-apply \
|
||||
--entrypoint /bin/sh \
|
||||
-e CHATBALLS_UPDATE_REPO="$REPO" \
|
||||
-e CHATBALLS_PROJECT="$PROJECT" \
|
||||
-e CHATBALLS_WORKDIR="$WORKDIR" \
|
||||
-e CHATBALLS_UPDATES_VOLUME="$UPDATES_VOLUME" \
|
||||
-e CHATBALLS_UPDATER_IMAGE="$SELF_IMAGE" \
|
||||
-v "$SOCKET:$SOCKET" \
|
||||
-v "$UPDATES_VOLUME:$DIR" \
|
||||
"$SELF_IMAGE" /usr/local/bin/chatballs-updater-apply.sh "$version" "$expected" >/dev/null 2>"$DIR/apply.err"; then
|
||||
if ! start_helper >/dev/null 2>"$DIR/apply.err"; then
|
||||
write_status failed "$version" "could not start helper: $(cat "$DIR/apply.err" 2>/dev/null | tail -c 400)"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Installing behind existing nginx
|
||||
|
||||
If nginx already occupies ports 80 and 443, it can serve the Chatballs HTTPS
|
||||
domain and forward requests to the installation gateway on a local HTTP port.
|
||||
|
||||
Create `compose.override.yaml` next to the release's `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
ports: !override
|
||||
- "127.0.0.1:8443:80"
|
||||
```
|
||||
|
||||
Docker Compose 2.24.4 or later is required. `!override` replaces the entire
|
||||
port list: the original bindings for 80 and 443 are removed. Here 8443 is an
|
||||
internal HTTP port, not an external HTTPS port.
|
||||
|
||||
Start the installation with both files:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.override.yaml up -d --wait
|
||||
```
|
||||
|
||||
The external nginx terminates HTTPS and proxies requests to
|
||||
`http://127.0.0.1:8443`, preserving `Host`, forwarding `X-Forwarded-Proto`,
|
||||
and supporting WebSocket. The external nginx handles the certificate and
|
||||
HTTP → HTTPS redirect. Open the setup wizard on the final HTTPS domain,
|
||||
without the internal port.
|
||||
|
||||
## Updates from the interface
|
||||
|
||||
The updater reads the Compose file list from the running container's labels.
|
||||
It replaces the first file with the new release and preserves additional files
|
||||
in their original order. This includes the standard `compose.override.yaml`
|
||||
and custom filenames explicitly passed with `-f`.
|
||||
|
||||
Apply the override when starting the installation. A file that merely sits
|
||||
next to the main file but is not part of the running project's configuration
|
||||
is not automatically added during an update.
|
||||
|
||||
The installation directory and additional files are mounted read-only in the
|
||||
update helper. This preserves resolution of `.env`, relative `env_file`
|
||||
entries, and configuration paths. Keep override files accessible at the same
|
||||
paths. A missing or invalid override stops the update before services are
|
||||
restarted. The release overwrites only the main `compose.yaml`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Установка за существующим nginx
|
||||
|
||||
Если порты 80 и 443 уже заняты nginx, он может обслуживать HTTPS-домен
|
||||
Chatballs и передавать запросы шлюзу установки по локальному HTTP-порту.
|
||||
|
||||
Создайте рядом с релизным `compose.yaml` файл `compose.override.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
ports: !override
|
||||
- "127.0.0.1:8443:80"
|
||||
```
|
||||
|
||||
Требуется Docker Compose 2.24.4 или новее. `!override` заменяет весь список
|
||||
портов: исходные публикации 80 и 443 не сохраняются. Здесь 8443 — внутренний
|
||||
HTTP-порт, а не внешний HTTPS-порт.
|
||||
|
||||
Запустите установку с обоими файлами:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.override.yaml up -d --wait
|
||||
```
|
||||
|
||||
Внешний nginx завершает HTTPS и проксирует запросы на
|
||||
`http://127.0.0.1:8443`, сохраняя `Host`, передавая `X-Forwarded-Proto`
|
||||
и поддерживая WebSocket. Сертификат и HTTP → HTTPS редирект обслуживает
|
||||
внешний nginx. Открывайте мастер первого запуска по окончательному
|
||||
HTTPS-домену без внутреннего порта.
|
||||
|
||||
## Обновления из интерфейса
|
||||
|
||||
Updater берёт список Compose-файлов из меток работающего контейнера.
|
||||
Первый файл заменяется новым релизом; дополнительные файлы сохраняются
|
||||
в исходном порядке. Поддерживаются стандартный `compose.override.yaml`
|
||||
и файлы с другими именами, явно переданные через `-f`.
|
||||
|
||||
Override должен быть применён при запуске установки. Файл, который просто
|
||||
лежит рядом, но не входит в конфигурацию работающего проекта, при обновлении
|
||||
автоматически не подключается.
|
||||
|
||||
Каталог установки и дополнительные файлы доступны помощнику обновления
|
||||
только на чтение. Это сохраняет разрешение `.env`, относительных `env_file`
|
||||
и путей конфигурации. Файлы override должны оставаться доступными по тем же
|
||||
путям. Пропавший или некорректный override останавливает обновление до
|
||||
перезапуска сервисов. Релиз перезаписывает только основной `compose.yaml`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Постоянное dev-окружение задачи
|
||||
|
||||
Обычная основная копия сохраняет прежние `./data` и Docker secret volumes.
|
||||
`CHATBALLS_DEV_DATA_DIR` в `compose.dev.yaml` позволяет разместить PostgreSQL,
|
||||
Redis и media вне checkout. В production используются прежние именованные тома.
|
||||
|
||||
Для worktree используйте `scripts/task-dev.ps1`. Он требует имя окружения,
|
||||
свободный диапазон из шести портов и абсолютный каталог данных вне всех worktree:
|
||||
|
||||
```powershell
|
||||
./scripts/task-dev.ps1 -Name t-008 -PortBase 18020 -DataDir C:/ChatballsRuntime/t-008
|
||||
```
|
||||
|
||||
Это запуск существующей базы: без `postgres/PG_VERSION` скрипт остановится.
|
||||
Он также проверяет наличие исходных secret volumes проекта `t-008`.
|
||||
Имя Compose нельзя менять при переносе данных: оно определяет секреты инстанса.
|
||||
|
||||
Для новой реальной установки владелец явно выбирает `-InitializeDatabase`.
|
||||
Запуск выполнит штатные миграции; владельца и организацию создают через обычный
|
||||
мастер первого запуска. Скрипт не создаёт пользователей или демонстрационные
|
||||
данные. Новая установка не является восстановлением старой базы.
|
||||
|
||||
Порты: `PortBase` PostgreSQL, `+1` Redis, `+2` backend-app, `+3`
|
||||
backend-platform, `+4` frontend, `+5` web-chat. Frontend использует свой
|
||||
`backend-app` внутри того же Compose-проекта. Скрипт проверяет настоящий
|
||||
`/api/v1/health/ready/` через frontend proxy (БД и Redis).
|
||||
|
||||
## Перенос существующих данных
|
||||
|
||||
1. Запишите исходное имя Compose и mount paths через `docker inspect`.
|
||||
2. Остановите все сервисы, которые пишут в PostgreSQL, Redis и media.
|
||||
3. Сделайте резервную копию и проверьте её; копируйте полный `data`, включая
|
||||
PostgreSQL WAL и служебные файлы, в постоянный каталог. Исходник сохраняйте.
|
||||
4. Сохраните прежнее имя Compose и все три исходных secret volumes. Не используйте
|
||||
`down -v`, не удаляйте тома и не генерируйте новые пароли для существующей базы.
|
||||
5. Задайте `CHATBALLS_DEV_DATA_DIR` и пересоздайте соответствующие сервисы штатным
|
||||
Compose. Проверьте mount paths, readiness и существующие данные/вход.
|
||||
|
||||
Если исходная база исчезла вместе с worktree, требуется её резервная копия.
|
||||
Восстановление только исходников из Git не возвращает БД и media. Не направляйте
|
||||
frontend на другую задачу, чтобы скрыть отсутствие API; общий backend должен
|
||||
быть явно согласованной зависимостью с устойчивыми исходниками и данными.
|
||||
|
||||
`scripts/start.ps1` и `start.sh` предназначены для основной копии, не для
|
||||
одноразовых worktree. Слияние задачи не должно удалять каталог, используемый
|
||||
Docker как source mount.
|
||||
|
||||
## Действующее общее окружение T-008 (29 сентября 2026)
|
||||
|
||||
Frontend T-008 на `http://localhost:5173` явно использует основное окружение:
|
||||
API `http://host.docker.internal:8010`, исходники backend из основной копии,
|
||||
её существующие `data/postgres`, `data/redis`, `data/media` и исходные секреты.
|
||||
Это основная база, а не восстановленная база T-007. Никакие аккаунты и данные
|
||||
для этого подключения не создавались. Авторизованные экраны требуют обычного
|
||||
входа существующим пользователем основной базы.
|
||||
|
||||
Постоянный override вне worktree:
|
||||
`C:/Users/drmar/AppData/Roaming/Skaro/runtime/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/compose.shared.yaml`.
|
||||
Из worktree T-008 frontend запускается так (без запуска собственного пустого backend):
|
||||
|
||||
```powershell
|
||||
$override = 'C:/Users/drmar/AppData/Roaming/Skaro/runtime/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/compose.shared.yaml'
|
||||
$env:INTERNAL_UI_PORT = '5173'
|
||||
docker compose -p t-008 -f compose.yaml -f compose.dev.yaml -f $override up -d --no-deps --no-build frontend
|
||||
```
|
||||
|
||||
Основной frontend отдельно доступен на 5174; Redis основного dev-окружения
|
||||
использует host port 16380. При штатном запуске основной копии задайте
|
||||
`INTERNAL_UI_PORT=5174` и `REDIS_HOST_PORT=16380`. Старый frontend one-off T-008
|
||||
остановлен и сохранён; его ссылка на порт 18010 ведёт в повреждённый T-007.
|
||||
Нельзя его запускать одновременно с новым frontend на 5173.
|
||||
@@ -15,6 +15,9 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Set-Location (Join-Path $PSScriptRoot "..")
|
||||
if (Test-Path -LiteralPath '.git' -PathType Leaf) {
|
||||
throw 'Worktree requires scripts/task-dev.ps1 with persistent data outside the checkout; see docs/dev-task-runtime.md.'
|
||||
}
|
||||
|
||||
$delivery = if ($Mode -eq "SelfHosted") { "SELF_HOSTED" } else { "CLOUD" }
|
||||
Write-Output "Сборка из исходников, режим поставки: $delivery."
|
||||
|
||||
@@ -14,6 +14,11 @@ set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -f .git ]; then
|
||||
echo "Worktree requires persistent CHATBALLS_DEV_DATA_DIR outside the checkout; see docs/dev-task-runtime.md." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mode="CLOUD"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Real Compose environment for a task; no seed users or substitute API.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[a-z0-9][a-z0-9-]*$')]
|
||||
[string] $Name,
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateRange(1024, 65000)]
|
||||
[int] $PortBase,
|
||||
[Parameter(Mandatory)]
|
||||
[string] $DataDir,
|
||||
# This is a new real installation, never recovery of a missing database.
|
||||
[switch] $InitializeDatabase
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if (-not [IO.Path]::IsPathFullyQualified($DataDir)) {
|
||||
throw 'DataDir must be an absolute persistent path outside the checkout.'
|
||||
}
|
||||
$runtimeRoot = [IO.Path]::GetFullPath($DataDir).TrimEnd('\', '/')
|
||||
$worktrees = @(& git -C $projectRoot worktree list --porcelain)
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cannot inspect worktrees; no services started.' }
|
||||
foreach ($line in $worktrees) {
|
||||
if (-not $line.StartsWith('worktree ')) { continue }
|
||||
$checkout = [IO.Path]::GetFullPath($line.Substring(9)).TrimEnd('\', '/')
|
||||
if ($runtimeRoot.Equals($checkout, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$runtimeRoot.StartsWith($checkout + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "DataDir is inside a worktree: $checkout. No services started."
|
||||
}
|
||||
}
|
||||
# Reject junctions/symlinks in the chosen path, so lexical containment cannot hide a worktree.
|
||||
$ancestor = $runtimeRoot
|
||||
while ($ancestor) {
|
||||
if (Test-Path -LiteralPath $ancestor) {
|
||||
$item = Get-Item -LiteralPath $ancestor -Force
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "DataDir has a junction/symlink ancestor: $ancestor. Use its real path."
|
||||
}
|
||||
}
|
||||
$ancestor = [IO.Path]::GetDirectoryName($ancestor)
|
||||
}
|
||||
$databaseMarker = Join-Path $runtimeRoot 'postgres/PG_VERSION'
|
||||
$hasDatabase = Test-Path -LiteralPath $databaseMarker -PathType Leaf
|
||||
if (-not $hasDatabase -and -not $InitializeDatabase) {
|
||||
throw 'Existing PostgreSQL data was not found. Restore data and original secret volumes first; initialization requires an explicit -InitializeDatabase.'
|
||||
}
|
||||
if (-not $hasDatabase -and (Test-Path -LiteralPath $runtimeRoot) -and
|
||||
@(Get-ChildItem -LiteralPath $runtimeRoot -Force).Count -gt 0) {
|
||||
throw 'DataDir is nonempty without PG_VERSION. Refusing to initialize over partial runtime data.'
|
||||
}
|
||||
$composeProject = $Name
|
||||
$compose = @('compose', '-p', $composeProject, '--project-directory', $projectRoot,
|
||||
'-f', (Join-Path $projectRoot 'compose.yaml'), '-f', (Join-Path $projectRoot 'compose.dev.yaml'))
|
||||
# A relocated database must keep the original project name and all original secrets.
|
||||
if ($hasDatabase) {
|
||||
foreach ($suffix in @('chatballs-secrets', 'chatballs-secrets-platform', 'chatballs-secrets-schema')) {
|
||||
& docker volume inspect "${composeProject}_$suffix" --format '{{.Name}}' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Original secret volume is missing: ${composeProject}_$suffix" }
|
||||
}
|
||||
}
|
||||
$values = @{
|
||||
CHATBALLS_DEV_DATA_DIR = $runtimeRoot.Replace('\', '/')
|
||||
POSTGRES_HOST_PORT = [string]$PortBase
|
||||
REDIS_HOST_PORT = [string]($PortBase + 1)
|
||||
BACKEND_APP_PORT = [string]($PortBase + 2)
|
||||
BACKEND_PLATFORM_PORT = [string]($PortBase + 3)
|
||||
INTERNAL_UI_PORT = [string]($PortBase + 4)
|
||||
WEB_CHAT_PORT = [string]($PortBase + 5)
|
||||
}
|
||||
$previous = @{}
|
||||
try {
|
||||
foreach ($key in $values.Keys) {
|
||||
$previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process')
|
||||
[Environment]::SetEnvironmentVariable($key, $values[$key], 'Process')
|
||||
}
|
||||
& docker @compose config --quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Compose validation failed.' }
|
||||
& docker @compose up -d --build --wait postgres redis backend-app backend-platform frontend web-chat worker worker-events
|
||||
if ($LASTEXITCODE -ne 0) { throw 'The real environment did not become healthy. Inspect Compose logs; data was not removed.' }
|
||||
$frontend = "http://localhost:$($PortBase + 4)"
|
||||
$response = $null
|
||||
for ($attempt = 0; $attempt -lt 10; $attempt++) {
|
||||
try {
|
||||
$response = Invoke-RestMethod "$frontend/api/v1/health/ready/" -TimeoutSec 5
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 9) { throw }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
if ($response.status -ne 'ok' -or -not $response.checks.database -or -not $response.checks.redis) {
|
||||
throw 'Frontend API proxy readiness failed.'
|
||||
}
|
||||
Write-Output "Environment: $composeProject; data: $runtimeRoot; frontend: $frontend; API: http://localhost:$($PortBase + 2)"
|
||||
} finally {
|
||||
foreach ($key in $previous.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Обновление применяет реальные Compose overrides к каноническому манифесту.
|
||||
|
||||
Проверяется только `docker compose config`: сервисы и API не запускаются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
LIBRARY = REPO_ROOT / "deploy/updater/chatballs-updater-compose.sh"
|
||||
COMPOSE = REPO_ROOT / "compose.yaml"
|
||||
|
||||
|
||||
def resolve_config(workdir: Path, config_files: list[Path]) -> subprocess.CompletedProcess:
|
||||
env = os.environ | {
|
||||
"PROJECT": "chatballs-override-check",
|
||||
"WORKDIR": str(workdir),
|
||||
"FILE": str(COMPOSE),
|
||||
"CHATBALLS_WEB_LISTENING_IP": "0.0.0.0",
|
||||
}
|
||||
return subprocess.run(
|
||||
[
|
||||
"sh", "-c",
|
||||
'. "$1"; OVERRIDE_FILES="$(updater_override_files "$2")"; '
|
||||
'compose config --format json',
|
||||
"updater-check", str(LIBRARY), ",".join(map(str, config_files)),
|
||||
],
|
||||
env=env, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def gateway_ports(result: subprocess.CompletedProcess) -> list[dict]:
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)["services"]["gateway"]["ports"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("docker"), reason="real Docker Compose is required")
|
||||
def test_update_without_override_keeps_standard_ports(tmp_path: Path) -> None:
|
||||
ports = gateway_ports(resolve_config(tmp_path, [tmp_path / "old-compose.yaml"]))
|
||||
assert {port["published"] for port in ports} == {"80", "443"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("docker"), reason="real Docker Compose is required")
|
||||
def test_update_preserves_override_order_and_paths_with_spaces(tmp_path: Path) -> None:
|
||||
# Первого файла уже может не быть: его заменяет новый релиз.
|
||||
first = tmp_path / "custom ports.yaml"
|
||||
first.write_text(
|
||||
"services:\n"
|
||||
" gateway:\n"
|
||||
" environment:\n"
|
||||
" CHATBALLS_PLATFORM_DOMAIN: platform.pmk-mebel.ru\n"
|
||||
" ports: !override\n"
|
||||
' - "127.0.0.1:18080:80"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
extra_dir = tmp_path / "outside installation"
|
||||
extra_dir.mkdir()
|
||||
last = extra_dir / "production.yaml"
|
||||
last.write_text(
|
||||
'services:\n gateway:\n ports: !override\n - "127.0.0.1:8443:80"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = resolve_config(tmp_path, [tmp_path / "old-compose.yaml", first, last])
|
||||
ports = gateway_ports(result)
|
||||
assert len(ports) == 1
|
||||
assert ports[0]["host_ip"] == "127.0.0.1"
|
||||
assert ports[0]["published"] == "8443"
|
||||
assert ports[0]["target"] == 80
|
||||
gateway = json.loads(result.stdout)["services"]["gateway"]
|
||||
assert gateway["environment"]["CHATBALLS_PLATFORM_DOMAIN"] == "platform.pmk-mebel.ru"
|
||||
|
||||
|
||||
def test_missing_active_override_fails_without_falling_back(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "compose.override.yaml"
|
||||
result = resolve_config(tmp_path, [COMPOSE, missing])
|
||||
assert result.returncode != 0
|
||||
assert str(missing) in result.stderr
|
||||
assert "missing or unreadable" in result.stderr
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("docker"), reason="real Docker Compose is required")
|
||||
def test_update_uses_installation_environment_and_relative_env_file(tmp_path: Path) -> None:
|
||||
(tmp_path / ".env").write_text("PUBLIC_PORT=8443\n", encoding="utf-8")
|
||||
(tmp_path / "gateway.env").write_text(
|
||||
"CHATBALLS_PLATFORM_DOMAIN=platform.pmk-mebel.ru\n", encoding="utf-8",
|
||||
)
|
||||
override = tmp_path / "compose.override.yaml"
|
||||
override.write_text(
|
||||
"services:\n"
|
||||
" gateway:\n"
|
||||
" environment:\n"
|
||||
" CHATBALLS_PLATFORM_DOMAIN: !reset null\n"
|
||||
" env_file: gateway.env\n"
|
||||
" ports: !override\n"
|
||||
' - "127.0.0.1:${PUBLIC_PORT}:80"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = resolve_config(tmp_path, [COMPOSE, override])
|
||||
ports = gateway_ports(result)
|
||||
assert ports[0]["published"] == "8443"
|
||||
gateway = json.loads(result.stdout)["services"]["gateway"]
|
||||
assert gateway["environment"]["CHATBALLS_PLATFORM_DOMAIN"] == "platform.pmk-mebel.ru"
|
||||
Reference in new issue
Block a user