Commit Graph
16 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:13:33 +03:00
AndreyandClaude Opus 5 6f422ac67c 🔒 fix(webchat): виджет открыт миру — origin по заголовкам, лимиты, срок сессии
Публичные endpoint'ы виджета принимают кого угодно без входа и без CSRF, и
до сих пор у них не было ни одного ограничителя. Три дыры разом.

Origin страницы-хозяина брался из поля `hostOrigin` тела запроса — раньше
заголовков. Поле заявляет клиент, поэтому ограничение по доменам виджета
(`allowed_origins`) обходилось строчкой в curl. Теперь решают заголовки,
которые ставит браузер, а `hostOrigin` читается только там, где они указывают
на нас самих: виджет живёт в iframe на нашем же origin, и узнать хозяина
оттуда больше нечем. Что это заявление, а не доказательство, записано в
докстринге `host_origin` — чтобы поверх не построили настоящий контроль.

Лимитов не было вовсе. Каждое сообщение — ход AI по ключу организации
(ingest зовёт провайдера прямо в запросе), каждый файл — место в хранилище:
один скрипт тратил чужие деньги и диск, а поток порождённых событий вдобавок
вытеснял из воркера поллинг мессенджеров. Контуров два. По адресу клиента —
для того, у кого сессии ещё нет; потолки нарочно высокие, потому что за одним
адресом мобильного оператора сидят тысячи живых посетителей. По токену сессии
— строго: за NAT адрес общий, и счёт по нему либо бьёт по соседям, либо
бесполезен. Токен для лимита достаётся из заголовка, а не из тела: иначе
двадцатимегабайтная загрузка доезжала бы до сервера прежде, чем её отобьют.

Сессия не истекала никогда. Токен лежит в localStorage: на общем компьютере
он открывал бы чужую переписку сколько угодно долго. Теперь срок считается от
последней активности (30 суток), отметка обновляется не чаще раза в час —
иначе опрос раз в 2.5 с писал бы в строку сессии четыре раза в минуту.
Виджет на 401 забывает токен и предлагает начать заново, а не висит молча.

Пять тестов. Для главного отдельно проверено, что он ловит прежнее поведение:
со старым `_origin` подделанный `hostOrigin` давал 201 вместо 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:10 +03:00
AndreyandClaude Opus 5 146052916b 🔥 refactor: снос сущности Product и авторизованного in-product чата (ADR-HUB-0045)
`Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но
из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка»,
в списке порталов — колонка «Продукты». Решение владельца — убрать сущность
целиком вместе со всем контуром, который на ней держался.

Удалено:
- приложения `products` и `support` (контракты идентификации, снимки личности,
  Product Support Token, публичные endpoints сессии);
- `Channel.product` и `requires_authenticated_product_identity` (инварианты
  P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`;
- `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна
  анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета;
- `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat;
- capability `products.*`, аудит-действия `products.*`, продукты в карточке
  контакта и в статистике (`byProduct`);
- раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2.

У диалога один источник identity — контакт: XOR заменён на
`conversation_requires_contact`. `PUT /portals/{id}/products/` удалён,
`GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`.

Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений
вычищены от ссылок (чистая установка объектов не создаёт), существующие БД
чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги
со снимком личности не удаляются — каждому создаётся контакт с именем из снимка.

Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и
web-chat OK, миграция прогнана на локальной БД, публичный портал открывается.

В коммит также вошли накопленные незакоммиченные правки рабочего дерева
(библиотека знаний, настройки, dev-compose и nginx, макеты базлайна).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:39:12 +03:00
AndreyandClaude Fable 5.1 2bfe69c80a ✨ feat(chat): AI отвечает на голосовые по стенограмме, голосовые и файлы в портале поддержки, настройка «Голосовые и звонки»
AI: входящее голосовое (TG/MAX/виджет/портал) расшифровывается BYOK-провайдером
при входе, AI отвечает текстом по стенограмме; стенограммы участвуют в истории
контекста. Без расшифровки (провайдер не умеет/недоступен, FAILED) — диалог
оператору с уведомлением, как прежде. Демо-провайдер честно отказывает
(ProviderError) вместо текста-заглушки. Кнопка «Расшифровать» использует тот
же helper (transcribe_voice_message).

