Когда то давно я покупал GoIP для обучения телефонии на своём железе, также на свой сервер поставил FreePBX. Вокруг них за полтора года выросли три репозитория, про которые я всё собирался рассказать и так и не собрался. Два дописывались под магистерскую (защитился кстати на красный диплом), третий уже после защиты, когда понадобился софтфон в Telegram. Пишу одним постом, чтобы не растягивать на три.

Что получилось:

  • SMSNodeBackend, сервер для GSM-шлюзов. Приём и отправка SMS, очередь с троттлингом и автоповторами, REST API, Telegram-бот.

  • SMSNode, клиент к нему на Kotlin Multiplatform. Одна кодовая база на Android, iOS, Windows, macOS, Linux и браузер.

  • SIPgram, шлюз между FreePBX и Telegram. Внутренние номера АТС звонят обычными голосовыми звонками Telegram.

Общее у них то, что железо и сервер свои, за сообщение и за минуту никто не платит, и ни переписка, ни звонки не уезжают к третьей стороне.


Часть первая: SMS

Что было до

GSM-шлюз из коробки умеет немного. У GoIP есть веб-морда и штатный GoIP Manager под Windows, и на этом всё: ни API для своих систем, ни ролей, ни истории в нормальной базе, ни клиента на телефоне. Gammu и SMSTools работают с модемами через AT-команды, про многоканальный шлюз ничего не знают, клиента и API у них нет. Облачные провайдеры задачу решают, но берут за каждое сообщение и держат переписку у себя, а если симки корпоративные и уже оплачены, платить ещё и за отправку странно.

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

Сервер

Python, asyncio, FastAPI, SQLAlchemy, PostgreSQL, бот на aiogram. Около девяти тысяч строк.

Адаптер шлюза спрятан за базовым классом с методами get_statusget_port_statussend_smssend_ussdreboot. Реализовано три: GoIP по UDP, GoIP по HTTP и Skyline. Менеджер поднимает из базы все активные шлюзы при старте и держит их в памяти.

Входящие приходят тремя разными путями, и это не от хорошей жизни:

  1. UDP. Шлюз сам шлёт пакеты на сервер. Основной путь, он же самый быстрый.

  2. HTTP. Сервер опрашивает веб-интерфейс шлюза. Для устройств, где UDP-клиент не настроен или не поднимается.

  3. SMTP. У GoIP есть функция SMS to Email, и бэкенд поднимает собственный SMTP-сервер на aiosmtpd, чтобы эти письма ловить. Выглядит дико, но на части прошивок это оказался единственный стабильно работающий путь.

Протокол GoIP

Самое интересное в проекте это протокол. Он текстовый, по UDP, и довольно своеобразный.

Шлюз держит keepalive, по одному на канал:

req:1;id:1001;pass:secret;num:+79991112233;signal:24;gsm_status:LOGINreg:1;status:200

reg:1;status:200

id вида 1001-1008 это канал: тысяча плюс номер порта.

Отправка разбита на четыре пакета, каждый со своим ответом:

MSG 42 11 Hello worldPASSWORD 42 secretSEND 42 1 +79991234567OK 42 1DONE 42

PASSWORD 42 secret

SEND 42 1 +79991234567

OK 42 1

DONE 42

Приём с подтверждением:

RECEIVE:7;id:1001;password:secret;srcnum:+79991112233;msg:ТестRECEIVE 7 OK

RECEIVE 7 OK

И тут первая засада. Номер собственной симки в пакете не передаётся вообще. srcnum это внешний абонент, а про то, какая из восьми симок приняла сообщение, есть только id канала. То есть порт вычисляется как id - 1000, а само устройство приходится определять по source IP пакета.

Пока сервер и шлюз в одной сети, всё сходится. Когда я перенёс бэкенд в контейнер на удалённой машине, source IP перестал совпадать с host в базе, шлюз не находился, и входящие ложились с пустым sim_card_id: в Telegram уведомление приходило, а в приложении сообщение не показывалось, потому что там фильтрация идёт по цепочке sim_card -> assigned_user. Лечится тем, что в host надо писать не адрес «как в локалке», а тот, с которого пакеты реально доходят до сервера. Логично задним числом, но час я на это потратил.

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

Очередь

Слать в шлюз параллельно нельзя: канал один, и если сыпать без пауз, устройство начинает рвать соединение. Поэтому исходящие идут через одну asyncio.Queue на 500 задач, с троттлингом по паре (шлюз, порт) и автоповторами. Интервал настраивается глобально и переопределяется для конкретного канала.

Цифры с нагрузочных испытаний (VPS 4 vCPU, 8 ГБ):

Режим

SMS/мин

Что происходит

без троттлинга

