Инлайн-режим¶
Старая версия aiogram
Для aiogram 3.x эта глава расположена здесь.
Введение¶
В предыдущих главах бот и человек общались каждый сам за себя, однако в Telegram существует специальный режим, позволяющий пользователю отправить информацию от своего имени, но с помощью бота. Это называется инлайн-режим (Inline mode). Как это выглядит в реальной жизни:
Как видно на скриншоте выше, итоговое сообщение отправлено от имени пользователя, но предварительный список был предоставлен ботом @imdb. Подробное описание инлайн-режима с точки зрения пользователя можно найти на официальном сайте, а в этой главе мы напишем собственного простого инлайн-бота для упрощения поиска и отправки ссылок на любимые YouTube-видео.
Формат инлайн-запросов и ответов¶
Когда пользователь вызывает бота в инлайн-режиме, введя его юзернейм в поле ввода, бот получает апдейт с типом
InlineQuery, из которого нам важно поле from
(from_user
в aiogram),
содержащее тип User с информацией о юзере, вызвавшем бота, а также поле
query
, т.е. текст запроса (может быть пустым). К сожалению, в настоящий момент нет возможности узнать, в каком чате
был вызван инлайн-бот, вероятно, это сделано специально для повышения приватности пользователей, т.к. бота необязательно
добавлять в группу или канал, чтобы использовать его в инлайн-режиме.
Для ответа на запрос необходимо вызвать метод answerInlineQuery,
куда следует передать массив объектов-результатов, и дополнительно различные флаги, о которых поговорим чуть позже. Типов
объектов-результатов аж целых двадцать, однако многие из них являются
вариациями друг друга. Так, например, InlineQueryResultPhoto
содержит ссылку (URL) на изображение, а
InlineQueryResultCachedPhoto — file_id
уже загруженного
в Telegram изображения. Более того, рекомендуется использовать объекты одного типа в инлайн-ответе, поскольку клиентские
приложения некорректно отображают (или не отображают вовсе) смешанный контент.
Обратите внимание: в инлайн-режиме нельзя загружать новые медиафайлы в Telegram, только использовать уже имеющиеся в облаке, либо указывать URL из Интернета.
Одним из наиболее часто используемых типов объектов-результатов является InlineQueryResultArticle — просто текст. Рассмотрим основные элементы такого объекта внимательнее:
Цифрами на рисунке выше обозначены: 1 — заголовок объекта; 2 — описание; 3 — предпросмотр; 4 — ссылка, по которой перейдёт пользователь если нажмёт на (3). Всё, кроме (1) необязательно, но позволяет визуально разнообразить выдаваемые результаты. Но что отправится в чат, если нажать не на предпросмотр, а на то, что справа от него? Для этого существует тип InputMessageContent, представленный четырьмя подтипами: Текст, Геолокация, Достопримечательность (Venue) и Контакт. В самом простом случае, пользователь видит такой список ссылок, нажимает на один из элементов и получает текст, указанный в InputTextMessageContent.
Сложно? Но и это ещё не всё! InputMessageContent можно использовать и с другими типами инлайн-объектов, например, выдавать пользователю в ответ стикеры, а при нажатии отправлять ссылку на весь стикерпак. Или описание фильма при нажатии на обложку в инлайн-режиме. Экспериментируйте!
Пишем бота¶
От теории — к практике. Опишем суть будущего бота: пользователь в диалоге с ботом добавляет (или обновляет) ссылку на видео с YouTube и указывает собственное описание. Далее в любом другом чате он вызывает бота в инлайн-режиме, выбирает в списке одно из сохранённых ранее видео и отправляет его. Дополнительно можно ввести текст, по наличию которого результаты будут отфильтрованы. Разумеется, у каждого юзера должен быть свой собственный набор сохранённых ссылок.
Предупреждение об используемых технологиях
Поскольку главная цель этой главы — рассказать об инлайн-режиме, то сопуствующие детали, такие как реализация работы с базой данных и хранилищем FSM, будут сознательно упрощены и/или не упомянуты в тексте. Так, например, в качестве FSM будет использован MemoryStorage, а в роли СУБД подойдёт и SQLite. В реальности рекомендуется использовать персистентное хранилище FSM (напр. Redis) и более продвинутую СУБД (напр. PostgreSQL), а также отдельный поисковый движок (ElasticSearch, Sonic...).
На забудьте включить боту инлайн-режим!
По умолчанию ботов нельзя использовать в инлайн-режиме. Чтобы его включить, необходимо перейти в личку с
@BotFather, далее /mybots
→ выбираете своего бота → Bot Settings → Inline Mode →
Turn on. При необходимости можно поменять заглушку с "Search..." на что-то поприятнее глазу. И лучше всего
перезапустить приложение Telegram, иначе клиент может закэшировать отсутствие инлайн-режима у бота.
Хранение данных¶
Исходя из приведённого выше «техзадания», сделаем вывод, что каждое сохранённое видео можно описать тремя обязательными сущностями: Telegram ID пользователя, идентификатор YouTube-видео (извлекается из URL) и пользовательское описание. Первые два элемента образуют первичный ключ, что даёт ограничение уникальности для каждой пары «Telegram_ID + YouTube_ID». Описание таблицы в БД выглядит следующим образом:
CREATE TABLE IF NOT EXISTS "youtube" (
"user_id" INTEGER NOT NULL,
"youtube_hash" TEXT NOT NULL,
"description" TEXT NOT NULL,
PRIMARY KEY("user_id","youtube_hash")
);
Ранее мы договорились, что не будем особо рассматривать работу с БД, и сконцетрируемся на особенностях инлайн-режима,
однако две функции всё же стоит рассмотреть для дальнейшего понимания. Первая — добавление ссылки с описанием в базу
данных. Здесь можно применить один трюк, называемый "UPSERT", т.е. Insert or Update. Дело в том, что, сформировав
SQL-запрос специальным образом, можно при вставке данных (Insert) в случае нарушения уникальности не выбрасывать ошибку,
а неявно обновлять затронутую строку (Update). Посмотрите на следующий кусок кода. В нём мы пытаемся
добавить новую строку в базу данных, однако если указанная пара «Telegram_ID + YouTube_ID»
уже существует, то вместо вставки обновляется описание (description
):
def insert_or_update(user_id: int, youtube_hash: str, description: str):
statement = "INSERT INTO youtube (user_id, youtube_hash, description) " \
"VALUES (:user_id, :youtube_hash, :description) " \
"ON CONFLICT(user_id, youtube_hash) " \
"DO UPDATE SET description = :description"
cursor.execute(statement, {
"user_id": user_id,
"youtube_hash": youtube_hash,
"description": description
})
cursor.connection.commit()
Вторая важная функция — получение списка ссылок. Поскольку пользователь может захотеть отфильтровать выдачу,
появляется второй необязательный аргумент search_query
. При его наличии в конец SQL-запроса будет добавлен
поиск подстроки с ключевым словом LIKE
. Вспомним, что в реальных условиях такой подход вряд ли сгодится из-за низкой
эффективности и точности поиска, но для демонстрации будет достаточно:
def get_links(user_id: int, search_query: str = None):
statement = "SELECT youtube_hash, description from youtube WHERE user_id = ?"
if search_query:
statement += f" AND description LIKE ?"
result = cursor.execute(statement, (user_id, f"%{search_query}%"))
else:
result = cursor.execute(statement, (user_id,))
return result.fetchall()
Switch-кнопки¶
Просмотр и удаление ссылок в личке с ботом делается элементарно, а добавление организуется с помощью простейшего конечного автомата (подробнее о механизме FSM можно узнать в предыдущей главе). Здесь стоит обратить внимание на последний шаг, когда идёт непосредственно добавление записи и подтверждение юзеру:
async def description_added(message: types.Message, state: FSMContext):
# Получение информации из FSM
user_data = await state.get_data()
# Вставка данных в БД
dbworker.insert_or_update(message.from_user.id, user_data["yt_hash"], message.text)
# Создание клавиатуры со switch-кнопками и отправка сообщения
switch_keyboard = types.InlineKeyboardMarkup()
switch_keyboard.add(types.InlineKeyboardButton(
text="Попробовать",
switch_inline_query=""))
switch_keyboard.add(types.InlineKeyboardButton(
text="Попробовать здесь",
switch_inline_query_current_chat=""))
await message.answer(
"Ссылка и описание успешно добавлены в инлайн-режим и "
"станут доступны в течение пары минут!\n"
"Полный список сохранённых ссылок: /links",
reply_markup=switch_keyboard)
await state.finish()
Ранее в главе про кнопки мы познакомились с двумя типами инлайн-кнопок: URL и Callback. Для инлайн-режима
полезен третий тип: Switch. В коде выше объявлены две кнопки, одна с пустым параметром switch_inline_query
, другая — с
пустым switch_inline_query_current_chat
. Первая предлагает выбрать чат, а затем подставляет в поле ввода юзернейм бота
для активации инлайн-режима, вторая делает то же самое, но в текущем чате (в нашем случае — в личке с ботом). Если
параметры сделать непустыми и указать какой-либо текст, то он будет добавлен к юзернейму бота, например:
switch_inline_query="text"
-> @bot text
в поле ввода в другом чате. Так, например, работает бот @like,
позволяющий сразу отправить свежесозданный пост куда-нибудь.
Обработка инлайн-запроса¶
Перейдём непосредственно к обработке запроса от юзера к боту в инлайн-режиме. В некоторых случаях удобно делать отдельный
хэндлер на пустой запрос (когда длина текста в поле query
объекта
InlineQuery равно нулю) и показывать какой-нибудь «общий» ответ или
приглашение перейти к боту, однако в нашем случае любой, даже пустой запрос, требует обращения к БД, поэтому объединим
оба случая в одном хэндлере. Рассмотрим первый вариант: у пользователя нет сохранённых ссылок или он ввёл запрос,
по которому ничего не нашлось. Смотрим код:
async def inline_handler(query: types.InlineQuery):
# Получение ссылок пользователя с опциональной фильтрацией (None, если текста нет)
user_links = dbworker.get_links(query.from_user.id, query.query or None)
if len(user_links) == 0:
# Выбор текста для подписи над результатами
switch_text = "У вас нет сохранённых ссылок. Добавить »»" \
if len(query.query) == 0 \
else "Не найдено ссылок по данному запросу. Добавить »»"
return await query.answer(
[], cache_time=60, is_personal=True,
switch_pm_parameter="add", switch_pm_text=switch_text)
Остановимся подробнее на этом отрывке кода. На изображении выше, где рассматривали объект InlineQueryResultArticle, сверху
можно было заметить строку "Добавить ссылку »»", это т.н. «swtich-объект», позволяющий перейти непосредственно в личку
с ботом, передав сразу команду /start
с дополнительным параметром, в нашем случае это add
, т.е. бот получит
сообщение с текстом /start add
и сможет соответствующим образом отреагировать. А в самом низу кода мы вызываем метод
answerInlineQuery
, с пустым массивом объектов-результатов, описанием switch-объекта, а также ещё двумя параметрами:
cache_time
и is_personal
. Первый отвечает за время кэширования результатов на серверах Telegram (в секундах)
и по умолчанию имеет значение 300 (5 минут). Это значит, что если пользователь в течение указанного периода вызовет
инлайн-бота с одним и тем же запросом (или даже пустым), то Telegram не будет перенаправлять его боту, а сразу ответит
значением из кэша. Второй параметр, is_personal
, делает кэширование уникальным для каждого пользователя, персонифицируя
результаты.
Автор этих строк однажды забыл указать флаг is_personal
в его боте @my_id_bot,
выставил кэш на 86400 секунд (1 сутки) и выслушал много возмущений от пользователей, отправлявших его ID вместо их
собственных. Учитесь на чужих ошибках, не на своих.
Теперь рассмотрим второй случай: по запросу пользователя нашлись какие-то ссылки. Сформируем массив объектов-результатов типа InlineQueryResultArticle:
user_links = dbworker.get_links(query.from_user.id, query.query or None)
# В случае успеха переменная выше содержит массив результатов, каждый
# из который сам является массивом и содержит YouTube-хэш и описание
articles = [types.InlineQueryResultArticle(
id=item[0],
title=item[1],
# В общем случае, описание (description) может быть произвольным
description=f"https://youtu.be/{item[0]}",
url=f"https://youtu.be/{item[0]}",
hide_url=False,
thumb_url=f"https://img.youtube.com/vi/{item[0]}/1.jpg",
input_message_content=types.InputTextMessageContent(
message_text=f"<b>{quote_html(item[1])}</b>\nhttps://youtu.be/{item[0]}",
parse_mode="HTML"
)
) for item in user_links]
По некоторым полям мы прошлись выше, рассмотрим остальные: id
— уникальный идентификатор объекта-результата,
причём эта уникальность должна быть для всей пачки ответов, включая дополнительно подгруженные (об этом ниже), в нашем
случае очень удобно использовать хэш YouTube-видео, hide_url
— флаг, определяющий видимость ссылки (url
)
в выдаче, thumb_url
— ссылка на изображение для предпросмотра, если не указано, то Telegram покажет заглушку, дополнительно
можно указать высоту (thumb_height
) и ширину (thumb_width
) превью. А вот за содержимое конечного сообщения отвечает
аргумент input_message_content
, которому назначен объект InputTextMessageContent
. Обратите внимание на обрамление
описания в вызов quote_html()
для экранирования возможных нехороших символов, ломающих разметку.
Итого полный текст инлайн-обработчика будет выглядеть следующим образом:
async def inline_handler(query: types.InlineQuery):
user_links = dbworker.get_links(query.from_user.id, query.query or None)
if len(user_links) == 0:
switch_text = "У вас нет сохранённых ссылок. Добавить »»" \
if len(query.query) == 0 \
else "Не найдено ссылок по данному запросу. Добавить »»"
return await query.answer(
[], cache_time=60, is_personal=True,
switch_pm_parameter="add", switch_pm_text=switch_text)
articles = [types.InlineQueryResultArticle(
id=item[0],
title=item[1],
description=f"https://youtu.be/{item[0]}",
url=f"https://youtu.be/{item[0]}",
hide_url=False,
thumb_url=f"https://img.youtube.com/vi/{item[0]}/1.jpg",
input_message_content=types.InputTextMessageContent(
message_text=f"<b>{quote_html(item[1])}</b>\nhttps://youtu.be/{item[0]}",
parse_mode="HTML"
)
) for item in user_links]
await query.answer(articles, cache_time=60, is_personal=True,
switch_pm_text="Добавить ссылку »»", switch_pm_parameter="add")
А вы знали, что, имея YouTube-хэш, можно легко получить различные превью к видео? Если нет, то добро пожаловать в этот чудесный пост на StackOverflow.
Switch туда и обратно¶
Как мы уже выяснили чуть выше, switch_pm_parameter
подставляется как start-параметр с переходом в личку с ботом
(на языке Bot API это называется Deep Linking). А у нашего бота
на команду /add
навешан трёхэтапный процесс добавления ссылки (в этом тексте не рассматривается, см. исходники к главе).
Пускай тот же процесс вызывается ещё и по Deep-линку /start add
, для этого зарегистрируем первый этап добавления ссылки
по двум разным триггерам:
dp.register_message_handler(cmd_add_link, commands="add", state="*")
dp.register_message_handler(cmd_add_link, CommandStart(deep_link="add"), state="*")
А как вы помните, на последнем этапе добавления пользователю отправляются две switch-кнопки. И здесь кроется
одна любопытная фича Telegram: если юзер перешёл в личку с ботом по switch_pm
-параметру, а затем получит сообщение
со switch_inline_query
-кнопкой, то он автоматически вернётся обратно, минуя выбор чата, как при обычном нажатии. Причём
это работает даже если сообщение с кнопкой отправлено не сразу, а как в нашем примере, через пару шагов по FSM.
На этом с ботом всё, исходные тексты к главе смотрите в репозитории, доступным по нажатию на кнопку в правом верхнем углу. Но прежде, чем закончить с инлайн-режимом, надо рассмотреть ещё пару интересных особенностей...
Дополнительные материалы¶
Подгрузка результатов¶
Согласно документации Bot API, в одном вызове answerInlineQuery
можно отправить не более 50 элементов. Но что если нужно больше? На этот случай пригодится параметр next_offset
. Его
указывает сам бот, и это же значение прийдёт в следующем инлайн-запросе, когда пользователь пролистает всю текущую пачку.
Для примера напишем простой генератор чисел, возвращающий пачки по 50 элементов, но с максимальным значением 195:
def get_fake_results(start_num: int, size: int = 50):
overall_items = 195
# Если результатов больше нет, отправляем пустой список
if start_num >= overall_items:
return []
# Отправка неполной пачки (последней)
elif start_num + size >= overall_items:
return list(range(start_num, overall_items+1))
else:
return list(range(start_num, start_num+size))
Теперь давайте перепишем наш инлайн-хэндлер таким образом, чтобы при приближении к концу текущего списка запрашивать
продолжение. Для этого в начале проверяем поле offset
и ставим его равным единице, если оно пустое. Далее генерируем
фейковый список результатов. Если на выходе ровно 50 объектов, то в ответе указываем next_offset
равный текущему
значению + 50. Если объектов меньше, то его делаем пустой строкой, чтобы Telegram больше не присылал запросы боту.
async def inline_handler(query: types.InlineQuery):
# Высчитываем offset как число
query_offset = int(query.offset) if query.offset else 1
results = [types.InlineQueryResultArticle(
id=str(item_num),
title=f"Объект №{item_num}",
input_message_content=types.InputTextMessageContent(
message_text=f"Объект №{item_num}"
)
) for item_num in get_fake_results(query_offset)]
if len(results) < 50:
# Результатов больше не будет, next_offset пустой
await query.answer(results, is_personal=True, next_offset="")
else:
# Ожидаем следующую пачку
await query.answer(results, is_personal=True, next_offset=str(query_offset+50))
По мере листания инлайн-результатов, бот будет получать запросы и возвращать всё новые и новые результаты, пока не дойдёт до 195-го элемента, дальше запросы прекратятся.
Сбор статистики¶
Мало кто знает, но Telegram позволяет собирать простенькую статистику по использованию бота в инлайн-режиме. Для начала
требуется включить соответствующую настройку у @BotFather: /mybots
- (выбрать бота) - Bot Settings - Inline Feedback:
Числа на кнопках означают вероятность получения события ChosenInlineResult при выборе пользователем какого-либо объекта в инлайн-режиме. Так, например, если выставлено значение 10%, то при каждом выборе объекта существует вероятность в десять процентов получить событие ChosenInlineResult в боте. Выставлять значение 100% Telegram не рекомендует из-за удвоения нагрузки на бота. Таким образом, для сколько-нибудь серьёзной аналитики подобная фича не подходит, но в умелых руках и за большой период времени может дать общее представление о наиболее полезных инлайн-результатах. Пример хэндлера на подобные события:
@dp.chosen_inline_handler()
async def chosen_handler(chosen_result: types.ChosenInlineResult):
logging.info(f"Chosen query: {chosen_result.query}"
f"from user: {chosen_result.from_user.id}")