~/potatohd.ruEN
← Все статьи
12 июля 2026·9 мин

Concord: свой Discord-совместимый сервер с нуля — REST, Gateway и голос

GoDiscord APIWebRTCagents

services/concord говорит на настоящем языке Discord: REST API v9/v10 для запросов, Gateway v10 для потока событий в реальном времени, Voice Gateway v8 с SFU (selective forwarding unit) для голосовых звонков. Это clean-room реализация wire-протокола с прицелом на совместимость на уровне байтов с настоящими Discord-клиентами — независимый сервер, отвечающий так же, как ответил бы настоящий Discord, для той же экосистемы клиентов. Среди опен-сорсных Discord-клиентов встречается вариант, который сам по себе ничего не реализует: Electron-обёртка вокруг настоящего сайта Discord, привязанная к нему захардкоженным списком хостов. Когда протокол настоящий, репойнтинг существующего клиента на такой сервер — это правка конфига. У Concord есть сиблинг — платформа под протокол Telegram, собранная раньше на той же инфраструктуре: chi, pgx, структурный логгер, Postgres с Valkey, тот же деплой, ни строчки общего кода.

#REST, Gateway и голос

REST-слой закрывает обычные операции — гильдии, каналы, сообщения, пользователей, — версии v9 и v10 держатся живыми бок о бок, потому что настоящий Discord и сам держит несколько версий API одновременно, а клиент сам решает, к какой подключиться. У протокола свои условности: Discord отвечает 200 почти на любой успешный запрос, включая создание нового ресурса. REST-конвенцию с 201 Created протокол нигде не использует. Мелочь. Она ещё аукнется.

Gateway v10 — realtime-половина: одно постоянное соединение на клиента, по которому события приходят сами, в тот момент, когда происходят. Клиенту не приходится их выпрашивать опросом.

Голос стоит на своём сигнальном канале, Voice Gateway v8, а сам звук идёт через SFU — selective forwarding unit. SFU не глухой ретранслятор пакетов: каждый входящий зашифрованный пакет он расшифровывает ровно один раз, держит открытый звук в памяти ровно на то время, что нужно для пересылки, и заново шифрует его отдельно под ключ каждого получателя.

#Дизайн-решения

Вход идёт по Resource Owner Password Credentials — прямому обмену логина и пароля на токен, в обход обычного OAuth-редиректа на страницу провайдера и обратно. Причина в самом протоколе: POST /auth/login у настоящего Discord синхронный, принимает логин и пароль в теле запроса и сразу отдаёт токен в том же ответе, без колбэка и промежуточной страницы. ROPC — единственный грант в OAuth2, который структурно соответствует такому обмену, даже если для типичного стороннего клиента этот паттерн считается небезопасным: сырой пароль пользователя попадает прямо в руки клиентского приложения, а именно этого большинство OAuth2-потоков стараются избежать. Для локальной разработки и CI есть dev-флаг, пропускающий identity-provider целиком, с фиксированным dev-логином и паролем.

Хранилище сообщений — плагин: Postgres или ScyllaDB, по умолчанию Scylla, ровно наоборот от сиблинга под Telegram, где дефолт — Postgres, а Scylla-бэкенд есть в коде, но не задеплоен. У сиблинга есть отдельный сервис-аллокатор, который выдаёт монотонно растущие id для порядка сообщений. Concord обходится без него: id сообщений в Discord — снежинки, с меткой времени внутри самого числа, так что сортировка по id уже сортирует по времени, и никакой координации между сервисами для этого не нужно.

Биллинг перенесли из клиента сиблинга и сделали fail-closed: если сервис биллинга недоступен или не настроен, пользователь остаётся с пустым дефолтным тарифом. Премиум молча никому не включается.

#Как это писалось

Пять параллельных ИИ-агентов вели пять вертикальных срезов одновременно: auth и пользователи, гильдии, каналы и сообщения на Scylla, realtime-gateway, голос и SFU. Первую попытку оборвали на середине — постороннюю сессию прервали ещё до того, как появился хоть один файл, — так что второй запуск стартовал с нуля.

Границы файлов между потоками почти не пересекались; трение вышло только в общем internal-пакете с хелперами, где случилось несколько мелких коллизий имён. Разрешилось само: сборку гоняли по ходу работы, конфликты правили на месте, без ручной координации с моей стороны.

#Семь багов на живом стеке

Юнит-тесты по всем пакетам были зелёными задолго до того, как против кода подняли настоящий стек: Postgres, Scylla, Valkey, api, gateway, voice, всё сразу и по-настоящему. e2e против этого стека нашёл семь багов. Ни один не показался в юнит-тестах: мокам не с чем было спорить.

