Перейти к содержанию

Инлайн-режим

Старая версия aiogram

Для aiogram 3.x эта глава расположена здесь.

Введение

В предыдущих главах бот и человек общались каждый сам за себя, однако в Telegram существует специальный режим, позволяющий пользователю отправить информацию от своего имени, но с помощью бота. Это называется инлайн-режим (Inline mode). Как это выглядит в реальной жизни:

Пример работы бота @imdb в инлайн-режиме

Как видно на скриншоте выше, итоговое сообщение отправлено от имени пользователя, но предварительный список был предоставлен ботом @imdb. Подробное описание инлайн-режима с точки зрения пользователя можно найти на официальном сайте, а в этой главе мы напишем собственного простого инлайн-бота для упрощения поиска и отправки ссылок на любимые YouTube-видео.

Формат инлайн-запросов и ответов

Когда пользователь вызывает бота в инлайн-режиме, введя его юзернейм в поле ввода, бот получает апдейт с типом InlineQuery, из которого нам важно поле from (from_user в aiogram), содержащее тип User с информацией о юзере, вызвавшем бота, а также поле query, т.е. текст запроса (может быть пустым). К сожалению, в настоящий момент нет возможности узнать, в каком чате был вызван инлайн-бот, вероятно, это сделано специально для повышения приватности пользователей, т.к. бота необязательно добавлять в группу или канал, чтобы использовать его в инлайн-режиме.

Для ответа на запрос необходимо вызвать метод answerInlineQuery, куда следует передать массив объектов-результатов, и дополнительно различные флаги, о которых поговорим чуть позже. Типов объектов-результатов аж целых двадцать, однако многие из них являются вариациями друг друга. Так, например, InlineQueryResultPhoto содержит ссылку (URL) на изображение, а InlineQueryResultCachedPhotofile_id уже загруженного в Telegram изображения. Более того, рекомендуется использовать объекты одного типа в инлайн-ответе, поскольку клиентские приложения некорректно отображают (или не отображают вовсе) смешанный контент.

Обратите внимание: в инлайн-режиме нельзя загружать новые медиафайлы в Telegram, только использовать уже имеющиеся в облаке, либо указывать URL из Интернета.

Одним из наиболее часто используемых типов объектов-результатов является InlineQueryResultArticle — просто текст. Рассмотрим основные элементы такого объекта внимательнее:

Пример работы бота @imdb в инлайн-режиме

Цифрами на рисунке выше обозначены: 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:

Пример работы бота @imdb в инлайн-режиме

Числа на кнопках означают вероятность получения события 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}")