72

шлюз начинает сбрасывать соединение

интервал 0.5 с

58

стабильно

интервал 1 с

54

рабочий режим по умолчанию

5 портов параллельно

260

проблем также не замечено

По API на отправку: 45 мс среднего при 10 параллельных отправителях, 245 мс при 100, а на 200 очередь забивается до потолка и появляются таймауты. Честная граница текущей реализации это примерно сотня параллельных отправителей. Лечится заменой внутрипроцессной очереди на брокер, но для дома и небольшой конторы это не потребовалось.

Что ещё внутри

Ролевая модель: админ раздаёт симки пользователям, пользователь видит только свои диалоги. Контакты и группы контактов, групповая рассылка, шаблоны сообщений, в том числе глобальные от админа. Правила на входящие: вебхук на внешний URL по ключевому слову и автоответчик. Три режима регистрации (открытый, закрытый, по заявкам). Журнал событий шлюза: keepalive, состояние GSM, соты, записи звонков.

Клиент

Kotlin Multiplatform плюс Compose Multiplatform, около семи тысяч строк. Android, iOS, Windows, macOS, Linux и WasmJS в браузере из одной кодовой базы.

Платформенного кода вышло на удивление мало, и он весь про хранение и сеть: TokenStorageCredentialsStorageServerUrlStorageRecentMessagesStorage и фабрика Ktor-клиента. Всё остальное, включая ViewModel и весь интерфейс, лежит в commonMain. Адаптивная вёрстка: на узких экранах нижняя навигация, на широких боковая панель.

Реалтайма нет, стоит периодический опрос. WebSocket через Ktor заводится и в вебе, и на мобилках, но при пяти платформах и фоновом сервисе на Android опрос оказался дешевле в поддержке. Цена известна и измерена: на сотне запросов в секунду PostgreSQL начинает шуршать диском, так что при полусотне активных клиентов интервал имеет смысл поднимать до 5-10 секунд.

Приложение доехало до сторов: Google Play, RuStore, App Store, Microsoft Store, Mac App Store, плюс deb/rpm/msi на гитхабе и веб-версия. Про прохождение ревью в пяти сторах подряд можно писать отдельный пост, но думаю никому интересно не будет. Отдельно пришлось сделать демо-режим: при входе с адресом-заглушкой вместо сети поднимается MockEngine со встроенным набором данных. Без него ревьюеру нечего смотреть, у него нет ни шлюза, ни симок.

Где это сейчас

Работает у меня дома, лежит на гитхабе. Активно развивать пока не планирую: задача решена, диплом защищён. В планах записано то, что упиралось в границы: замена внутрипроцессной очереди на RabbitMQ или Redis Streams, push вместо опроса, балансировка по портам, адаптеры под Dinstar и USB-модемы через pyserial. Если будет интерес, возьмусь.


Часть вторая: звонки

Задача

АТС дома есть, внутренние номера есть, а таскать софтфон на телефоне не хочется: он ест батарею, отваливается от пушей и требует отдельного приложения, в которое надо заходить, ещё и на iOS они в большинстве своём проблемные, даже платный Grandstream не всегда принимает звонки в фоне. Хотелось, чтобы звонок на внутренний номер приходил обычным звонком Telegram, в тот же Telegram, который и так открыт.

Готовое на рынке ровно одно: sip.tg. Работает, но сервис платный, живёт непонятно где, ваши SIP-креды при этом уезжают к ним (их worker-серверы регистрируются на вашей АТС вашим логином и паролем), а бесплатный тариф для личного некоммерческого использования урезан до состояния, в котором пользоваться им как рабочим телефоном для внутренней связи не получается.

Что было в опенсорсе

  • Infactum/tg2sip, канонический проект и первое, что находится. Мёртв: держался на libtgvoip, с которой Telegram давно ушёл, с современными клиентами звонок не устанавливается.

  • foobar26/tg2sip, живой форк на pjsua2 плюс NTgCalls, на C++. Рабочий современный референс, читал внимательно.

  • NTgCalls, то, на чём всё держится. Отдаёт наружу сырые PCM-кадры и принимает их обратно, лежит на PyPI готовыми колёсами под Linux и Windows, сборки не требует.

  • pyVoIP и прочие питоновские SIP-библиотеки: для телефона за АТС либо слишком общие, либо недоделанные.

Почему свой SIP-стек

Причина прозаичная: я хотел pip install без компилятора, одинаково на Linux и на Windows, на актуальном питоне. pjsua2 это сборка из исходников, и на Python 3.14 под Windows это отдельный квест на вечер.

А телефону за АТС нужен не весь RFC 3261, а компактное подмножество: REGISTER с digest (MD5 и SHA-256, qop), INVITE/ACK/BYE/CANCEL в обе стороны, ретрансмиссии T1/T2 для UDP, ответы на OPTIONS/INFO/NOTIFY/UPDATE/MESSAGE, re-INVITE для удержания и session timers, REFER с NOTIFY sipfrag, транспорты UDP, TCP и TLS. Ровно то, что делает любой настольный SIP-телефон. На asyncio это пишется и отлаживается. Вышло около восьми тысяч строк плюс две с половиной тысячи строк тестов.

Как оно работает

PSTN / SIP-транк ──► FreePBX ──SIP/RTP (Opus, G.711)──► SIPgram ──Telegram P2P (MTProto)──► Telegram
                     Asterisk                          Python: свой SIP-стек + NTgCalls + Telethon

Для АТС шлюз это набор обычных SIP-телефонов. Каждому пользователю заводится свой extension, шлюз на нём регистрируется. Дальше:

Входящий на 491. Шлюз отвечает АТС 180 Ringing, присылает владельцу номера в чат имя и номер звонящего и звонит ему в Telegram. Не ответили за ring_timeout, и АТС получает 480 и уходит на follow-me или voicemail. Отклонили в Telegram, приходит 603. Занято, 486. FreePBX всё это обрабатывает штатно, ничего доучивать не надо.

Исходящий. Отправили номер в чат, шлюз перезванивает вам в Telegram и, когда вы взяли трубку, шлёт INVITE от имени вашего extension. Outbound Routes, CallerID и права номера работают как обычно, потому что это и есть обычный extension. В INVITE добавляются заголовки X-TG-User-Id и X-TG-User-Name, их видно в диалплане через PJSIP_HEADER.

Маршрутизация, очереди, запись, follow-me, CDR остаются на АТС. Шлюз в них не лезет принципиально.

Главное, что я подсмотрел у sip.tgодин Telegram-аккаунт обслуживает всех пользователей сразу. Не по аккаунту на человека, а один общий «Офис», который параллельно держит несколько разговоров. На пользователя приходится не больше одного звонка Telegram, к нему крепятся SIP-плечи: активное, удержанное (АТС играет музыку) и ожидающее, если пришёл второй входящий. Переходы сериализуются локом на пользователя, а разные пользователи не пересекаются вообще: NTgCalls держит звонки по id собеседника, SIP-аккаунт держит диалоги по Call-ID, дополнительная синхронизация не нужна.

Сверху обычный набор офисного телефона: DTMF, второй вызов с удержанием, attended и blind перевод через REFER, конференция через ConfBridge на АТС, запись разговора голосовым сообщением в чат (кодируется на лету в Opus, лежит в Ogg в памяти, на диск не пишется), HTTP API с токеном для click-to-call из CRM, расписание рабочих и тихих часов с чёрными и белыми списками, опциональный бот с кнопками, RU и EN.

Что оказалось неочевидным

DTMF. Отправляешь 1 в чат, шлюз шлёт RFC 4733, IVR цифру видит, а человек на том конце не слышит ничего, и в Telegram тоже тихо. Это правильное поведение: по RFC 4733 цифра едет отдельным RTP-событием мимо звукового потока, а тональный отклик клавиш генерирует сам телефон, которого тут нет. Пришлось подмешивать настоящий двухчастотный тон в то, что слышит пользователь, ровно как это делает трубка, плюс глушить аудио из моста на время цифры (этого требует RFC, без этого некоторые SBC цифры теряют), плюс добавить режим dtmf: inband на случай, когда тон должен слышать именно собеседник. Проверяется сквозным тестом: шлю 1, через Echo() на Asterisk возвращаются 697 и 1209 Гц.

«Access denied» на 100. Оказалось не багом шлюза. В FreePBX 1, нажатая во время разговора, зарезервирована под One Touch Record, и Asterisk перехватывает эти две цифры раньше, чем они уйдут собеседнику. Остаток набора уходит уже без первых двух. То же самое услышишь с обычного настольного телефона.

REGISTER, который не уходил на Windows. sipgram check получал 408 ровно через 32 секунды, при этом пробник с той же машины отвечал мгновенно, а порт и конфиг были правильные. Причина: сокет привязан к конкретному локальному адресу, а адрес АТС задан доменным именем, и sendto по имени в такой конфигурации на Windows молча не отправлял пакет, ошибка уходила только в debug-лог. На Linux то же самое работало, поэтому интеграционные тесты против настоящего Asterisk баг не поймали. Теперь адрес резолвится в IP на старте и перерезолвится при неудачной регистрации, а ошибки отправки логируются как warning. Нашлось это флагами -v --sip-trace, которые я в тот же день и добавил.

