Как я писал расшифровщик записей, но в итоге получил бота для Яндекс.Телемост, с функциями которые даже не снились
Всем привет. Меня зовут Алексей, пишу тут впервые. Работаю в небольшом рекламном агентстве, в основном backend и всякое смежное. По вечерам реверс‑инжиниринг, чисто для удовольствия. Стек у меня был максимально предсказуемый. PHP, немного JS с TS, Python. Скучно. Захотелось нового, и я полез в Go.
Пару лет назад, когда ElevenLabs только появился, я на коленке собрал питоновский скрипт: он гонял записи моих созвонов через их API и отдавал текст. Поигрался неделю и забросил, как это обычно и бывает. А летом 2026-го решил, что пора собрать нормальное портфолио и довести пет‑проекты до чего‑то, что не стыдно показать. Вот и вернулся к тому расшифровщику.
Спойлер: расшифровщик я довёл. А потом он как‑то сам, на чистом энтузиазме, оброс WebRTC‑ботом, диаризацией, нейросетевыми заметками, Телеграм‑ботом, MCP‑сервером и самым прикольным, это подключение к видеовстрече по средству звонком на специальный номер и вводом кода, ибо опыт с IP телефонией есть, и принцип ее работы тоже понятен.
Начало начал
Взять аудио, скинуть в ElevenLabs Scribe, получить текст с разбивкой по спикерам. Все вроде довольно было просто. Так что, первая версия, черновик был сделан буквально за пару вечеров. Файл прилетает через multipart, падает в очередь, воркер дёргает API, результат складывается в Postgres. Фронт на React, бэк на chi и pgx. Ничего сложного, не?
Ну не совсем, так как я в основном занимаюсь беком, во фронт не лезу, пришлось через AI делать интерфейс, хорошо что опыт в этом есть и ранее делал небольшую библиотеку компонентов, которая до боли похожа на OpenWebUI. Она и легла в основу и был построен довольно простой интерфейс и очень удобный.

Так идем далее, через время пришлось прикручивать авторизацию, и я не хотел сильно этим заниматься, по этому поступил по самому тупому сценарию, это проверка на наличия пользователя по корп. почте и выдача JWT. Конечно, ранее там был баг, связанные с timeout, что он мог до 5 минут проверять есть ли пользователь, но сейчас у меня 15 секунд и все, пока!
func VerifyIMAPCredentials(host, port, email, password string) bool {
dialer := &net.Dialer{Timeout: 10 time.Second}
conn, err := client.DialWithDialerTLS(dialer, host+":"+port,
&tls.Config{ServerName: host})
if err != nil {
return false
}
defer conn.Logout()
conn.Timeout = 15 time.Second
return conn.Login(email, password) == nil
}И все это работало долгое время, пока не клюнул петух в одно место и мне захотелось большего
Переломный момент, или как пришлось разбираться с работой Яндекс.Телемоста
Я с коллегами часто созваниваемся в Яндекс.Телемосте. И мне уже надоели костыли с OBS либо со скачиванием записи разговора, и последующей его конвертации в wav. И в этот момент, я задумался, о том что есть же решения по обходу белых списков с помощью SFU телемоста, так значит есть вероятность закинуть бота в созвон.
Телемост внутри это WebRTC. Медиа идёт по SRTP, а вся логика (кто в комнате, какие дорожки активны, ICE‑кандидаты, когда сервер переезжает) гоняется по WebSocket поверх своего JSON‑протокола. Описания нигде нет. Так что дальше пошли вечера с DevTools, разбор бандла и чтение WS‑трафика по строчкам, как книжку.
Документ, который я написал о его работе я тут выкладывать не буду. Но скажу примерно, как сейчас все работает: бот прикидывается веб‑клиентом. Ходит по тому же фронтовому API, поднимает RTCPeerConnection, шлёт и принимает сигналы.
Вот тут пошел первый баг. Бот заходит, обменивается рукопожатиями с сервером, и ровно через пять секунд соединение падает. Каждый раз. Пять секунд, как по будильнику. Я сидел в логах, в дампах час и тупил, пока не дошло. Я генерил instance-id заново на каждый запрос. Сервер цепляет сессию именно по нему. Видит «новый» id, не узнаёт посетителя, рвёт коннект. Решение было простое, я зафиксировал id на всю сессию, и всё.
client := &telemost.Client{
HTTP: http.DefaultClient,
Cookie: cookie,
AppVersion: "3.2.0",
InstanceID: uuid.NewString(), // ОДИН на всю сессию, не на запрос!
}Дальше проще. Бот подписывается на входящие дорожки, в pion это один колбэк:
subPC.OnTrack(func(track webrtc.TrackRemote, r webrtc.RTPReceiver) {
switch {
case strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus):
s.recordTrack(track, mid) // голос участника в .ogg
case strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeVP8):
s.recordScreenTrack(track) // демонстрация экрана в .ivf
}
})Голос каждого участника пишется в свой .ogg. С демонстрацией экрана повозиться пришлось: VP8 приходит кусками, нужен samplebuilder чтобы собрать целые кадры, плюс руками ловить первый ключевой кадр, иначе запись стартует с каши из артефактов. И ещё отдельно просить у Телемоста высокое разрешение, иначе прилетает мыло.
На выходе куча дорожек, ffmpeg сводит их в один файл, файл уходит в расшифровщик. Всё, петля замкнулась. Бот сам сидит в созвоне и сам достаёт звук.
В последствии обнаружил один баг, гостевой вход работает, но не везде. Если у встречи включён «зал ожидания» или подтверждение входа, бот как гость не зайдёт, нужны куки хозяина. Мелочь, но на паре звонков я на это налетел и не сразу понял, почему бот молча висит снаружи.
Заигрывание с ботом, или что на него вышло повесить
Так, бот у нас есть, он умеет в обе стороны, получать и отдавать информацию. Ради эксперимента я подключил Gemini Live, дописал некоторые скиллы, навыки. И теперь во время созвона, можно обратиться к боту, за информацией, или запомнить что нибудь важное. ИИ это выполнит и сообщит, об этом прямо на встрече.

