← На главную

Как мы собрали корпоративный мессенджер на Matrix: инженерный дневник

Важно: все домены, IP-адреса, имена пользователей и название организации в этой статье вымышлены или обобщены. Реальные секреты (токены, пароли, внутренние адреса) в статью не попали ни в каком виде.

Зачем вообще собственный мессенджер

Задача звучала просто: создать ВКС с jitsi и чатом, нарисовать красивый интерфейс в корп стиле.

Решение - написать собственный тонкий веб-клиент поверх matrix-js-sdk, который разговаривает с уже существующим Synapse-сервером и Jitsi-сервером, но выглядит и ведёт себя как часть внутреннего портала компании.

Ниже - подробный, местами болезненный дневник того, как это строилось: от каркаса на Vite до продакшена с реальными пользователями и очень поучительных багов.

Архитектура в двух словах

Система состоит из трёх независимых сервисов за одним обратным прокси:


Браузер сотрудника │ ▼ reverse-proxy (nginx) --> SSO (OIDC) --> Keycloak-подобный identity provider │ ├── /app/* -> статика React-приложения (веб-клиент) ├── /app/api/* -> Node.js "мост авторизации" (auth-bridge) ├── /_matrix/* -> Synapse (сервер Matrix) └── /external_api.js, /app/call/* -> Jitsi Meet

Веб-клиент - React 19 + Vite + TypeScript + matrix-js-sdk. Полностью статическая сборка, раздаётся простым Node-сервером.

Мост авторизации - небольшой Node.js-сервис. Его единственная задача: превратить корпоративную SSO-сессию сотрудника в Matrix access-токен, не заставляя сотрудника логиниться ещё раз и не выдавая ему пароль от Matrix-аккаунта.

Synapse - обычный self-hosted сервер Matrix, шифрование на нём отключено осознанно (внутренний контур, не требуется).

Jitsi Meet - отдельный сервер видеоконференций, клиент грузит его external_api.js напрямую с этого сервера, а не из npm-пакета.

Ключевая архитектурная особенность, которая потом аукнется несколько раз в этой статье: мост авторизации минтит (выпускает) Matrix-токены через административный API Synapse (POST /_synapse/admin/v1/users/<id>/login) - это позволяет войти «от имени» сотрудника, не зная его пароль и не проводя отдельный вход в Matrix. У этого решения есть побочный эффект: такой токен не привязан ни к какому реальному устройству на сервере. Это всплывёт в разделе про шифрование.

Технологический стек

Фронтенд (portal-web)

Компонент Версия/детали
Язык TypeScript ~6.0
UI-фреймворк React 19.2 + react-dom
Роутинг react-router 8.3 (не react-router-dom)
Сборщик Vite 8.2 (обычный Rollup-бандлер, без Rolldown)
Matrix-клиент matrix-js-sdk 42.3
Крипто-модуль SDK @matrix-org/matrix-sdk-crypto-wasm 18.8 (обязательная инициализация SDK; само E2E-шифрование выключено)
Иконки lucide-react 1.43
Эмодзи-пикер emoji-picker-react 4.20
Стили CSS Modules + один файл CSS-переменных темы, prefers-color-scheme
Состояние без стейт-менеджера - React-хуки поверх событий MatrixClient
Тесты Vitest 5.0
Линтер oxlint 1.79
Раздача статики собственный Node.js-сервер (server.mjs), без веб-фреймворка

Бэкенд-сервисы

Все - Node.js без npm-зависимостей, только встроенные модули: fetch, fs, crypto, http.

  • matrix-auth-bridge - мост Keycloak-сессия -> Matrix access-токен, фоновая доставка отложенных сообщений
  • jwt-minter - выдача модераторского JWT для Jitsi
  • portal-web/server.mjs - раздача собранного фронтенда

Рантайм - статический бинарник Node.js 22.14 (доставлен архивом, сервер без интернета).

Мессенджер-бэкенд (Matrix)

Компонент Версия/детали
Homeserver Synapse 1.159.0 (Docker, matrixdotorg/synapse:latest)
БД PostgreSQL 16 (alpine)
Федерация выключена (federation_domain_whitelist: [])
Шифрование выключено осознанно

Видеоконференции и SSO

docker-jitsi-meet (unstable-образы): web, jicofo, prosody (XMPP), JVB. Jibri/Jigasi не развёрнуты.

Identity provider - Keycloak (OIDC, confidential-клиент), шлюз перед бэкендами - oauth2-proxy v7.15.4.

Инфраструктура

Компонент Версия/детали
ОС Debian 12 (bookworm)
Реверс-прокси nginx 1.22.1
Контейнеризация Docker + Docker Compose (для Matrix и Jitsi; собственные Node-сервисы - как systemd-юниты, без Docker)
Process supervisor systemd (4 юнита)
WAF Wallarm-агент (режим monitoring)
Развёртывание сборка в песочнице с интернетом (прод-сервер без исходящего интернета) -> архив по scp/rsync

Стадия 0: катастрофа с исходниками и урок про git

Первая версия клиента существовала только в виде собранного dist/ на проде и во временной рабочей папке на машине разработчика - исходники никогда не коммитились в git. Машина ушла в перезагрузку, временная папка была в /tmp и не пережила рестарт. Исходники потерялись безвозвратно.

К счастью, сам собранный dist/ уцелел и был отдельно забэкаплен. Пересборка велась по детально задокументированному поведению и сверке с живым бандлом на проде - там нашлась как минимум одна незадокументированная, но реально работающая фича (фон чата), под неё уже существовали серверные эндпоинты.

Урок, осознанно заложенный в процесс с этого момента: git-репозиторий создаётся ПЕРВЫМ действием, раньше даже npm create vite@latest. Коммит - после каждого логически законченного куска работы, а не «в конце этапа».

mkdir -p ~/project && cd ~/project
git init
npm create vite@latest . -- --template react-ts
git add -A && git commit -m "chore: scaffold vite react-ts template"

Технологические решения, зафиксированные с самого начала

  • React 19 и react-router (актуальный путь импорта, а не устаревающий react-router-dom).
  • matrix-js-sdk плюс явная зависимость на WASM-модуль крипто-библиотеки SDK (обязательная часть инициализации клиента - это не значит, что сообщения шифруются; на этом сервере шифрование отключено на уровне конфигурации).
  • Обычный Rollup-бандлер Vite, единственная нестандартная настройка - base: '/app/' (приложение живёт под путём /app/, рядом с остальными сервисами портала).
  • Без Redux/Zustand/react-query: всё состояние живёт в объекте MatrixClient и React-хуках поверх его событий - идиоматичный подход для matrix-js-sdk, второй слой стейт-менеджмента только добавил бы сложности.
  • CSS Modules по компонентам + один файл с CSS-переменными темы, поддержка светлой/тёмной темы через prefers-color-scheme с ручным оверрайдом в localStorage.

Этапы разработки

Разработка велась пошагово, с остановкой и проверкой после каждого этапа - не пытаясь сразу написать всё приложение целиком.

Этап 0 - каркас. Инициализация Matrix-клиента из ответа GET /app/api/session (браузер никогда не обращается к identity provider напрямую - всю OIDC-логику делает бэкенд), пустой каркас с боковой навигацией и таблица маршрутов. Отдельно, вне общего каркаса - маршрут для экрана видеозвонка: ему нужна высота на весь экран (100vh), а не flex: 1 внутри общего лейаута, и это стоило закладывать сразу, а не чинить потом.

Этап 1 - личные чаты. Список чатов, окно переписки, текстовые сообщения. Сразу правильно, а не патчем потом:

  • Поиск уже существующего личного чата проверяет оба статуса участия - не только «уже состою», но и «приглашён, но ещё не принял» - иначе поиск плодил дубликаты переписки с одним и тем же человеком.
  • Автопринятие приглашений подключается сразу при создании Matrix-клиента, до того как какой-либо код успеет искать существующие чаты - иначе появлялось состояние гонки.

Этап 2 - группы, вложения, редактирование. Важное архитектурное решение: любое медиа на основе mxc://-ссылок получает URL только через один-единственный авторизованный helper, который скачивает файл с Bearer-токеном и превращает в blob-URL. Обычный <img src="mxc://..."> не работает вообще (сервер отдаёт медиа только по авторизованному эндпоинту - тихий отказ без видимой ошибки), а если решить эту проблему один раз для аватарок и не закрепить как правило, она надёжно возвращается позже - во вложениях, в фоне чата, где угодно.

Редактирование сообщений - через relation m.replace, удаление - через redactEvent.

Этап 3 - реакции, ответы, упоминания, опросы, голосовые, фон чата. Несколько намеренно нестандартных решений:

  • Упоминания участников и опросы реализованы через собственные, не-стандартные типы событий/полей контента, а не через существующие MSC-предложения - сознательный выбор в пользу простоты и предсказуемости над соответствием черновым, ещё не стабилизировавшимся стандартам.
  • Фон чата: изображение ресайзится на клиенте через <canvas> (без дополнительной библиотеки, около 30 строк), ссылка на него кладётся в состояние комнаты отдельным кастомным типом события.
  • Голосовые сообщения - через MediaRecorder.

Этап 4 - звонки. Кнопка создания видеоконференции транслитерирует название в URL-слаг, экран звонка встроен через JitsiMeetExternalAPI, который грузится скриптом прямо с сервера конференций, а не из npm-пакета - так гарантированно используется совместимая с сервером версия. Решение из Этапа 0 про отдельный маршрут вне общего лейаута окупилось: 100vh на видеозвонке ни разу не пришлось чинить постфактум.

Этап 5 - статус прочтения. Протокол Matrix даёт только «участник X прочитал вплоть до события E», а не булев флаг «это конкретное сообщение прочитано». Понадобился отдельный компаратор позиции двух событий в таймлайне:

function isReadByUser(room, userId, messageEventId): boolean {
  const readUpTo = room.getEventReadUpTo(userId)
  if (!readUpTo) return false
  const cmp = compareEventPosition(room, readUpTo, messageEventId)
  return cmp !== null && cmp >= 0
}

В личном чате - одна/две галочки на своём сообщении. В групповом - «прочитано X из Y», по клику открывается список кто и когда прочитал.

Важная деталь дизайна: это отдельный хук от логики разделителя «непрочитанные сообщения». Разделитель фиксирует свою позицию прочтения один раз при открытии чата, до отправки своего read receipt. Статус прочтения читает чужие receipt-ы непрерывно, пока чат открыт. Смешивание этих двух хуков в один почти наверняка сломало бы порядок операций одного из них при последующей правке другого.

Этап 6 - профиль, контакты, уведомления, брендинг. Профиль пользователя, справочник контактов, браузерные уведомления, финальный проход по всем текстам через файл конфигурации бренда. Полный smoke-тест перед первым реальным деплоем: загрузка приложения, переключение темы, отсутствие дублей личных чатов, аватарки и медиа, все типы сообщений и реакции, опрос с несколькими голосами, фон чата, три сценария входа в звонок, статус прочтения в обоих видах чатов, финальная сборка даёт ожидаемый набор файлов.

Продакшен: что оказалось не так просто, как в теории

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

«Пропадают старые сообщения при каждом деплое»

После каждого обновления фронтенда (hard-refresh страницы) в старых чатах видно только последние несколько сообщений. Причина оказалась двойной:

  • matrix-js-sdk по умолчанию агрессивно обрезает старые события из таймлайна в памяти по мере поступления новых - это не баг синхронизации и не удаление на сервере, просто клиент их больше не хранит и не рисует. Лечится одним флагом клиента: timelineSupport: true.
  • Начальная синхронизация (initialSyncLimit) при каждой свежей загрузке подгружает только «хвост» каждого чата, а явной подгрузки истории назад изначально не было. Добавили: при прокрутке к верху ленты вызывается paginateEventTimeline({ backwards: true }), с компенсацией позиции скролла, чтобы подгрузка не «подбрасывала» пользователя.

«От шифрования сыплются ошибки в консоли»

Cannot enable encryption on MatrixClient with unknown deviceId и следом поток POST /keys/upload 400. Корень - архитектурное решение из вводной части: мост авторизации логинит пользователя через административный API сервера, который не создаёт устройство на сервере. Любой deviceId, который придумает клиент, серверу неизвестен - попытка инициализировать крипто-стек и опубликовать ключи устройства закономерно проваливается. Поскольку шифрование в этой инсталляции выключено на уровне конфигурации сервера - решение было просто не инициализировать крипто-модуль SDK вообще. Обычные, нешифрованные комнаты работают штатно.

«Своё сообщение отображается как чужое»

У одного конкретного пользователя иногда своё же сообщение в ленте выглядело отправленным другим человеком. Причина: мост авторизации сам вычисляет Matrix ID пользователя из заголовков, которые присылает SSO-прокси, не сверяя результат с сервером. Если формат этих заголовков чуть отличается между заходами (регистр, порядок источника claim'а), мост может вычислить немного другой ID, чем тот, под которым отправлялись прошлые сообщения - и client.getUserId() перестаёт совпадать с event.getSender() у собственных же сообщений.

Фикс - при создании клиента дополнительно вызывать client.whoami() и, если сервер называет другой ID, доверять именно ему:

const whoami = await client.whoami()
if (whoami.user_id && whoami.user_id !== client.credentials.userId) {
  client.credentials.userId = whoami.user_id
}

«Фон чата не защищён от удаления через три дня»

У функции фона чата была задумана защита от автоматической очистки медиа (у сервера есть политика удаления неиспользуемых файлов через несколько дней). Административный эндпоинт «защитить медиа от удаления» стабильно возвращал 404 - оказалось, что в установленной версии Synapse такого административного метода попросту не существует (проверено прямым запросом к серверу, не связано с правами токена). Решение - не изобретать обходной путь, а принять, что фон чата живёт как любой обычный файл и может быть вычищен политикой хранения через несколько дней.

«Отложенные сообщения» - от клиентского костыля к серверной реализации

Функцию попросили в формате «выбрать дату/время и отправить сообщение позже, не проваливаясь в сам чат». Первая реализация была честно предупреждена как временная и заменена на настоящую серверную задачу:

  • Перед любым изменением бэкенда - резервная копия.
  • В хранилище (обычный JSON-файл на диске бэкенда) складываются отложенные сообщения: кто, куда, что, когда отправить.
  • Фоновый таймер каждые N секунд проверяет, не наступило ли время очередного сообщения, и отправляет его от имени автора (тем же приёмом «логин через административный API»).
  • У пользователя, который запланировал сообщение, в интерфейсе появляется значок часов со счётчиком - сколько отложенных сообщений ждут отправки в этом чате, и возможность отменить любое из них.

На этом шаге всплыл поучительный, почти комичный баг: systemd-юнит бэкенд-сервиса запущен в песочнице (ProtectSystem=strict), и каталогу, куда исходно писался JSON-файл с отложенными сообщениями, разрешена только чтение - юнит специально разрешает запись лишь в один конкретный каталог данных. Новая фича по умолчанию писала не туда, куда нужно, и падала с EROFS: read-only file system. Исправлено сменой пути по умолчанию на разрешённый каталог, без единой правки самого systemd-юнита или файла с секретами.

Одиссея с автоскроллом ленты сообщений

Самая многократно переписываемая логика за весь проект - хороший пример того, что «очевидное» поведение чата на самом деле состоит из нескольких конфликтующих требований:

  1. Сначала - безусловная прокрутка вниз при любом изменении ленты. Сразу выявило проблему: при чтении старой истории новое сообщение выдёргивало пользователя обратно вниз.
  2. Попытка сделать «умную» прокрутку - вниз, только если пользователь и так был у низа ленты. Реализация оказалась сырой и была отклонена.
  3. Возврат к безусловной прокрутке как временной мере.
  4. Прокрутка к «разделителю непрочитанных» при открытии чата вместо жёсткого «в самый низ».
  5. Прямое требование убрать автоматическую прокрутку при новом сообщении полностью - чат не должен никуда выдёргивать, пока читаешь историю.

После этого удаления выяснилось: без него ломается самое первое открытие только что созданного чата (в момент переключения комнаты лента ещё пустая, scrollHeight равен нулю - прокручивать пока некуда, а как только сообщения подгружаются мгновением позже, повторно это уже никто не проверяет). Решение - эффект, пересматривающий условие при каждом изменении числа сообщений, пока не встанет успешно один раз, и затем - никогда больше принудительно для этой открытой комнаты.

Отдельно обнаружилось и было исправлено: прокрутка к «разделителю непрочитанных» иногда визуально ощущалась как «чат открылся в старых сообщениях» - если этот разделитель указывал на сообщение, уже подгруженное из более ранней истории, заметно выше самого низа. Финальное решение: разделитель остаётся только визуальной меткой, но не управляет позицией скролла - чат при открытии всегда идёт в самый низ, к новым сообщениям.

Финальный виток - логами в браузере доказано, что после отключения пункта 5 чат переставал докручиваться и при новых сообщениях, пока пользователь и так стоял внизу и просто смотрел на живую переписку - то есть требование «не выдёргивать при чтении истории» по ошибке реализовали как «не докручивать вообще никогда». Итоговая логика: если пользователь сам проскроллил вверх (читает историю) - новое сообщение ленту не трогает; если он и так был у низа - новое сообщение подтягивает вниз. Отличать эти два случая пришлось через постоянно поддерживаемый флаг «у низа ли пользователь прямо сейчас», обновляемый и по событию скролла, и по факту изменения числа сообщений.

Детективная история со счётчиком непрочитанных

Пользователи стабильно жаловались: бейдж с числом непрочитанных сообщений либо не появляется вовсе, либо появляется и тут же гаснет, причём именно в момент системного уведомления о новом сообщении.

Разбирательство заняло несколько итераций, каждая опровергала предыдущую гипотезу:

  1. Гонка между событием «пришло новое сообщение» и обновлением серверного счётчика. Проверка исходников SDK эту гипотезу опровергла: сервер присылает актуальный счётчик раньше, чем обрабатываются сами сообщения.
  2. Окно чата слишком охотно отправляет отметку «прочитано» даже при неактивной вкладке. Гипотеза оказалась верной лишь частично - реальным багом, но не тем, что описывали пользователи (чат вообще не был открыт в момент, когда пропадал счётчик).
  3. Добавлено прямое логирование: каждый вызов «отметить прочитанным» с полным стеком вызова, и каждое изменение серверного счётчика с меткой времени.
  4. Логи показали чистую картину: счётчик скачет 1 → 0 в течение сотни миллисекунд, без единого вызова «отметить прочитанным» в этой вкладке. Значит, где-то есть вторая забытая открытая сессия того же пользователя (другая вкладка, другое устройство), в которой этот самый чат уже открыт и прокручен вниз - она и отмечает каждое новое сообщение прочитанным почти мгновенно.

После того как пользователи закрыли лишние вкладки и параллельные сессии - счётчик заработал корректно.

Методологический урок: первые две гипотезы были логически безупречны, подкреплены чтением исходного кода библиотеки - и обе оказались либо неверны, либо неполны. Только прямое логирование в продакшене дало однозначный ответ. Гадать по описанию бага дальше двух-трёх итераций не имело смысла.

Отдельная деталь для будущих себя: диагностический console.debug() в Chrome DevTools относится к уровню «Verbose», который скрыт фильтром консоли по умолчанию - первая попытка логирования «ничего не показала» именно поэтому, а не потому что код не сработал. Для временной отладки в продакшене надёжнее console.warn().

Что осталось в архитектуре как осознанные компромиссы

  • Кастомные, не-MSC типы событий для упоминаний, опросов, фона чата, аватарок групп - вместо экспериментальных, ещё не стабилизировавшихся предложений по расширению протокола.
  • Нет шифрования - осознанное решение уровня инфраструктуры (замкнутый внутренний контур), а не недостаток реализации.
  • Один системный процесс на бэкенд - Node.js без очередей и брокеров сообщений, с простым файлом на диске под отложенные задачи. Для внутреннего инструмента с некритичным объёмом это оправданная простота, а не технический долг.
  • Единая точка бренд-конфигурации - избавляет от риска «забыли заменить название компании в одном из полусотни файлов» при каждой публикации в открытый репозиторий.

Главные практические выводы

  • Git - с первого коммита, без исключений. Потеря исходников из-за забытой временной папки - единственная причина, по которой этот проект вообще пришлось переписывать с нуля.
  • Закладывать структурные решения сразу, а не патчить постфактум - отдельный маршрут для полноэкранного звонка, единый helper для авторизованной загрузки медиа, разделение хуков «своя» и «чужая» позиция прочтения - каждое из этих решений, принятое на старте, избавило от болезненной правки позже.
  • Бэкап перед любой серверной правкой, диагностика перед любой серверной гипотезой. Административные эндпоинты сервера стоит проверять прямым запросом, а не доверять документации версии, которая может не совпадать с реально установленной.
  • Когда третья подряд гипотеза по логам не подтверждается - прекратить гадать и добавить логирование.
  • «Очевидное» поведение чата требует явно сформулированных, отдельных правил под разные ситуации («читаю историю» против «слежу за живой перепиской») - попытка описать это одним общим эффектом раз за разом давала регрессию то в одну, то в другую сторону.
Источник и код:
Оригинал статьи: habr.com/ru/articles/1082044
Репозиторий проекта: github.com/skvorezvictor/go-servise-vks-chats

💬 Обсуждение и вопросы

Комментарии и обсуждение — на интерактивной версии статьи.

Перейти к обсуждению →