Opengram: свой Telegram-совместимый мессенджер с нуля — 355 из 781 метода MTProto
781 вызываемый RPC-метод — это весь реальный протокол Telegram, посчитанный рефлексией по tg.Client из gotd/td, открытой Go-библиотеки телеграм-клиента. Библиотека уже знает каждый метод, который способен вызвать настоящий клиент; оставалось только пересчитать. Opengram — self-hosted мессенджер, который говорит этим протоколом по-настоящему, с нуля, на Go: ScyllaDB под хранилище сообщений, рядом отдельный сервис-sequencer, а перед всем стеком — VLESS+Reality-прокси для обхода блокировок.
Каждый из 781 метода живёт в манифесте в одном из трёх состояний: implemented, intentionally_unsupported, planned. Переход между ними без реального теста не засчитывается, и именно поэтому итоговое число нельзя просто вписать в README и забыть о нём. Реализовано пока 355: настоящие Postgres-хендлеры, настоящие e2e-тесты против живой инфраструктуры. SDK для iOS, Android и веба уже встроены в кодовую базу, а сами клиентские приложения поверх них — пока пустой каркас.
#Манифест
Каждое состояние в манифесте требует подтверждения. implemented не принимается без ссылки на конкретный e2e-тест, который это доказывает; intentionally_unsupported — без указанной причины. Без этой дисциплины манифест — просто текстовый файл, которому можно было бы велеть говорить что угодно, поправив одну строку без всякого основания под ней. С дисциплиной каждая строка становится проверяемым утверждением: открываешь нужный файл и видишь либо тест, либо причину. Отдельная CLI-утилита перегенерирует манифест и валидирует его на каждой сборке, роняя её, если манифест устарел или неполон. Для разработчика это красная сборка, которую нельзя смержить, пока запись не приведена в порядок.
Дисциплина не спасает от одной вещи: от того, что статус в манифесте просто забывают обновить. Один прогон, сверявший манифест с кодом реальной диспетчеризации запросов — с тем, что на самом деле решает, какой хендлер обслужит какой вызов, — нашёл 188 методов с рабочими хендлерами. Все они всё ещё значились в манифесте как planned. Причина обычная для любого файла, который обновляют руками отдельно от кода: хендлер подключали к диспетчеру и шли дальше, а строку в манифесте поправить забывали. Урок из этого разбора простой: код диспетчеризации проверяют первым, манифест — вторым, потому что манифест регулярно отстаёт от того, что уже реально работает.
Сам Opengram живёт в отдельном git-репозитории. Однажды плановая регенерация манифеста показала число методов выше ожидаемого. Тем же файлом параллельно занимался независимый процесс, чинивший несвязанные вещи — вход в админку, протокол прокси, деплой-конфиг, — и по пути закрывший ещё 27 методов, которых не было в плане на эту конкретную сессию вообще. Ничего не потерялось: утилита при каждом запуске читает манифест заново с диска и добавляет новые записи поверх уже существующих. Два независимых потока правок легли слоями друг на друга.
#Шифрование
1:1-переписка шифруется по-настоящему: согласование ключей на X3DH, затем Double Ratchet, и оба протестированы end-to-end. У оригинального MTProto есть более старая ветка шифрования, secret chats: отдельная схема на голом Diffie-Hellman, около девяти RPC-методов. В манифесте Opengram все девять размечены как intentionally_unsupported, с зафиксированной причиной прямо в файле — та же манифест-дисциплина, что и везде: без указанной причины запись не пройдёт ту же самую сборку. У 1:1-чатов уже есть рабочее современное шифрование; вторая, более слабая параллельная схема добавила бы к кодовой базе только лишний путь — писать, тестировать, держать без регрессий, — не открывая при этом ни одного нового реального сценария для пользователя. Расширять поверхность атаки ради функциональности, которая уже закрыта лучшим протоколом, смысла нет.
Но в самом Double Ratchet, том самом протоколе, который должен был закрыть вопрос шифрования 1:1-чатов раз и навсегда, есть незакрытая проблема. Один из тестов — round-trip двустороннего ratchet, то есть проверка, что сообщение, прошедшее полный цикл шифрования в обе стороны, приходит целым и проверяемым, — падает. Не через раз. Детерминированно, с ошибкой аутентификации сообщения, и падение воспроизводится при каждом повторном прогоне подряд. Стабильность самого падения — единственная хорошая новость в этой истории: будь ошибка случайной, её можно было бы списать на таймаут или гонку в тестовом окружении. Детерминированный отказ так не объяснить — где-то в самой логике протокола или в его реализации есть настоящее расхождение между тем, что одна сторона диалога зашифровала, и тем, что вторая сторона в состоянии расшифровать и подтвердить. Баг реальный, лежит в криптографическом слое и отмечен как требующий отдельного разбора — не тот случай, где можно просто перезапустить тест и понадеяться, что во второй раз повезёт.
#Каналы и группы
Путь отправки сообщения в Opengram резолвит только одно направление — диалог один на один. Кода, который резолвил бы канал или группу как получателя, пока не существует вообще: не отсутствующая ветка в условии, не заглушка, которая должна вернуть ошибку, — самого маршрута нет.
Порядок здесь обычный для протокол-совместимого проекта: сначала правила, кто где состоит, потом — что сквозь эти правила можно отправить. Снаружи членство в канале и реальная отправка сообщений внутрь него выглядят одной фичей: и то и другое прячется за словом «работает». Изнутри это два разных куска работы с разной судьбой. Членство — создание канала или группы, вход, выход, приглашения, баны, настройки — реализовано и работает целиком. Реальная отправка сообщений внутрь канала или группы пока нет. На практике это значит: канал можно создать, в него можно позвать людей, из него можно выгнать нарушителя, но написать в него сообщение, которое дойдёт до всех участников, пока некому — маршрут для этого типа получателя ещё не существует.
Бан на уровне членства сделан по-настоящему: убирает участника из чата и дополнительно проверяет прямо на пути входа в канал, что забаненный не сможет зайти обратно. Без этой второй проверки бан значил бы только запись в базе — точную, но бесполезную в момент, когда тот же человек стучится в канал снова, если ничто не сверяется с этой записью. Отдельно корректно репортится и промежуточное состояние: участник замьючен, но всё ещё числится в чате, — два разных факта, которые легко перепутать местами при небрежной сериализации.
Первый же прогон тестов на этом поймал реальный баг: один из путей поиска членства вообще не заглядывал в таблицу банов и молча скрывал активное ограничение. Пользователь в такой выдаче выглядел обычным, ничем не ограниченным участником чата, хотя по всем правилам обязан был оставаться забаненным. Тихий баг: не падает, не бросает ошибку, не привлекает к себе внимания — просто отвечает неверными данными. Заметить такое способен только тест, нацеленный именно на статус конкретного забаненного пользователя. Общая проверка «запрос отработал без ошибки» такую подмену не заметит.
#Что в сухом остатке
У REST API — админка, публичная регистрация и всё в этом духе — отдельная бухгалтерия, и она строже манифеста MTProto. На MTProto 355 из 781 ещё можно прочитать как честную промежуточную картину: часть протокола реализована, часть размечена и ждёт своей очереди. У REST так нельзя — здесь нет промежуточного состояния, только полный охват или ошибка в отчёте. 41 операция из 41. Проверяет это тест, который проходит по реальному продакшен-роутеру и сверяет его сразу тремя независимыми способами: с OpenAPI-спекой, с закреплённым в репозитории инвентарным файлом, и вдобавок напрямую парсит исходники каждого хендлера, чтобы отловить любой маркер-заглушку. Три способа нужны потому, что каждый ловит свой класс ошибки: спека может разойтись с кодом, инвентарный файл — устареть точно так же, как отставал манифест MTProto, а хендлер — быть честно подключён к роутеру и при этом внутри остаться заглушкой, которая ничего не делает по существу.
Часть методов из каталожной категории — дефолтные эмодзи-статусы, места под спонсорские сообщения, списки платформенных фич — отвечает пустым результатом окончательно. У самого протокола Telegram для них нет парного метода записи: ни один Telegram-клиент, включая официальный, не может отправить запрос, который заполнил бы эти поля чем-то, кроме пустоты. Пустой ответ Opengram здесь и есть вся реализация, целиком, — ровно тот же ответ, который получил бы клиент и от настоящего Telegram, спроси он о том же самом.
Реальны пока бэкенд и SDK — iOS, Android, веб, уже встроенные в кодовую базу. Клиентские приложения поверх них ещё предстоит написать.