Звук. Ресемплинг в обычном случае не нужен: частота внешних кадров NTgCalls выставляется равной частоте SIP-кодека, а WebRTC внутри сам приводит поток к Opus 48 кГц. Направление Telegram в SIP пакетируется тикером 20 мс с джиттер-буфером, направление SIP в Telegram отдаётся кадрами по 10 мс сразу по приходу RTP, джиттер там компенсирует NetEq на стороне клиента. Opus на SIP-плече прикручен через ctypes к libopus, поэтому широкополосный звук доходит от Telegram до внутреннего номера без сужения до восьми килогерц.

Тесты

102 модульных теста, около десяти секунд: SIP-парсер, digest по вектору из RFC 2617, SDP offer и answer, G.711 и Opus, RTP, контейнер Ogg, конфиг, логика звонков на фейковых плечах, расписание и списки номеров, голосовые чаты, локализация, HTTP API.

24 сквозных против настоящего Asterisk 22.10 в контейнере: регистрация по UDP и TCP, неверный пароль, исходящий на Echo() со спектральной проверкой вернувшегося тона, Milliwatt(), 486 Busy, 180 с последующим ответом, 183 с ранними медиа, CANCEL, BYE от АТС, DTMF в обе стороны, входящие через AMI Originate, удержание и возобновление, перевод через REFER и два одновременных диалога на одной регистрации. Оба набора гоняются в CI на каждый пуш.

Конфиги Asterisk для этих тестов лежат в репозитории, их же можно брать как пример для голого Asterisk без FreePBX.

Ограничения

Сразу и честно:

  • Нужен отдельный Telegram-аккаунт под шлюз: Telegram не звонит сам себе. Это userbot-аккаунт, и свежезарегистрированный Telegram ограничивает, так что брать надо обжитый.

  • Сколько параллельных звонков реально выдержит один аккаунт, решает Telegram, а не я. На первых прогонах max_calls лучше держать небольшим.

  • Telegram Web (web.telegram.org/k) звонит по протоколу 12/13, которого нет в стабильном NTgCalls 2.2.x, вроде обещают в v3 завезти, ждём, а пока мобильные и десктопные клиенты работают.

  • Только голос. SIP-кодеки Opus, G.711, L16. TLS есть на сигнализации, SRTP нет.

Про другие мессенджеры

Естественный вопрос в чате "Asterisker-ы": а WhatsApp, VK, MAX? Разбирался, коротко результат.

WhatsApp. У Meta с 2026 года есть официальный Business Calling API с SIP-режимом, и для него SIPgram не нужен вообще: транк заводится прямо на Asterisk на wa.meta.vc, обязательны TLS на сигнализации и SRTP на медиа, кодек Opus. Всё это умеет chan_pjsip. Но работает это только на входящих от клиента: бизнес-инициированный звонок требует предварительного разрешения от пользователя, не больше двух запросов за семь дней, не больше десяти состоявшихся звонков в сутки внутри окна, плюс порог квалификации аккаунта. Как повседневный софтфон схема не живёт. Неофициальный путь тоже есть, библиотека meowcaller поверх whatsmeow отдаёт PCM ровно как NTgCalls, но Meta банит аккаунты за whatsmeow, а терять рабочий номер ради эксперимента не хочется.

VK Звонки. Публичный API умеет создавать ссылки на конференции и управлять ими, клиентский SDK встраивает звонки в ваше приложение. Серверного headless-доступа к аудиопотоку нет, позвонить конкретному человеку нельзя. Единственный обходной путь это headless Chromium с виртуальной звуковой картой, для рабочего телефона не годится.

MAX. В Bot API звонков нет вообще, документация по аудио отсылает к мини-приложениям. Протокол реверсят, есть описание опкодов управления звонком поверх WebSocket, но готового медиастека нет: писать придётся и сигнализацию, и WebRTC с их кодеком, под закрытый меняющийся протокол.

Так что Telegram здесь пока один такой, и держится это на трёх совпадениях: NTgCalls отдаёт наружу сырые PCM-кадры, лежит на PyPI готовым колесом под все платформы, и Telegram терпит userbot-аккаунты. Убери любое из трёх, и схема разваливается.


Итого

Мысль во всём этом одна: если железо уже стоит у тебя, то и софт вокруг него логично держать свой, а не подписку на чужой сервер.

SMSNode делался как дипломная работа и своё дело сделал. Активно допиливать его я не планировал, но если будет интерес, есть понятный список того, что делать дальше. SIPgram у меня просто работает как рабочий телефон, и его я буду поддерживать в любом случае, но приоритеты расставлю по тому, что спросят.

Вопросы, баг-репорты и pull request'ы принимаются в issues.

Ссылки