Также получилось привязать Gemini Flash 3.8, на саммари и прочие фичи, по типу Mindmap, и названия встречи. Вообщем мелочи.
Тут стоит сказать про один трюк, который потом выстрелит ещё раз. Голос в дорожку бота я не кодирую руками, cgo и libopus тащить было лень. В образе есть ffmpeg, вот он и жмёт PCM в Opus на лету, а я потом режу поток на пакеты по 20 мс и отдаю в трек. Простая схема, но она мне ещё пригодится в самой дикой фиче. Конечно, это огромный костыль и из‑за него есть небольшие задержки, но вскоре исправлю.
MCP
Потом захотелось, чтобы в мои разговоры могли ходить другие модели. Прикрутил MCP‑сервер, это такой протокол, по которому ИИ ходит в твои данные как в набор инструментов. Выдаёшь себе персональный ключ, вставляешь в клиент, и всё, тот же Claude, ChatGPT умеет искать по моим созвонам, читать расшифровки и тянуть саммари. По сути чат с собственными встречами, только из любимого клиента. Мелочь, а приятно.

Самое интересное, звонок в Яндекс.Телемост с обычного телефона
А вот теперь то, ради чего половина статьи. Опыт с IP телефонией у меня есть, так что идея легла сама. Что если человеку не нужен ни ноут, ни Телемост, а только телефон в кармане? Звонишь на номер, голосом набираешь пятизначный код встречи, и ты в видеозвонке обычным участником. Телемост про SIP ничего не знает. Зато у меня уже есть бот, живой участник WebRTC. Значит нужен переходник телефон в бота. Сигналинг взял на sipgo, он неожиданно нормальный. Медиа снова на pion.
Дальше пара нюансов, на которых я честно залип. Первый, DTMF это не звук. Цифры с телефона летят не тоном в аудио, а отдельными RTP‑пакетами специального типа (RFC 2833). В пакете код клавиши, бит окончания, длительность.
// telephone-event, 4 байта
// 0 1 2 3
// | event |E|R| volume | duration |
digit := payload[0] // 0-9, 10 = '*', 11 = '#'
end := payload[1]&0x80 != 0 // E-бит: нажатие завершилосьВторой нюанс, на который потратил больше времени. Код срабатывал на цифру раньше времени. Набираю 6317, а в буфере уже 63177, откуда лишняя семёрка. Полез в логи и увидел, что Zoiper на одно нажатие иногда шлёт два события с разными таймстампами, и последняя цифра дублируется. Добавил дебаунс одинаковой цифры, а сами коды стал генерить без соседних одинаковых цифр, чтобы дебаунс случайно не сожрал нормальный ввод.
А сам звук это тот самый трюк с ffmpeg, только теперь в обе стороны. Голос абонента приходит как PCMU 8кГц, я декодирую его в PCM, ffmpeg жмёт в Opus 48кГц, и это льётся в дорожку бота. А обратно микс участников тем же путём уходит в трубку. В Телемосте у бота загорается микрофон, и его слышно как живого человека, а сам абонент слышит встречу.
И да, звук двусторонний. Абонент и говорит в встречу, и слышит её в трубке. Полоса телефонная, 8 килогерц, для музыки узко, но голос передаёт нормально. По ощущениям обычный созвон, только ты в нём через телефон. Набрал номер, ввёл код, и ты на встрече.
Стек, если коротко
Backend: Go. chi, pgx, pion/webrtc, pion/rtp, sipgo, gorilla/websocket. Postgres.
AI: ElevenLabs Scribe на распознавание, Gemini Live и Gemini Flash на заметки, саммари и mindmap.
Медиа: ffmpeg на Opus и сведение дорожек.
Front: React и TypeScript, интерфейс на своей библиотеке компонентов.
Прочее: Docker Compose, nginx.
Что в итоге
Хотел переписать питоновский скрипт на Go для галочки в резюме. Получил бота, который сам сидит в созвонах, пишет их, ведёт заметки нейронкой, поднимается кнопкой из Телеграма, отдаёт данные другим ИИ по MCP, и в который можно позвонить с мобилы и оказаться на встрече. Go зашёл, горутины, каналы и pion сделали процесс почти приятным.
Если тема зайдёт, во второй части подробно разберу сигналинг Телемоста.
Спасибо, что дочитали, надеюсь Яндекс не постучится, как было у меня с Rutube. Текст в некоторых местах был написан с помощью ИИ, так как мне было немного структуризировать логически.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.