Просто песня: телеграм-бот, который присылает 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 МБ памяти — для «найти, скачать, отправить» этого хватает с запасом, а всё, что не влезает в такую модель, в бота и не просится.

Для бота, которым пользуются несколько человек, облачная функция — идеальный формат. Платишь копейки за фактические вызовы, не администрируешь сервер, а весь «бэкенд» помещается в один файл, который читается за десять минут.

Код целиком тут:

github.com/filimonovadm/singsongnobot

← Back to Blog