Files
crm-calls/README.md

26 KiB
Raw Blame History

crm-calls

Модуль «Дзвінки» для CRM Tvorymo і власний Jitsi Meet з авторизацією через JWT.

Що Адреса
Модуль (сайт в iframe CRM, вікно дзвінка, гостьова сторінка) https://crm-calls.volcanic.tes.gd
Jitsi Meet https://meet.volcanic.tes.gd
CRM (тестова) https://test.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://test.units.tvorymo.com
CRM_ASSETS_ORIGIN звідки стилі CRM https://test.units.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="https://test.units.tvorymo.com https://units.tvorymo.com" (і CRM_ASSETS_ORIGIN, якщо стилі звідти) → Redeploy.

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-облікові дані.

Розробка

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)