# 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:` — лише власнику, інші id — запитом `GET /rest/v1/workspaces?id=eq.` з токеном користувача (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 ; 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: `/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 `. Помилки: `{ "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) ```