Первый — та самая мелочь про коды ответа. Discord отвечает 200 почти на любой успешный запрос, включая создание нового ресурса; REST-конвенцию с 201 Created протокол не использует нигде. Это нигде явно не прописали в общих инструкциях, и все пять независимых частей кода угадывали по-своему — угадали одинаково неправильно везде: код возвращал не то в девяти местах в двух файлах, и вскрылось это только когда e2e стал сверять реальные ответы с реальным протоколом.

Второй жил в одном-единственном JOIN-запросе, где колонка с одинаковым именем нашлась в обеих участвующих таблицах. Компилировалось. Проходило ревью. Работало на моках. Postgres, получив тот же запрос против настоящей схемы, отверг его как неоднозначный — двусмысленность, которую не видно нигде, кроме настоящего SQL-планировщика с настоящей схемой перед глазами.

Ещё два бага — одна и та же ошибка пути Docker build-контекста, повторённая дважды: COPY в Dockerfile и поле dockerfile: в compose предполагали не тот корень сборки. Тот же класс бага уже был задокументирован в сиблинг-проекте под Telegram — и всё равно повторился здесь, в другом коде, тем же способом.

#Токен, который никто не сохранил

По всем формальным признакам голос был готов. Пакет gateway проходил свои тесты. Пакет voice проходил свои. Сборка была зелёной, vet чистым, покрытие — как у всего остального кода. На бумаге оставалось просто включить и слушать.

Звонки не работали. Не через раз и не с одним пограничным багом на периферии — не работали вообще, от первого шага до последнего. При подключении к голосовому каналу realtime-gateway генерирует токен доступа: клиент должен предъявить его отдельному voice-сервису, чтобы тот пустил его в звонок. Токен генерировался честно. Просто нигде не сохранялся.

Обработчик подключения на стороне voice делает ровно то, что должен: проверяет входящий токен, ищет строку в базе, которую gateway обязан был туда записать в момент выдачи токена. Строки не было. Записи не было никогда, ни при каких обстоятельствах, ни разу — в коде просто не существовало операции, которая должна была её создать.

Ни один из двух наборов юнит-тестов не мог поймать это в принципе, и дело не в качестве тестов. Тесты gateway мокают всё, что не gateway. Тесты voice мокают всё, что не voice. Баг жил ровно в шве между ними, в передаче данных, которую ни один набор тестов не видит, — там, где каждый сервис просто верил, что свою часть контракта выполнил другой. Нашли это после прямого вопроса: работает ли это на самом деле, целиком, от щелчка «позвонить» до звука в наушниках. Зелёных юнит-тестов для ответа «да» не хватило. Починили добавлением недостающей записи в базу в момент выдачи токена и регрессионным тестом, который теперь явно проверяет порядок: запись раньше, чем токен уходит клиенту.

#Два участника, одно и то же аудио

Регрессионный тест на порядок операций закрыл сам баг, но ничего не говорил о том, действительно ли SFU пересылает звук правильно, когда на звонке двое живых людей одновременно. Готового инструмента, который говорит этим wire-форматом, не существовало, так что тестовый клиент на двух участников написали с нуля: сырой websocket для сигналинга, UDP для самого медиа, AES-256-GCM побайтово в том же формате, что и настоящий сервер.

У каждого участника свой ключ шифрования, никак не связанный с ключом другого. Участник 1 отправляет пакет, зашифрованный своим ключом. SFU получает его, расшифровывает ровно один раз и держит открытый звук в памяти не дольше, чем нужно для пересылки. Участнику 2 пакет уходит заново зашифрованным — уже под его собственный ключ. Участник 2 расшифровывает пришедшее своим ключом и получает байт-в-байт то же самое аудио, которое участник 1 отправил на входе.

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

#Что в сухом остатке

Локально всё зелёное: сборка, vet и тесты по 14 пакетам, без единой гонки данных. Против живого стека — 36 из 36 REST-тестов, 6 из 6 websocket-тестов gateway, 11 из 11 голосовых тестов на двух живых участниках.

Открыто не всё. Сквозное шифрование голоса, протокол DAVE, не реализовано: SFU по-прежнему видит медиа в незашифрованном виде на своей стороне, есть только транспортный уровень шифрования между клиентом и сервером. Вебхуки заведены по маршрутам, но пока отвечают «не реализовано». Поиск по сообщениям отдаёт пустой результат — полнотекстового индекса нет ни у одного из двух бэкендов хранения. Путь голосового подключения через WebRTC для браузера и нативных клиентов сейчас всегда отклоняет соединение: форвардинг медиа в SFU существует и протестирован изолированно, просто не подключён к этому пути — сначала нужно захватить рукопожатие настоящего клиента и подтвердить точный формат.

// свободен для заказов

Нужен сайт или настройка под ключ?