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
- CRM → налаштування бічної панелі → додати посилання.
- URL:
https://crm-calls.volcanic.tes.gd(саме так, без шляху й редіректів: CRM надсилає токен лише на origin цієї адреси). - Назва: «Дзвінки».
- Увімкнути галочку «Передавати авторизацію» (без неї модуль відкриється, але чекатиме авторизацію).
- Відкрити модуль у CRM: має з'явитися ім'я користувача і назва воркспейсу, кнопка «Приєднатися» і гостьове посилання.
Як це працює
- Модуль у 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). Вибір запам'ятовується. - Доступ до воркспейсу сервер перевіряє сам:
shared— усім користувачам CRM,personal:<uid>— лише власнику, інші id — запитомGET /rest/v1/workspaces?id=eq.<id>з токеном користувача (RLS). Інакше403. - Кімната: одна постійна на воркспейс, ім'я
ws-+ 20 випадкових символів[a-z0-9], створюється при першому зверненні, зберігається в SQLite. Id воркспейсу в імені немає. - «Приєднатися»: модуль одразу відкриває нове вікно (
window.openпо кліку, у sandbox CRM єallow-popups), бере в сервера одноразовий код (60 секунд) і веде вікно на/call?code=…. У iframe CRM немає доступу до камери, тому дзвінок іде в окремому вікні. - Вікно
/callодразу прибирає код з адреси, міняє його на сесію вікна (12 годин, уsessionStorage) і по ній бере пропуск Jitsi. Звонок — черезexternal_api.js(new JitsiMeetExternalAPI('meet.volcanic.tes.gd', { roomName, jwt, parentNode })). Токен CRM у вікно не передається взагалі. - Пропуск Jitsi: HS256,
iss = tvorymo-crm,aud = jitsi,sub = meet.volcanic.tes.gd,room— одна кімната, 15 хвилин,context.user = { id, name, email, moderator }. Учасник воркспейсу —moderator: true, гість —false. - Після 15 хвилин розпочатий дзвінок не обривається (пропуск перевіряється лише при підключенні).
Короткий обрив мережі Jitsi переживає сам (відновлення XMPP-сесії). Після довгого обриву (перевірено 90 с) пропуск уже
застарів, prosody відмовляє (
connection.passwordRequired) — вікно саме бере новий пропуск і входить знову без екрана підготовки (так само при фатальних помилках; до 6 спроб із паузою). - Телефон:
/callі гостьова сторінка показують «Відкрити в додатку» і «Продовжити в браузері». «Відкрити в додатку» бере пропуск лише по натисканню й відкриваєorg.jitsi.meet://meet.volcanic.tes.gd/<кімната>?jwt=<пропуск>(тут пропуск неминуче в адресі). Посилання на App Store і Google Play — поруч. Додаток сам новий пропуск не бере: якщо зв'язок зник надовго, людина натискає кнопку ще раз. Власну сторінку Jitsi «завантажте додаток» вимкнено (DISABLE_DEEP_LINKING). - Гостьове посилання
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.
Запис і транскрипція
- Початок дзвінка. Модуль раз на 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). Учасникам нічого натискати не треба, працює і для гостей, і для мобільного додатка. - Кінець дзвінка. Кімната порожня 20 с →
stopService. Тривалість дзвінка рахується за присутністю учасників. - Jibri finalize (
jitsi/finalize.sh, власний образjitsi/jibri.Dockerfile): лише звук (моно, 16 кГц, mp3 32 кбіт/с), відео видаляється одразу, звук ріжеться на частини поCALLS_CHUNK_SECONDS(1200 с ≈ 4,7 МБ, ліміт Whisper 25 МБ),meta.json(тривалість, частини, статистика тиші й гучності) і маркерready. - Обробка в модулі (кроки зберігаються, повтор продовжує з місця зупинки):
- дзвінок коротший за
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 до воркспейсу у вікі → помилка «… не має доступу до воркспейсу …», обходу немає.
- дзвінок коротший за
- Ліміт дзвінків
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)