Files
crm-calls/README.md

253 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# crm-calls
Модуль «Дзвінки» для CRM Tvorymo і власний Jitsi Meet з авторизацією через JWT.
| Що | Адреса |
|---|---|
| Модуль (сайт в iframe CRM, вікно дзвінка, гостьова сторінка) | https://crm-calls.volcanic.tes.gd |
| Jitsi Meet | https://meet.volcanic.tes.gd |
| CRM | https://shotam.tvorymo.com (також https://units.tvorymo.com) |
| Supabase CRM | https://yjxbkcqsquwbsyoonfkn.supabase.co |
Розгортання: Coolify Вулканика (`http://coolify.volcanic.tes.gd:8000`), VPS `164.90.233.156` (DigitalOcean), проєкт `crm-calls`
(`fdzlvs0ox6tb58i8ezwpca00`), два застосунки з одного репозиторію:
| Застосунок Coolify | uuid | Compose | Домен (`docker_compose_domains`) |
|---|---|---|---|
| `crm-calls` (Jitsi) | `ibugx0ujnub2pbvtfr3euk9f` | `docker-compose.yml` | `web` → `https://meet.volcanic.tes.gd:8000` |
| `crm-calls-module` (модуль) | `g2mi10g1rcuukibu6yy3tf5u` | `docker-compose.calls.yml` | `calls` → `https://crm-calls.volcanic.tes.gd:8080` |
`:8000` / `:8080` у домені — порт контейнера, куди Traefik веде трафік; публічні адреси — звичайний HTTPS 443.
Застосунки окремі, щоб деплой модуля не перезапускав Jitsi і не обривав дзвінки.
## Як додати модуль у бічну панель CRM
1. CRM → налаштування бічної панелі → додати посилання.
2. URL: `https://crm-calls.volcanic.tes.gd` (саме так, без шляху й редіректів: CRM надсилає токен лише на origin цієї адреси).
3. Назва: «Дзвінки».
4. Увімкнути галочку **«Передавати авторизацію»** (без неї модуль відкриється, але чекатиме авторизацію).
5. Відкрити модуль у CRM: має з'явитися ім'я користувача і назва воркспейсу, кнопка «Приєднатися» і гостьове посилання.
## Як це працює
1. **Модуль у iframe CRM** надсилає `crm:ready`, отримує `crm:auth` (токен Supabase і `workspace: { id, kind, title }`),
сервер перевіряє токен (`exp`, `aud = authenticated`, `GET /auth/v1/user` з publishable-ключем).
Ім'я та email — з відповіді Supabase. Зміна воркспейсу в CRM (`crm:workspace`) підхоплюється одразу.
Якщо CRM не передала воркспейс (`workspace: null` або поля немає), модуль показує список: «Мій», «Загальний»
і воркспейси на людей, де користувач власник або учасник (таблиця `workspaces` з його токеном, RLS). Вибір запам'ятовується.
2. **Доступ до воркспейсу** сервер перевіряє сам: `shared` — усім користувачам CRM, `personal:<uid>` — лише власнику,
інші id — запитом `GET /rest/v1/workspaces?id=eq.<id>` з токеном користувача (RLS). Інакше `403`.
3. **Кімната:** одна постійна на воркспейс, ім'я `ws-` + 20 випадкових символів `[a-z0-9]`, створюється при першому зверненні,
зберігається в SQLite. Id воркспейсу в імені немає.
4. **«Приєднатися»:** модуль одразу відкриває нове вікно (`window.open` по кліку, у sandbox CRM є `allow-popups`),
бере в сервера одноразовий код (60 секунд) і веде вікно на `/call?code=…`. У iframe CRM немає доступу до камери,
тому дзвінок іде в окремому вікні.
5. **Вікно `/call`** одразу прибирає код з адреси, міняє його на **сесію вікна** (12 годин, у `sessionStorage`) і по ній
бере **пропуск Jitsi**. Звонок — через `external_api.js` (`new JitsiMeetExternalAPI('meet.volcanic.tes.gd', { roomName, jwt, parentNode })`).
Токен CRM у вікно не передається взагалі.
6. **Пропуск Jitsi:** HS256, `iss = tvorymo-crm`, `aud = jitsi`, `sub = meet.volcanic.tes.gd`, `room` — одна кімната, 15 хвилин,
`context.user = { id, name, email, moderator }`. Учасник воркспейсу — `moderator: true`, гість — `false`.
7. **Після 15 хвилин** розпочатий дзвінок не обривається (пропуск перевіряється лише при підключенні).
Короткий обрив мережі Jitsi переживає сам (відновлення XMPP-сесії). Після довгого обриву (перевірено 90 с) пропуск уже
застарів, prosody відмовляє (`connection.passwordRequired`) — вікно саме бере новий пропуск і входить знову без екрана
підготовки (так само при фатальних помилках; до 6 спроб із паузою).
8. **Телефон:** `/call` і гостьова сторінка показують «Відкрити в додатку» і «Продовжити в браузері».
«Відкрити в додатку» бере пропуск лише по натисканню й відкриває `org.jitsi.meet://meet.volcanic.tes.gd/<кімната>?jwt=<пропуск>`
(тут пропуск неминуче в адресі). Посилання на App Store і Google Play — поруч. Додаток сам новий пропуск не бере:
якщо зв'язок зник надовго, людина натискає кнопку ще раз. Власну сторінку Jitsi «завантажте додаток» вимкнено (`DISABLE_DEEP_LINKING`).
9. **Гостьове посилання** `https://crm-calls.volcanic.tes.gd/g/<токен>`: постійне, без лобі. Гість вводить ім'я і входить без прав модератора.
Будь-який учасник воркспейсу може згенерувати нове (підтвердження своїм вікном, `confirm()` в iframe не працює):
старе одразу перестає пускати нових гостей, ті, хто вже в дзвінку, лишаються. Токен посилання = HMAC(`GUEST_LINK_SECRET`, id посилання);
у базі — id, SHA-256 токена, хто й коли згенерував.
### Jitsi (`docker-compose.yml`)
`ENABLE_AUTH=1`, `AUTH_TYPE=jwt`, `ENABLE_GUESTS=0`, `JWT_APP_ID=tvorymo-crm`, `JWT_ACCEPTED_ISSUERS=tvorymo-crm`,
`JWT_ACCEPTED_AUDIENCES=jitsi`, `JWT_APP_SECRET` (env Coolify), `XMPP_MUC_MODULES=token_affiliation` (модератор з пропуску),
`JICOFO_ENABLE_AUTH=0` і `ENABLE_AUTO_OWNER=false` (інакше jicofo робить модератором кожного з токеном, гостей теж),
`DISABLE_DEEP_LINKING=true`, `CSP_HEADER: frame-ancestors https://crm-calls.volcanic.tes.gd` (вбудовувати Jitsi може лише модуль).
Пам'ять Java: `VIDEOBRIDGE_MAX_MEMORY=512m`, `JICOFO_MAX_MEMORY=256m` (типово було 3072m — більше за весь сервер).
**Лише голос (рішення власника 2026-10-15).** `jitsi/custom-config.js` дописується в `config.js` сервера (власний образ
`jitsi/web.Dockerfile`), тож діє і в браузері, і в мобільному додатку Jitsi Meet (він бере `config.js` з нашого сервера):
`startAudioOnly`, без кнопок камери й демонстрації екрана, без гарячих клавіш (`V`/`D`). У пропуску JWT вимкнено
`screen-sharing`, `recording`, `livestreaming` (`context.features`). Камеру в мобільному додатку повністю заборонити
з сервера не можна: додаток стартує в режимі «лише звук», але кнопку камери у своєму меню може показати.
### Заголовки модуля
`Content-Security-Policy: frame-ancestors <CRM_ORIGINS>; script-src 'self' https://meet.volcanic.tes.gd; frame-src https://meet.volcanic.tes.gd; …`
(без `X-Frame-Options`), `Referrer-Policy: no-referrer`, `Permissions-Policy` дозволяє камеру й мікрофон собі й Jitsi.
## Запис і транскрипція
1. **Початок дзвінка.** Модуль раз на 5 с питає prosody (`mod_muc_size`, `GET http://jitsi-prosody:5280/room`), хто в кімнатах,
де видавали пропуски за останні 13 год. Коли в кімнаті ≥ `RECORD_MIN_PARTICIPANTS` (2) учасників, модуль запускає
Jibri через його HTTP API (`POST http://jitsi-jibri:2222/jibri/api/v1.0/startService`, запис у файл, прихований користувач
`recorder`). Учасникам нічого натискати не треба, працює і для гостей, і для мобільного додатка.
2. **Кінець дзвінка.** Кімната порожня 20 с → `stopService`. Тривалість дзвінка рахується за присутністю учасників.
3. **Jibri finalize** (`jitsi/finalize.sh`, власний образ `jitsi/jibri.Dockerfile`): лише звук (моно, 16 кГц, mp3 32 кбіт/с),
**відео видаляється одразу**, звук ріжеться на частини по `CALLS_CHUNK_SECONDS` (1200 с ≈ 4,7 МБ, ліміт Whisper 25 МБ),
`meta.json` (тривалість, частини, статистика тиші й гучності) і маркер `ready`.
4. **Обробка в модулі** (кроки зберігаються, повтор продовжує з місця зупинки):
- дзвінок коротший за `MIN_CALL_SECONDS` → файли видаляються, сторінки немає;
- немає `OPENAI_API_KEY` або `CRM_USER_EMAIL`/`CRM_USER_PASSWORD` → запис чекає на диску (статус `waiting`), назовні нічого не йде;
- Whisper (`OPENAI_TRANSCRIBE_MODEL`, `verbose_json`, сегменти; мова — автоматично) частина за частиною, тайміги
кожної частини зсуваються на тривалість попередніх; результат — `transcript.json`;
- вхід користувача CRM у Supabase (password grant) → аудіо в Storage: `<RECORDINGS_BUCKET>/<RECORDINGS_PREFIX>YYYY/MM/<випадкові 24 hex>/dzvinok-YYYY-MM-DD-HHMM.mp3`,
посилання — публічне (`/storage/v1/object/public/…`, як вкладення CRM у бакеті `files`);
- сторінка у вікі воркспейсу дзвінка, папка `WIKI_CALLS_FOLDER` у корені (створюється, якщо немає), від того самого користувача CRM:
«Дзвінок YYYY-MM-DD HH:MM» (Europe/Warsaw), дата й час, тривалість, учасники (гості з позначкою «гість»), посилання на аудіо,
транскрипція рядками `[гг:хх:сс] текст`;
- **лише після** завантаження аудіо і створення сторінки локальні файли видаляються.
- Помилка будь-якого кроку → файли лишаються, повтор через 1, 2, 5, 10, 30, потім щогодини; текст помилки — у журналі модуля
(Coolify → Logs) і в `GET /api/admin/calls`. Немає доступу користувача CRM до воркспейсу у вікі → помилка
«… не має доступу до воркспейсу …», обходу немає.
5. **Ліміт дзвінків** `MAX_CONCURRENT_CALLS` (1): дзвінок зайнятий, поки в кімнаті є учасники (плюс бронь кімнати на 2 хв після
видачі пропуску). У іншу кімнату пропуск не видається: модуль і вікно дзвінка показують «Зараз уже йде інший дзвінок,
спробуйте пізніше». У дзвінок, що вже йде, входити можна. Jibri — один екземпляр (не менше за ліміт).
Модуль і Jitsi — різні застосунки Coolify; модуль бачить `jitsi-prosody` і `jitsi-jibri` у спільній docker-мережі
`crm-calls-bridge` (її створює застосунок Jitsi). Файли записів — спільний каталог хоста `/data/crm-calls/recordings`
(модуль при старті віддає його uid 1000 — користувачу `s6` у Jibri). HTTP API Jibri слухає `0.0.0.0:2222` лише всередині docker-мереж, назовні порт не відкрито.
### Ресурси (VPS 2 vCPU / 3,8 ГБ + своп 4 ГБ, тест 2026-10-15, звонок 8 хв, 2–3 учасники, запис)
| | Простій | Під час запису |
|---|---|---|
| Jibri | 190 МБ (поки Chrome «прогрівається» — до 760 МБ) | 730–780 МБ, CPU у середньому 24 % (старт запису — до 100 % на кілька секунд) |
| JVB | 165 МБ | 255 МБ, 12 % |
| jicofo / prosody / web / модуль | 145 / 32 / 15 / 30 МБ | 152 / 38 / 17 / 40–60 МБ |
| Хост: доступно пам'яті | ≈ 1,8–2,0 ГБ | 1,33–1,62 ГБ (у середньому 1,57) |
| Хост: своп | 150–230 МБ | 245–255 МБ, не росте |
Якість запису (тест: два учасники з тонами 440 і 660 Гц): жодних провалів ≥ 0,3 с, крім задуманих (3,5 хв без мікрофонів і
15 с після виходу), обидва голоси однаково чутні, втрат пакетів 0, джитер 2–3 мс. Запис не обривається, коли всі мовчать або
вимкнули мікрофони (таймаути Jibri «немає медіа» / «всі без мікрофонів» підняті до 4 год).
## Змінні оточення (Coolify)
Секрети — **лише** в env Coolify, у репозиторії їх немає.
**Модуль (`g2mi10g1rcuukibu6yy3tf5u`):**
| Змінна | Що це | Значення / за замовчуванням |
|---|---|---|
| `JWT_APP_SECRET` | секрет підпису пропусків Jitsi, **такий самий, як у Jitsi** | секрет |
| `GUEST_LINK_SECRET` | секрет токенів гостьових посилань; зміна робить усі гостьові посилання недійсними | секрет |
| `SUPABASE_PUBLISHABLE_KEY` | publishable-ключ Supabase CRM (публічний за дизайном) | `sb_publishable_…` |
| `CRM_ORIGINS` | origin CRM через пробіл або кому: звідки приймати `crm:auth`, вони ж у `frame-ancestors` | `https://shotam.tvorymo.com,https://units.tvorymo.com` |
| `CRM_ASSETS_ORIGIN` | звідки стилі CRM (файли від кореня: `/css/tvorymo-ui.css`, `/css/crm.css`) | `https://shotam.tvorymo.com` |
| `SUPABASE_URL` | | `https://yjxbkcqsquwbsyoonfkn.supabase.co` |
| `JITSI_DOMAIN` | | `meet.volcanic.tes.gd` |
| `JITSI_TOKEN_TTL_MINUTES` | строк пропуску | `15` |
| `CALLS_SESSION_HOURS` | скільки живе сесія вікна дзвінка / гостя | `12` |
| `CALLS_PUBLIC_URL` | | `https://crm-calls.volcanic.tes.gd` |
| `OPENAI_API_KEY` | ключ OpenAI для Whisper; без нього записи чекають | **задає власник**, секрет |
| `OPENAI_TRANSCRIBE_MODEL` | модель; потрібні сегменти з таймінгами (`verbose_json`) | `whisper-1` |
| `CRM_USER_EMAIL`, `CRM_USER_PASSWORD` | користувач CRM, від якого завантаження аудіо і сторінки; має бути учасником воркспейсів | **задає власник**, пароль — секрет |
| `RECORDINGS_BUCKET` | бакет Supabase Storage | `files` |
| `RECORDINGS_PREFIX` | папка в бакеті | `calls/` |
| `WIKI_URL` | вікі CRM | `https://wiki-crm.tes.gd` |
| `WIKI_CALLS_FOLDER` | папка у вікі | `Дзвінки` |
| `MIN_CALL_SECONDS` | коротші дзвінки не обробляються | `60` |
| `MAX_CONCURRENT_CALLS` | одночасних дзвінків (Jibri — один) | `1` |
| `RECORD_MIN_PARTICIPANTS` | з якої кількості учасників починається дзвінок і запис | `2` |
| `RECORDING_ENABLED` | `false` — вимкнути запис (ліміт дзвінків лишається) | `true` |
| `JIBRI_RECORDER_PASSWORD` | пароль прихованого користувача recorder, **такий самий, як у Jitsi** | секрет |
| `ADMIN_TOKEN` | службові `/api/admin/*`; порожньо — вимкнені | секрет |
Новий домен CRM додається в `CRM_ORIGINS` (і `CRM_ASSETS_ORIGIN`, якщо стилі звідти) → Redeploy. Посилання на стилі в `calls/frontend/index.html` теж вказують на `https://shotam.tvorymo.com/css/…`.
**Jitsi (`ibugx0ujnub2pbvtfr3euk9f`):** `JWT_APP_SECRET` (секрет, той самий), `JICOFO_AUTH_PASSWORD`, `JVB_AUTH_PASSWORD`,
`JIBRI_RECORDER_PASSWORD`, `JIBRI_XMPP_PASSWORD` (секрети), `PUBLIC_URL=https://meet.volcanic.tes.gd`, `JVB_PORT=10000`,
`JVB_ADVERTISE_IPS=164.90.233.156`, `TZ`, `DEFAULT_LANGUAGE`, `VIDEOBRIDGE_MAX_MEMORY` (512m), `JICOFO_MAX_MEMORY` (256m),
`CALLS_AUDIO_BITRATE` (32k), `CALLS_CHUNK_SECONDS` (1200).
Заміна `JWT_APP_SECRET`: однакове нове значення в обох застосунках → Redeploy обох.
## REST API модуля
| Метод | Шлях | Авторизація | Що робить |
|---|---|---|---|
| `GET` | `/health` | — | `{ status, dbCreatedAt }` |
| `GET` | `/api/config` | — | `{ crmOrigins, meetDomain, publicUrl, appStoreUrl, playStoreUrl }` |
| `POST` | `/api/verify` | — | `{ access_token }` → `{ ok, id, email, name, exp }` |
| `GET` | `/api/workspaces` | токен CRM | воркспейси користувача (для вибору) |
| `GET` | `/api/room` | токен CRM + `X-Calls-Workspace` | `{ workspace, guestLink: { url, updatedAt, updatedByName } }` |
| `POST` | `/api/room/guest-link` | токен CRM + `X-Calls-Workspace` | нове гостьове посилання |
| `POST` | `/api/join` | токен CRM + `X-Calls-Workspace` | `{ code, expiresIn: 60, url }` — одноразовий код входу |
| `POST` | `/api/call/exchange` | — | `{ code }` → `{ session, kind, name }`; код одноразовий (`410`, якщо використаний/застарів) |
| `POST` | `/api/call/pass` | — | `{ session }` → `{ jwt, exp, room, domain, name, kind }`; `401` — сесія завершилась |
| `GET` | `/api/guest/{token}` | — | `200` — посилання дійсне, `404` — ні |
| `POST` | `/api/guest/{token}/join` | — | `{ name }` → `{ session, kind: "guest", name }` |
| `POST` | `/api/admin/test-link` | `X-Admin-Token` | `{ workspace: "test-…", regenerate? }` → гостьове посилання тестового воркспейсу (не CRM); записи таких дзвінків не публікуються, файли видаляються |
| `GET` | `/api/admin/calls` | `X-Admin-Token` | останні 30 дзвінків: стан обробки (`recording`, `ended`, `waiting`, `error`, `done`, `skipped`), помилка, учасники |
`409` на `/api/join` і `/api/call/pass` — «Зараз уже йде інший дзвінок» (`MAX_CONCURRENT_CALLS`).
Токен CRM: `Authorization: Bearer <access_token>`. Помилки: `{ "error": "…" }`, `401` — токен не прийнято, `403` — немає доступу до воркспейсу.
Дані: SQLite `/data/calls.db` на томі `calls-data` (таблиці `rooms`, `guest_links`, `join_codes`, `call_sessions`, `pass_log`, `calls`; секрети — лише хешами).
## Відомі обмеження
- `JitsiMeetExternalAPI` сам кладе пропуск у адресу вкладеного iframe Jitsi (`?jwt=…`). В адресному рядку й історії вікна його немає
(там `/call`), але він потрапляє в адресу запиту до `meet.volcanic.tes.gd` (і в журнал nginx Jitsi). Пропуск — на одну кімнату й 15 хвилин.
- Вікно дзвінка, відкрите з iframe CRM, успадковує його sandbox: `alert`/`confirm` там не працюють (у коді їх немає).
- Сесія вікна дзвінка живе 12 годин і не перевіряє повторно членство у воркспейсі: людина, яку прибрали з воркспейсу, може
перепідключитися до кінця сесії. Новий вхід з CRM уже не пройде.
- Гості, які увійшли за старим посиланням, після перегенерації лишаються в дзвінку й можуть перепідключатися до кінця своєї сесії.
- Учасник, який пробув у дзвінку менше ніж ~5 с (період опитування), може не потрапити в список учасників на сторінці.
- Коли в дзвінок заходить третій учасник, Jitsi переходить з P2P на JVB: у цей момент можливі кілька секунд «замаскованого» звуку.
- Передеплой застосунку Jitsi перезапускає всі його контейнери — дзвінки, що йдуть, обриваються.
## Мережа
VPS має публічну адресу `164.90.233.156` безпосередньо на інтерфейсі (без NAT), тому
`JVB_ADVERTISE_IPS=164.90.233.156`.
| Порт | Протокол | Куди | Навіщо |
|--------------|----------|--------------------------|--------|
| 443 (і 80) | TCP | Traefik → `web:8000` | веб, `/xmpp-websocket` |
| 10000 | **UDP** | `jvb` (опублікований на хості) | медіа і SCTP-канал до JVB |
UDP 10000 має бути дозволений і в ufw/iptables на VPS, і в DigitalOcean Cloud Firewall (якщо він
прив'язаний до droplet). Порт, опублікований Docker, зазвичай проходить повз ufw, але не повз Cloud Firewall.
Без UDP 10000 сторінка й кімната відкриваються, але аудіо/відео через JVB не буде.
Перевірка: під час дзвінка в `chrome://webrtc-internals` вибрана ICE-пара має вести на
`164.90.233.156:10000` (udp). Або на VPS: `sudo tcpdump -ni any udp port 10000` і ззовні
`echo test | nc -u -w1 164.90.233.156 10000`.
## TURN (не робимо — рішення власника 2026-10-14; довідка на майбутнє, `turn.volcanic.tes.gd`)
Потрібен клієнтам, у яких заблокований UDP (корпоративні мережі, частина мобільних операторів).
DNS `turn.volcanic.tes.gd` уже веде на `164.90.233.156` (wildcard).
**Варіант 1 — класичний coturn (найкраща якість).** Відкрити у файрволі VPS / DO:
`3478` UDP+TCP, `5349` TCP (TURNS), relay-діапазон напр. `49160-49200` UDP.
coturn: `use-auth-secret`, `static-auth-secret=<секрет у Coolify env>`, `realm=crm-calls.volcanic.tes.gd`,
`external-ip=164.90.233.156`, `min-port`/`max-port` = relay-діапазон, TLS-сертифікат для
`turn.volcanic.tes.gd` (5349), `denied-peer-ip` для приватних мереж.
**Варіант 2 — TURNS на 443 через Traefik (нових портів не треба).** coturn слухає TCP 3478 лише у
внутрішній мережі; TCP-роутер Traefik `HostSNI(`turn.volcanic.tes.gd`)` на entrypoint `https` з TLS
(letsencrypt) → `coturn:3478`. coturn ретранслює медіа на JVB усередині хоста. Працює там, де дозволений
лише TCP 443; якість нижча (медіа по TCP).
В обох варіантах Jitsi (env prosody, через Coolify): `TURN_CREDENTIALS=<той самий секрет>`,
`TURNS_HOST=turn.volcanic.tes.gd`, `TURNS_PORT=5349` або `443`, для варіанту 1 також
`TURN_HOST=turn.volcanic.tes.gd`, `TURN_PORT=3478`, `TURN_TRANSPORT=udp,tcp`.
Prosody (`mod_external_services`) роздає клієнтам тимчасові TURN-облікові дані.
## Розробка
```sh
cd calls/backend/Calls.Api && Jitsi__AppSecret=<32+ символи> Calls__LinkSecret=<32+ символи> \
ASPNETCORE_URLS=http://127.0.0.1:5090 dotnet run # база в ./data
cd calls/frontend && npm i && npm run dev # Vite, /api → 127.0.0.1:5090
dotnet test calls/backend/Calls.Api.Tests # кімнати, коди, пропуски, гостьові посилання, доступ до воркспейсу (фальшивий Supabase)
```