Портал поддержки: POST /support/sessions/messages/ принимает multipart audio и
file (+подпись), отдача /messages/<id>/audio|attachment/?credential=; в poll —
kind/hasAudio/durationSeconds/attachment; старт сессии возвращает features.
Виджет support-режима: запись голосового, скрепка, плеер и карточки.

Настройки → «Голосовые и звонки»: матрица точек входа × Голосовые /
Аудиозвонки / Видеозвонки (Integration.voice_messages_enabled,
audio_calls_enabled, video_calls_enabled; integrations/0007;
GET/PATCH company/administration/communication/). Флаги отдаются в
conversation.connection и в config/сессии виджетов: композер прячет микрофон,
карточка контакта — кнопки звонка, бэкенд отклоняет запись/звонок (400/403).
Почта звонки не поддерживает («недоступно»).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:50:32 +03:00
AndreyandClaude Fable 5.1 5be03dd993 ✨ feat(chat): файлы и фото в диалоге — «Прикрепить» в композере, приём из TG/MAX/почты/виджета
Сообщение kind=file с вложением (имя, тип, размер) в тенантном хранилище.
Приём: Telegram (document/photo через getFile), MAX (attachments file/image),
почта (вложения письма вместо заглушки «не поддерживаются»), web-виджет
(multipart file + подпись). Файл без текста уходит оператору, как голосовое.
Отправка оператором: POST /conversations/<id>/attachments/ (файл + подпись,
перехват диалога как у текста) — Telegram sendDocument/sendPhoto, MAX /uploads,
почта MIME-вложение, Web поллингом. Отдача: /messages/<id>/attachment/ (?inline
для фото/PDF) и /webchat/messages/<id>/attachment/ по токену сессии.
Ограничения: 20 МБ, исполняемые типы отклоняются.

UI: кнопка «Прикрепить» (32px, как в макете), чип файла над полем, вставка из
буфера; в ленте — превью фото или карточка файла со ссылкой «Скачать» (.link);
превью в списке «Фото / Файл · имя». Виджет: скрепка в композере, чип, карточки.
email.py: выправлены двойные CR в концах строк.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:04:45 +03:00
AndreyandClaude Fable 5 ce8c753357 ✨ feat(chat): голосовые в web-виджете (дизайн-базлайн v2, кадр H)
Виджет записывает голосовое (MediaRecorder, зеркало хука internal-ui) и
шлёт multipart в /webchat/messages/; входящее байтами — voice_content в
InboundMessage, скачивание не нужно. Аудио отдаётся по токену сессии
(/webchat/messages/<id>/audio/), в пузыре — нативный плеер. Оператор
может отправлять голосовые и в WEB-канал (доставка поллингом виджета).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 01:48:57 +03:00
Andrey f2187f3e83 ✨ feat(webchat): support multiple widget entry points 2026-08-01 21:44:53 +03:00
Andrey bb7ed5e8f9 fix(call): fix audio calls 2026-08-01 00:37:24 +03:00
Andrey c34b181681 🚑 fix(calls): bootstrap call before kind dispatch in web-chat call page
CallApp returned <AudioCallStage> before the bootstrap effect ran: on first
render call was null, so call?.kind !== 'VIDEO' was always true and the audio
scene rendered with empty call/accessToken/iceServers — RTC never started and
the screen stayed idle. The invite banner and the call page also checked kind
inversely (kind !== 'AUDIO' vs kind !== 'VIDEO'), diverging whenever kind was
undefined, so the widget labelled an underdetermined call as video.

- api.ts: single isVideoCall(call) helper (default AUDIO); used by both the
  invite banner and the call page so they agree on the kind.
- CallApp: composition-only dispatcher — bootstrap (invite/access token,
  polling) runs unconditionally, all hooks called, branching only in JSX by
  isVideoCall. No early return from the middle of the hook body.
- VideoCallStage: video path factored out of CallApp (symmetric to
  AudioCallStage), reusing the loaded call/access/iceServers.
- useConnectionTimer: shared connection timer; removes the duplicate from
  AudioCallStage.
2026-07-31 21:35:16 +03:00
Andrey e78eb9f17d ✨ feat(calls): add audio calls alongside video calls
Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.

Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
  public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording

@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
  audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
  AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts

internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
  AudioCallOverlay; kind threaded through model/hook/workspace

web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model

Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
  calls) with explanatory comment
2026-07-31 01:35:29 +03:00
Andrey bba6f17557 feat(calls): Step B 2026-07-12 01:17:17 +03:00
AndreyandClaude Fable 5 bb9fc5d210 feat(calls): контур приглашений P2P-звонков — проход A этапа E09
Пункты 4, 6-8 (частично), 17 PLAN-HUB-0001/E09 по SPEC-HUB-0013:
- timeline-события звонка в диалоге при переходах lifecycle (без дублей)
- API: отмена сотрудником, активный звонок диалога, клиентские
  state/accept/decline по call access token (throttle call_access)
- доставка приглашения TG/MAX через OutboxEvent с ретраями; token
  ротируется на каждую попытку, сырой token не хранится в БД и payload
- Web Chat: приглашение в payload поллинга, баннер входящего звонка,
  accept открывает /calls/ с access token во фрагменте URL
- customer call view /calls/<token>: проверка token до запроса камеры,
  pre-call preview, терминальные состояния по baseline
- internal-ui: кнопка запроса звонка (атомарный takeover), CallOverlay
- sweep воркера: MISSED/EXPIRED по истечению invite, FAILED по grace
  (HUB_CALL_CONNECT_GRACE_SECONDS)
- nginx: /calls/ → web-chat call.html (local + production)

Backend 218/218; typecheck/build internal-ui и web-chat зелёные.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:44:39 +03:00
AndreyandClaude Fable 5 b3e7d9e60e feat(conversations): username клиента и запрос контакта (TG/MAX/Web)
- username из TG/MAX сохраняется в ConnectionIdentity и виден во вкладке «Клиент»
- кнопка «Запросить контакт»: TG/MAX — кнопка «Поделиться контактом» в чате бота,
  Web — форма телефона с маской в виджете; телефон пишется в Contact.phone
- приём контакта без AI-хода: сообщение kind=contact + подтверждение
  (в TG со снятием reply-клавиатуры)
- POST /conversations/<id>/request-contact/ и POST /webchat/contact/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:53:11 +03:00
Andrey aeb7412cf4 feat(web-chat): support mode widget
SPEC-HUB-0010 §7.2: authenticated in-product support chat.

- api.ts: startSupportSession (verify Product Support Token → conversation +
  widget-credential), pollSupport/sendSupport по widget-credential.
- SupportApp.tsx: support-режим виджета — нет consent/lead form, нет полей
  имя/email/purchase; старт по токену один раз; приветствие «Здравствуйте,
  <displayName>»; poll/send через widget-credential; unavailable при истечении
  токена. Переиспользует Bubble/Typing/Header pattern sales-виджета.
- main.tsx: режим по ?mode (sales default, support по data-mode в loader).
- App.tsx (sales) не тронут — минимум риска.
2026-07-09 16:52:01 +03:00
AndreyandClaude Opus 4.8 d79ebda2b7 feat(webchat): embeddable Web Chat widget as a channel connection
Web Chat is a WEB connection of a processing channel (SPEC-0003/ADR-0012),
reusing the whole contour: a public widget loads from the hub domain via one
async tag (<script src=hub.edevs.tech/chat-widget.js data-channel=...>), opens
an isolated iframe panel. Backend webchat app: public config/session/messages
endpoints (anonymous session, hash-at-rest token); inbound goes through the same
ingest (AI release + retrieval + handoff + notifications); WEB transport send is
a no-op (browser polls). Panel rebuilt 1:1 from the baseline (welcome+consent /
ai / operator / unavailable, quick replies, typing). nginx serves /chat-widget.js
and /chat/ from the hub domain; demo host page at /chat/demo.html. Verified
end-to-end: config→session→message→AI reply→poll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:55:00 +03:00