Просто песня: телеграм-бот, который присылает MP3 из Яндекс Музыки
Мой пет-проект: serverless-бот на 251 строку Python, который ищет треки в Яндекс Музыке и присылает их в Telegram. Про webhook, ретрай-штормы и прокси, без которого ничего бы не работало.
Иногда хочется скинуть другу песню файлом. Не ссылкой на стриминг, где нужна подписка и приложение, а просто файлом — нажал и слушаешь. Я написал для этого бота: пишешь ему название трека, он находит несколько вариантов в Яндекс Музыке, ты выбираешь нужный, и через секунду MP3 уже в чате.
Живёт он в Telegram под именем @singsongnobot. Вся логика — 251 строка Python в одном файле. Никакого фреймворка, никакой базы данных, никакого сервера. И именно поэтому про него интересно рассказать.
Что он делает
Сценарий короткий. Пишешь боту Кино — Группа крови или просто группа крови. Бот ищет по Яндекс Музыке и отвечает списком из пяти кнопок — по одной на трек, с исполнителем и названием. Нажимаешь кнопку — бот скачивает MP3 и присылает его как нормальное аудиосообщение: с обложкой плеера, длительностью, исполнителем. Можно слушать прямо в Telegram или переслать кому угодно.
Первая версия работала ещё проще: брала первый результат поиска и сразу его отправляла. Быстро выяснилось, что первый результат — это далеко не всегда то, что ты искал. Каверы, ремиксы, одноимённые треки других исполнителей. Так появился выбор из пяти вариантов.
Стек
- Python 3.12, одна функция в Yandex Cloud Functions. Не сервер, не демон — функция, которая просыпается на входящее сообщение и засыпает обратно.
pyTelegramBotAPI(telebot) — но не как фреймворк, а как тонкий клиент к Telegram API. Никаких@bot.message_handlerи поллинга.yandex-music— неофициальный клиент Яндекс Музыки от MarshalX. Поиск и скачивание треков.boto3— каждый скачанный трек заодно складывается в Object Storage.
База данных не нужна вообще. Единственное состояние, которое надо протащить между «показал список» и «пользователь выбрал», — это id трека. И для него в Telegram есть готовое место: callback_data у инлайн-кнопки. Кладём туда id при отправке списка, получаем обратно при нажатии:
keyboard = types.InlineKeyboardMarkup()
for track in tracks:
keyboard.add(types.InlineKeyboardButton(
text=f'{artists} — {track.title}',
callback_data=str(track.id),
))
Лимит callback_data — 64 байта, числовой id влезает с запасом. Telegram сам хранит эти данные и возвращает их при нажатии. Получается, что сессионное хранилище за меня держит Telegram, бесплатно.
Как устроен вход
Раз бот — это облачная функция, апдейты приходят вебхуком: Telegram делает POST на URL функции, функция разбирает JSON и решает, что это было — текст или нажатие кнопки.
Здесь меня ждала первая засада: Yandex Cloud Functions может отдать тело запроса в base64. Не всегда, а когда сочтёт нужным. Пока я этого не знал, часть апдейтов просто не парсилась:
def handler(event: dict, context) -> dict:
body_raw = event.get('body', '{}')
if event.get('isBase64Encoded'):
body_raw = base64.b64decode(body_raw).decode('utf-8')
update_data = json.loads(body_raw)
Вторая засада оказалась серьёзнее и стоит того, чтобы про неё рассказать подробно.
Всегда отвечай 200
Однажды бот перестал отвечать. Совсем. Логи показали, что где-то внутри обработчика вылетело исключение — причём вылетело в том месте, где бот пытался отправить пользователю сообщение об ошибке. Исключение ушло наверх, функция вернула 502.
А дальше цепочка: Telegram видит 502 и считает, что апдейт не доставлен. Помечает вебхук проблемным и начинает ретраить — с экспоненциальной задержкой. Пока он ретраит один несчастный апдейт, все остальные стоят в очереди. Один необработанный exception — и бот молчит для всех.
Лечится это жёстким правилом: функция возвращает 200 всегда. Упал поиск — 200. Упало скачивание — 200. Упала даже отправка сообщения об ошибке — всё равно 200:
try:
bot.send_message(chat_id, 'Что-то пошло не так, попробуй ещё раз.')
except Exception:
pass
return {'statusCode': 200, 'body': 'ok'}
Выглядит как антипаттерн из учебника — глотать исключения молча. Но здесь это осознанное решение: код ответа вебхука — это не «получилось ли обработать», а «получил ли я апдейт». Получил — значит 200, а свои проблемы я разберу по логам.
Холодный старт и зачем в zip кладут site-packages
У serverless есть цена: холодный старт. Если функция давно не вызывалась, облако поднимает её с нуля, и первое сообщение обрабатывается заметно дольше.
Часть этой цены я убрал кэшированием клиентов. Клиент Яндекс Музыки при инициализации ходит в сеть за токеном сессии — делать это на каждое сообщение расточительно. Поэтому он живёт в глобальной переменной модуля и переживает тёплые вызовы:
_ym_client: Client | None = None
def _get_ym_client() -> Client:
global _ym_client
if _ym_client is None:
_ym_client = Client(YM_TOKEN).init()
return _ym_client
Вторая часть — зависимости. Сначала я отдавал облаку requirements.txt и позволял ему ставить пакеты самому. Это медленно и иногда ломается. Теперь CI на GitHub Actions собирает Linux-совместимые wheel-пакеты (--platform manylinux2014_x86_64) и кладёт их прямо в zip рядом с кодом. Функция стартует уже со всеми зависимостями на месте.
Правда, такой zip перестал влезать в лимит прямой загрузки — 3.5 МБ. Пришлось заливать архив в Object Storage и передавать функции ссылку на него плюс sha256. Приятный побочный эффект: если хэш не изменился, новая версия функции не создаётся. Пуш без изменений кода — ноль действий в облаке.
Прокси: самая странная часть
А теперь история, которой в коде нет вообще.
В какой-то момент вебхуки перестали доходить. Бот жив, функция на месте, getWebhookInfo показывает растущий счётчик ошибок: connection timeout. Оказалось, связность между Telegram и Яндекс Облаком просто умерла — TCP-хендшейк висит до таймаута. Судя по всему, постарался РКН: не мой код, не моя конфигурация, а блокировка где-то на маршруте между двумя конкретными сетями.
Починилось прокладкой:
Telegram ──► прокси ──► functions.yandexcloud.net
Промежуточный прокси на нейтральном хостинге принимает вебхук от Telegram и пробрасывает его в Яндекс. Красивое в этом решении то, что бот про прокси не знает. В коде нет ни строчки. Меняется только URL, который я передал в setWebhook, — а он хранится на серверах Telegram, привязан к токену и в репозитории не светится. Обратный путь — отправка аудио из функции в Telegram — работает напрямую, тот маршрут не пострадал.
Мелочи, которые делают бота приятным
Есть набор вещей, каждая из которых стоит одну-две строки, но вместе они отличают бота, которым приятно пользоваться, от бота, который «вроде работает»:
- Пока идёт поиск, бот показывает «печатает…», пока качает трек — «отправляет файл…». Пользователь видит, что процесс идёт, а не завис.
- Сразу после нажатия кнопки бот отвечает на callback query — иначе на кнопке крутится спиннер до таймаута.
- После выбора трека клавиатура с вариантами убирается. Нельзя случайно нажать дважды и получить два скачивания.
- Аудио отправляется с метаданными: длительность, исполнитель, название. Telegram рисует нормальную карточку плеера, а не безымянный файл.
Со скачиванием тоже не всё прямолинейно. Сначала бот просит у Яндекса MP3 в 192 kbps. Если такого варианта нет — берёт список всех доступных форматов, фильтрует MP3 и выбирает максимальный битрейт из того, что есть:
try:
audio_bytes = track.download_bytes(codec='mp3', bitrate_in_kbps=192)
except Exception:
infos = track.get_download_info()
mp3_infos = [i for i in infos if i.codec == 'mp3']
info = (
max(mp3_infos, key=lambda i: i.bitrate_in_kbps)
if mp3_infos else infos[0]
)
audio_bytes = info.download_bytes()
Чего тут осознанно нет
Нет очередей, нет базы, нет кэша скачанных треков, нет плейлистов и поиска по альбомам. Функция живёт с лимитом 25 секунд на выполнение и 512 МБ памяти — для «найти, скачать, отправить» этого хватает с запасом, а всё, что не влезает в такую модель, в бота и не просится.
Для бота, которым пользуются несколько человек, облачная функция — идеальный формат. Платишь копейки за фактические вызовы, не администрируешь сервер, а весь «бэкенд» помещается в один файл, который читается за десять минут.
Код целиком тут: