Фото, файлы, стикеры, голосовые

До сих пор твой Цыплёнок умел читать и писать только текст — сегодня он научится видеть фото, ловить присланные файлы, узнавать стикеры и слушать голосовые.
file_id — это короткий идентификатор, который Telegram выдаёт на каждый загруженный файл (фото, документ, стикер, голосовое). По нему бот может переслать тот же файл сколько угодно раз, ничего не скачивая и не загружая заново.

Зачем нам этот урок

Представь: ты сделал бота, который проводит опрос в чате игрового клана. Друг присылает ему скриншот своего ранга, чтобы похвастаться, а бот в ответ молчит — потому что ты научил его понимать только текст. Или ты хочешь, чтобы бот по команде /meme кидал тебе любимую картинку, а по /cheatsheet — PDF со шпаргалкой по Python перед контрольной. Без работы с медиа ничего этого не выйдет.

Подумай, как ты сам общаешься в мессенджерах. Вряд ли ты переписываешься только текстом: ты кидаешь мемы, отправляешь голосовые, когда лень печатать, скидываешь другу скрин с ответом на задачу. Медиа — это половина живого общения. И если твой бот понимает только буквы, он сразу чувствуется чем-то ненастоящим, неживым. А вот бот, который умеет принять фото и тут же что-то про него сказать или прислать тебе картинку по команде, ощущается как полноценный собеседник.

В прошлом уроке мы разобрали, как хэндлеры ловят текстовые сообщения и команды. Сегодня мы сделаем следующий шаг: научим того же бота Цыплёнка-помощника понимать, что именно ему прислали — фото, документ, стикер или голосовое, — и самому отправлять картинки и файлы. А ещё познакомимся с маленькой магией под названием file_id, благодаря которой бот отправляет одну и ту же картинку мгновенно и бесплатно по трафику. Ничего страшного в этом нет: ты уже умеешь писать хэндлеры, а сегодня мы просто научим их реагировать не на текст, а на вложения.

Вот к чему придём — кусочек кода, который мы будем разбирать по частям:

@dp.message(F.photo)
async def on_photo(message: Message):
    file_id = message.photo[-1].file_id
    await message.answer(f"Классное фото! Его file_id:\n{file_id}")

Результат: в чате бот ответит на любое присланное фото сообщением «Классное фото!» и покажет длинную строку — это и есть file_id той картинки.

Сообщение — это не только текст

Главная мысль урока вот в чём: каждое сообщение в Telegram — это как посылка с разными отделениями. В одном отделении может лежать текст, в другом — фото, в третьем — голосовое. У объекта message, который приходит в хэндлер, есть отдельное поле под каждый тип содержимого:

  • message.text — обычный текст (его мы уже знаем);
  • message.photo — список размеров присланной фотографии;
  • message.document — присланный файл (PDF, архив, что угодно);
  • message.sticker — стикер;
  • message.voice — голосовое сообщение;
  • message.audio — музыкальный файл, message.video — видео.

Если в сообщении пришло фото — поле message.photo заполнено, а message.text равно None (пусто). Если пришёл текст — наоборот. Бот должен сначала понять, что за «отделение» посылки заполнено, и только потом решать, что делать.

Почему вообще Telegram раскладывает всё по разным полям, а не сваливает в одну кучу? Потому что у каждого типа контента — свои свойства, которые тебе пригодятся. У документа есть имя файла и размер, у голосового — длительность в секундах, у стикера — связанный с ним эмодзи. Если бы всё лежало в одном поле, добраться до этих свойств было бы куда сложнее. Вот что полезного лежит внутри самых частых типов:

ТипПоле messageЧто полезного внутри
Текстmessage.textсама строка с текстом
Фотоmessage.photoсписок размеров; у каждого есть file_id
Документmessage.documentfile_name, file_size, file_id
Стикерmessage.stickeremoji, file_id, флаг is_animated
Голосовоеmessage.voiceduration (секунды), file_id
Видеоmessage.videoduration, width, height, file_id

Заметь общую деталь: у каждого медиа-вложения есть file_id. Это и есть тот универсальный «номерок», к которому мы вернёмся чуть позже. А пока запомни главное: чтобы добраться до свойств вложения, сначала надо понять его тип.

Как понять, что именно прислали

Проще всего разложить это на чистом Python, без всякого бота. Представь, что сообщение — это словарь, где заполнено только одно поле, а остальные пустые (None). Напишем функцию, которая определяет тип:

def content_type(message):
    if message.get("text"):
        return "текст"
    if message.get("photo"):
        return "фото"
    if message.get("document"):
        return "документ"
    if message.get("voice"):
        return "голосовое"
    return "что-то ещё"


print(content_type({"text": "Привет", "photo": None}))
print(content_type({"text": None, "photo": ["small", "big"]}))
print(content_type({"text": None, "voice": "abc123"}))

Вывод:

текст
фото
голосовое

Видишь логику? Проверяем поля по очереди и возвращаем название первого заполненного. Именно так под капотом и работает фильтр, который мы сейчас применим в aiogram, — только там за нас всё делает сама библиотека.

Реагируем на типы вложений с помощью F

В aiogram 3.x есть удобный инструмент — «магический фильтр» F (его импортируют как from aiogram import F). Думай о нём как о вопросе к сообщению: F.photo значит «в этом сообщении есть фото?», F.voice — «есть голосовое?». Если ответ «да» — сработает нужный хэндлер.

import asyncio
import os
from aiogram import Bot, Dispatcher, F
from aiogram.types import Message

bot = Bot(token=os.getenv("BOT_TOKEN"))
dp = Dispatcher()


@dp.message(F.photo)
async def on_photo(message: Message):
    await message.answer("Вижу фото! Красивое 🐤")


@dp.message(F.document)
async def on_document(message: Message):
    await message.answer(f"Принял файл: {message.document.file_name}")


@dp.message(F.sticker)
async def on_sticker(message: Message):
    await message.answer(f"О, стикер! Его эмодзи: {message.sticker.emoji}")


@dp.message(F.voice)
async def on_voice(message: Message):
    seconds = message.voice.duration
    await message.answer(f"Голосовое на {seconds} сек. Слушаю внимательно!")


async def main():
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Результат: пришлёшь боту фото — он ответит «Вижу фото! Красивое 🐤»; пришлёшь файл — назовёт его имя; пришлёшь стикер — покажет эмодзи стикера; запишешь голосовое — скажет, сколько оно длится. На каждый тип контента — свой отдельный хэндлер.

Обрати внимание: мы берём токен через os.getenv("BOT_TOKEN") — это переменная окружения, чтобы секретный токен не лежал прямо в коде. Объекты bot и dp — те же самые, что и в прошлых уроках, мы просто дописываем новые хэндлеры в наш общий bot.py.

Почему у фото — список, а у документа нет

Заметил странность? У фото мы писали message.photo[-1] со скобками и индексом, а у документа — просто message.document. Дело в том, что Telegram хранит каждую фотографию в нескольких размерах сразу: крошечную превьюшку, среднюю и оригинал. Поэтому message.photo — это список, отсортированный от самого маленького размера к самому большому. Запись message.photo[-1] берёт последний элемент списка, то есть фото в максимальном качестве. А документ, стикер и голосовое приходят в одном экземпляре — там список не нужен.

Бот сам отправляет фото и файлы

Принимать медиа научились — теперь пусть бот сам что-нибудь пришлёт. Для этого у сообщения есть методы:

  • message.answer_photo(...) — отправить фотографию;
  • message.answer_document(...) — отправить файл;
  • message.answer_sticker(...) — отправить стикер;
  • message.answer_voice(...) — отправить голосовое.

Откуда бот возьмёт картинку? Есть три способа, и у каждого свой смысл.

Способ 1: отправить по ссылке из интернета

@dp.message(F.text == "/cat")
async def send_cat(message: Message):
    url = "https://cataas.com/cat"
    await message.answer_photo(photo=url, caption="Держи котика 🐱")

Результат: на команду /cat бот пришлёт картинку с котиком, скачав её по ссылке, и подпишет её «Держи котика 🐱». Параметр caption — это подпись под фото.

Способ 2: отправить файл со своего компьютера

Если картинка лежит рядом с твоим bot.py, её нужно «обернуть» в специальный объект FSInputFile (от слов File System Input File — «файл из файловой системы»). Это как сказать боту: «возьми вот этот файл с диска и загрузи его в Telegram».

from aiogram.types import FSInputFile


@dp.message(F.text == "/meme")
async def send_meme(message: Message):
    photo = FSInputFile("memes/chick.jpg")
    await message.answer_photo(photo=photo, caption="Мем дня 🐤")

Результат: на команду /meme бот загрузит картинку chick.jpg из папки memes рядом с ботом и пришлёт её с подписью «Мем дня 🐤».

Способ 3: отправить по file_id (самый быстрый)

А вот теперь — обещанная магия. Когда кто-то впервые отправляет файл боту (или бот загружает файл с диска), Telegram присваивает этому файлу file_id — короткую строку-удостоверение. Если у тебя есть этот file_id, бот может отправить тот же самый файл мгновенно: ничего не качается из интернета и не загружается с диска, Telegram просто берёт уже знакомый ему файл со своих серверов.

SAVED_PHOTO = "AgACAgIAAxkBAAID..."  # сюда вставляешь file_id один раз


@dp.message(F.text == "/logo")
async def send_logo(message: Message):
    await message.answer_photo(photo=SAVED_PHOTO, caption="Наш логотип 🐤")

Результат: на команду /logo бот мгновенно пришлёт ранее сохранённую картинку по её file_id, без задержки на загрузку.

Как добыть этот file_id? Очень просто: вспомни самый первый хэндлер из урока — он печатал file_id любого присланного фото. Отправь боту нужную картинку один раз, скопируй из его ответа длинную строку и вставь её в код. Дальше бот будет переотправлять её хоть тысячу раз без скачивания.

Аналогия, чтобы запомнить

Представь школьную раздевалку. Ты сдаёшь куртку (загружаешь файл) и получаешь номерок (file_id). Пока куртка висит в гардеробе Telegram, тебе достаточно показать номерок — и куртку выдадут мгновенно, не надо тащить её заново из дома. file_id — это и есть такой номерок на файл, лежащий на серверах Telegram.

Какой способ когда выбирать

Три способа — не «лучше» и «хуже», у каждого своя роль. Держи короткую шпаргалку, чтобы не путаться:

  • По ссылке — когда картинка живёт где-то в интернете и постоянно меняется (например, свежая погодная карта или случайный котик). Telegram скачает её на лету.
  • Через FSInputFile — когда файл лежит у тебя на диске рядом с ботом: твой логотип, заранее подготовленная картинка, PDF-шпаргалка.
  • По file_id — когда одну и ту же картинку бот шлёт часто и она не меняется. Это самый быстрый и экономный способ: ничего не качается и не загружается заново.

На практике профессиональные боты делают так: загружают картинку с диска один раз, забирают из ответа Telegram её file_id и дальше шлют уже по нему. Получается и удобно, и быстро.

А если прислали несколько фото сразу

Бывает, друг отправляет боту не одну картинку, а целый альбом — пять скриншотов разом. С точки зрения Telegram это не одно сообщение, а несколько отдельных, просто связанных общим номером — он лежит в поле message.media_group_id. Поэтому твой хэндлер F.photo сработает столько раз, сколько фото в альбоме, — по одному разу на каждое. Это нормально: на старте достаточно знать, что так бывает, чтобы не удивляться пяти одинаковым ответам бота. Аккуратная «склейка» альбома в одно сообщение — задача чуть более продвинутая, и мы её здесь не трогаем.

Частые ошибки и подводные камни

Вот на чём обычно спотыкаются новички — сверься, чтобы не потерять полдня на отладку.

1. Забыть про список у фото

Самая частая ошибка — написать message.photo.file_id вместо message.photo[-1].file_id. У списка нет поля file_id, поэтому код упадёт с ошибкой AttributeError. Запомни: message.photo — это всегда список размеров, бери из него элемент по индексу.

2. Думать, что file_id вечный и универсальный

Чужой file_id не подойдёт твоему боту: каждый бот видит файлы под своими идентификаторами. Если друг прислал тебе file_id из своего бота — у тебя он не сработает. Бери только те file_id, которые получил твой собственный бот. Обычно они живут долго, но это не «навсегда»: для чего-то важного лучше держать оригинал файла под рукой.

3. Перепутать порядок хэндлеров с общим фильтром

Если выше по коду стоит хэндлер @dp.message() вообще без фильтра, он перехватит все сообщения, и до F.photo очередь уже не дойдёт. aiogram проверяет хэндлеры сверху вниз и останавливается на первом подходящем. Правило: сначала конкретные хэндлеры (F.photo, F.voice), а «ловушку на всё остальное» оставляй в самом низу.

4. Отправлять локальный файл как обычную строку

Если написать answer_photo(photo="memes/chick.jpg"), Telegram решит, что это file_id или ссылка, не найдёт такого — и пришлёт ошибку. Файл с диска нужно обязательно обернуть в FSInputFile("memes/chick.jpg"). Просто строка с путём — не работает. И ещё про пути: FSInputFile("memes/chick.jpg") ищет файл относительно того места, откуда ты запускаешь бота. Если запускаешь из другой папки — бот файл не найдёт. Пока проще всего держать картинки рядом с bot.py и запускать бота из его же папки.

5. Путать caption и обычный текст

Подпись к фото передаётся параметром caption в тот же метод answer_photo, а не отдельным message.answer("текст"). Если послать фото и текст разными вызовами, в чате это будут два разных сообщения, а не одно красивое фото с подписью. И ещё: подпись не может быть длиннее 1024 символов — для длинного текста придётся слать его отдельно.

Мини-практика: бот-сортировщик контента

Теперь твоя очередь. Допиши Цыплёнка так, чтобы он стал вежливым приёмщиком любых вложений:

  1. На фото — отвечает «Сохранил твоё фото!» и показывает его file_id (пригодится для способа 3).
  2. На документ — отвечает именем файла и его размером в килобайтах (подсказка: message.document.file_size — это размер в байтах, раздели на 1024).
  3. На голосовое — отвечает «Голосовые я пока не расшифровываю, но {длительность} секунд послушал!».
  4. На команду /pet — присылает фото какого-нибудь животного по ссылке из интернета (например, того же котика).
  5. Бонус: на стикер — отвечает тем же стикером обратно (подсказка: у тебя уже есть его message.sticker.file_id, а отправить можно методом answer_sticker).

Собери всё это в новые хэндлеры внутри своего bot.py и проверь в живом чате. Когда бот начнёт уверенно различать, что ему прислали, и отвечать по делу — задание выполнено.

Итоги

Сегодня твой Цыплёнок перестал быть «только текстовым» и научился работать с медиа. Коротко, что теперь у тебя в руках:

  • Сообщение — это посылка с разными полями: message.photo, message.document, message.sticker, message.voice и другие; заполнено обычно одно из них.
  • Фильтр F (например F.photo) позволяет повесить отдельный хэндлер на каждый тип вложения.
  • Бот сам отправляет медиа методами answer_photo, answer_document, answer_sticker, answer_voice — по ссылке, через FSInputFile с диска или мгновенно по file_id.
  • file_id — номерок на файл в «гардеробе» Telegram: получил один раз и переотправляешь сколько угодно без загрузки.

В следующем уроке мы дадим Цыплёнку кнопки: научимся показывать reply-клавиатуру под полем ввода, чтобы пользователю не надо было вручную набирать команды — достаточно нажать кнопку. Бот станет по-настоящему удобным. До встречи! 🐤

Проверьте себя
1. Почему у присланного фото пишут message.photo[-1].file_id, а не просто message.photo.file_id?
AПотому что message.photo — это список разных размеров фото, и [-1] берёт самый большой
BПотому что у фото всегда ровно один размер, а [-1] — это просто привычка
CПотому что file_id хранится в самом начале списка, а [-1] исправляет ошибку
DПотому что без [-1] aiogram отправит фото в плохом качестве
2. Что такое file_id?
AИмя файла на твоём компьютере, например chick.jpg
BКороткий идентификатор файла на серверах Telegram, по которому бот может переотправить его без загрузки
CАдрес картинки в интернете, по которому её можно скачать
DСекретный токен бота, выданный BotFather
3. Бот должен отправить картинку, которая лежит в папке рядом с bot.py. Как правильно передать её в answer_photo?
Aanswer_photo(photo="memes/chick.jpg")
Banswer_photo(photo=open("memes/chick.jpg"))
Canswer_photo(photo=FSInputFile("memes/chick.jpg"))
Danswer_photo(file_id="memes/chick.jpg")
4. Какой фильтр повесит хэндлер на голосовые сообщения?
A@dp.message(F.audio)
B@dp.message(F.voice)
C@dp.message(F.text == "voice")
D@dp.voice()
5. Хэндлер на фото (F.photo) почему-то никогда не срабатывает. Что вероятнее всего не так?
AВыше в коде стоит хэндлер @dp.message() без фильтра, который перехватывает все сообщения
Baiogram не умеет принимать фотографии
CФото нельзя ловить фильтром, нужен только текст
DУ бота не хватает прав на приём картинок
6. Чем параметр caption отличается от обычного message.answer("текст")?
AНичем, это два названия одного и того же
Bcaption — это подпись прямо под отправленным фото в том же сообщении, а answer шлёт отдельное текстовое сообщение
Ccaption работает только со стикерами, а answer — только с текстом
Dcaption отправляет текст до фото, а answer — после