---
title: "Inline-режим: бот в поле поиска"
description: "Inline-режим телеграм-бота на Go: InlineQuery в поле поиска, AnswerInlineQuery, CacheTime и ChosenInlineResult. Включаем подсказки бота через BotFather."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/15-inline-rezhim/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 18
---

# Inline-режим: бот в поле поиска

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 15 из 25. ~18 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/15-inline-rezhim/*

Inline-режим выносит бота из чата в поле ввода: пользователь печатает @имябота запрос в любом диалоге, а InlineQuery и AnswerInlineQuery на Go превращают это в подсказки-карточки, которые можно вставить одним нажатием. Разберём апдейт InlineQuery, статьи AnswerInlineQuery, кэш результатов и включение режима через BotFather.

### InlineQuery: апдейт без сообщения

Бот-словарь терминалов Go живёт в чате, и чтобы посмотреть значение слова, пользователь должен открыть диалог, написать команду, прочитать ответ — четыре шага ради справки. Inline-режим сжимает их до двух: в любом чате, даже в группе с друзьями, человек набирает @имябота и первые буквы термина, а над полем ввода появляются карточки-подсказки; нажатие вставляет готовый текст в сообщение. Это тот же механизм, которым пользуются @gif и @pic при поиске картинок, и он меняет роль бота: из собеседника он становится службой подсказок, доступной из поля ввода каждого чата. Механика на Go держится на двух объектах: входящий InlineQuery и ответ AnswerInlineQuery со списком результатов. Ниже — включение режима, ветка в цикле обновлений, статьи с кэшированием и честный разбор того, почему бот с работающим кодом может молчать.

Пользователь начал набирать @имябота — Telegram присылает в цикл обновлений объект InlineQuery, у которого Message равен nil: это не сообщение в чат, а запрос подсказок. Рабочие поля четыре. ID — идентификатор запроса, на него надо ответить. From — кто ищет: для персонализации и лимитов. Query — сам поисковый запрос, обрезанный по текущей позиции курсора. Offset — строка пагинации для длинных списков. Первым делом режим надо включить: в BotFather команда /setinline, выбор бота и подсказка-плейсхолдер для поля ввода — без этого апдейты InlineQuery не приходят вовсе, и код будет безошибочно молчать. Ответ обязателен на каждый запрос, включая пустой Query: пользователь стёр буквы — бот должен вернуть пустой список, иначе клиент держит старые подсказки. Ветка в цикле ставится рядом с проверкой CallbackQuery из девятого урока: update.InlineQuery != nil — своя ветка со своим обработчиком.

### Ветка inline в цикле обновлений

```go
for update := range updates {
        if update.InlineQuery != nil {
                handleInline(bot, update.InlineQuery)
                continue
        }
        if update.CallbackQuery != nil {
                handleCallback(bot, update.CallbackQuery)
                continue
        }
        if update.Message == nil {
                continue
        }
        handleMessage(bot, update.Message)
}

func handleInline(bot *tgbotapi.BotAPI, iq *tgbotapi.InlineQuery) {
        log.Printf("inline: %q от %d", iq.Query, iq.From.ID)
}
```

Порядок проверок одинаковый: спец-апдейты первыми, сообщение последним. InlineQuery проверяется раньше CallbackQuery — порядок между ними не важен, важно, что оба раньше Message, потому что у них update.Message равен nil. Обработчик пока только логирует запрос и автора: ответ на iq.ID добавим следующим шагом.

### AnswerInlineQuery и статьи

Ответ строится из списка результатов, а простейший результат — статья: NewInlineQueryResultArticle(id, title, text) создаёт карточку с заголовком и текстом, который вставится в поле ввода при выборе. Первый аргумент — короткий идентификатор результата: он нужен, чтобы потом узнать выбранный вариант в ChosenInlineResult, и обычно собирается как индекс или ключ записи. Одним ответом уходит до пятидесяти результатов — для подсказок хватает трёх-пяти, длинные списки режутся по Offset. Отправляет ответ bot.AnswerInlineQuery с InlineQueryConfig, где InlineQueryID равен iq.ID, а Results — срез интерфейсов. Тонкость: Results имеет тип []interface{}, и каждый результат придётся приводить к этому типу при добавлении — особенность сигнатуры библиотеки v5. Ответ должен уйти быстро: клиент показывает подсказки только живому ответу, а Telegram хранит результаты в кэше и не ждёт вечности.

### Ответ тремя подсказками

```go
func handleInline(bot *tgbotapi.BotAPI, iq *tgbotapi.InlineQuery) {
        if iq.Query == "" {
                return
        }

        r1 := tgbotapi.NewInlineQueryResultArticle(
                "1", "Карта: map", "map[string]int — словарь ключ-значение")
        r2 := tgbotapi.NewInlineQueryResultArticle(
                "2", "Срез: slice", "[]int — динамический массив")
        r3 := tgbotapi.NewInlineQueryResultArticle(
                "3", "Канал: chan", "chan int — обмен между горутинами")

        cfg := tgbotapi.InlineQueryConfig{
                InlineQueryID: iq.ID,
                Results:       []interface{}{r1, r2, r3},
        }
        bot.AnswerInlineQuery(cfg)
}
```

На пустой запрос бот не отвечает — старые подсказки у клиента и так исчезают. Три статьи: у каждой свой идентификатор, заголовок и текст для вставки. Срез Results объявлен как []interface{} — статьи добавляются в него без проблем, а ответ уходит по InlineQueryConfig с ID исходного запроса.

> **Код работает, а подсказок нет.** Самая частая загадка новичка: цикл живой, ошибок нет, но @бот в поле ввода не отвечает. В девяти случаях из десяти inline-режим не включён: без /setinline в BotFather Telegram не присылает InlineQuery вообще. Второй случай — ответ уходит не на тот ID или с пустым Results: клиент молча игнорирует битый ответ. Проверяйте включение режима до отладки кода.

### CacheTime и IsPersonal

У InlineQueryConfig есть поля, которые управляют тем, как Telegram кэширует и переиспользует ответы. CacheTime — срок жизни ответа в секундах: по умолчанию Telegram держит результаты запроса пять минут и не дёргает бота повторно на тот же запрос от того же пользователя. Для словаря терминов это подарок: статичные подсказки отдаются мгновенно, нагрузка на бота падает в разы. Для живых данных — курса валют, остатков на складе, свободных слотов — кэш вредит, и CacheTime ставят в единицу или ноль. IsPersonal делает результаты приватными: без него один ответ может быть показан участникам чата, где искали — неважно для терминов, критично для личных документов и цен. Offset и его разбор вместе с ChosenInlineResult закрывают продвинутые сценарии: пагинация и статистика выбора — но и без них бот-словарь уже полноценный.

### Кейс: поиск по словарю терминов

Свяжем механику с реальным сценарием. Словарь — срез структур Term с полями Name и Meaning, штук сорок-пятьдесят для учебного проекта. Обработчик фильтрует его по iq.Query: strings.Contains(strings.ToLower(t.Name), strings.ToLower(iq.Query)) — регистр приводится с двух сторон, иначе «КАРТА» не найдёт карту. На первые три совпадения создаются статьи: идентификатор — индекс в словаре, заголовок — термин, вставляемый текст — значение. Кэш CacheTime 300 не мешает: словарь меняется с релизами, а не с секундами. Логика поиска — чистый Go над строками и срезами, и она полноценно тестируется без Telegram: unit-тесты на фильтр ловят и регистр, и пустой запрос, ещё до первого запуска бота. Когда словарь вырастет за сотню записей, фильтр по памяти переедет в Redis с поиском по множествам — механика inline при этом не изменится.

### Поиск по словарю и ответ

```go
type Term struct {
        Name    string
        Meaning string
}

var terms = []Term{
        {"Карта", "map[K]V — словарь ключ-значение"},
        {"Срез", "[]T — динамический массив"},
        {"Канал", "chan T — обмен между горутинами"},
}

func handleInline(bot *tgbotapi.BotAPI, iq *tgbotapi.InlineQuery) {
        q := strings.ToLower(iq.Query)
        results := make([]interface{}, 0, 3)

        for i, t := range terms {
                if strings.Contains(strings.ToLower(t.Name), q) {
                        r := tgbotapi.NewInlineQueryResultArticle(
                                fmt.Sprintf("term-%d", i),
                                t.Name, t.Meaning)
                        results = append(results, r)
                }
                if len(results) == 3 {
                        break
                }
        }

        cfg := tgbotapi.InlineQueryConfig{
                InlineQueryID: iq.ID,
                Results:       results,
                CacheTime:     300,
        }
        bot.AnswerInlineQuery(cfg)
}
```

Фильтр нечувствителен к регистру с двух сторон: и запрос, и термин приводятся к нижнему. Идентификатор результата собирается из префикса и индекса — по нему ChosenInlineResult потом опознаёт выбранную запись. Лимит в три результата останавливает проход раньше полного перебора словаря, а CacheTime 300 включает штатное кэширование.

### Вариация: форматированные результаты

Статья из конструктора вставляет плоский текст, а для подсказок с разметкой есть второй конструктор — NewInlineQueryResultArticleHTML: вставляемый текст разбирается как HTML, и в чат уходит сообщение с жирным, курсивом и кодом из тринадцатого урока. Пример словаря выигрывает сразу: значение термина с кодом в теге code выглядит в чате как настоящий фрагмент документации, а не как строка с квадратными скобками. У каждого результата есть ещё два полезных поля: Description — серая строка под заголовком, куда уходит усечённое значение термина, и ThumbnailURL — миниатюра слева от карточки. Один нюанс безопасности наследуется из урока про форматирование: в разметку результата попадает пользовательский запрос? экранируйте его тем же html.EscapeString, иначе скобка из поиска сломает вставку у получателя сообщения.

### Типичная ошибка: тяжёлый поиск на каждую букву

Клиент шлёт InlineQuery на каждое изменение поля: пользователь набрал слово из восьми букв — бот получил восемь запросов, включая одиночную m. Учебный фильтр по сорока терминам это переживёт усмехаясь, но словарь на тысячи записей с запросом в базу на каждую букву положит и Redis, и лимиты API. Лечение многослойное. Сначала честный порог: запросы короче двух букв отвечают пустым списком — клиент всё равно показывает мусор, который хуже отсутствия подсказок. Второй слой — CacheTime: уже включённое кэширование съедает повторные запросы той же строки. Третий — счётчик: тяжёлый поиск оборачивается таймером, который не даёт чаще одного запроса в секунду на пользователя, механика та же, что в rate limiter. Для учебного словаря хватает первых двух слоёв, но привычка считать запросы на каждую букву экономит потом ночные инциденты с настоящей базой.

> **Половина урока тренируется без Telegram.** Фильтр словаря, срезы, strings.Contains — чистый Go, и поиск можно доводить в песочнице или тестах. Живой inline-режим проверяется только в настоящем клиенте: локальный Go, реальный токен, включённый /setinline и ввод @имябота в поле поиска любого чата.

### Поля InlineQuery

| Поле | Тип | Что содержит |
| --- | --- | --- |
| ID | string | Идентификатор запроса — адрес ответа AnswerInlineQuery |
| From | *User | Кто ищет: ID, имя, username |
| Query | string | Поисковый запрос после @имябота |
| Offset | string | Метка пагинации для длинных списков |

### ChosenInlineResult: что выбрал пользователь

AnswerInlineQuery отдаёт подсказки, но не говорит, чем всё кончилось: выбрал ли человек вариант или стёр запрос. Обратная связь приходит отдельным апдейтом — ChosenInlineResult с полями From, ResultID и Query. Для его получения в BotFather включается /setinlinefeedback, и в цикле появляется третья ветка спец-апдейтов. Зачем это боту-словарю: статистика — какие термины ищут, какие подсказки реально вставляют; на этих числах живут и сортировка результатов, и решение о новых статьях словаря. Формат простой: по ResultID — а мы клали туда term-с-индексом — наращиваем счётчик в Redis, и через неделю видно топ терминов. Без включённого фидбека бот тоже работает, просто вслепую: подсказки уходят в никуда, и качество поиска растёт только усилиями автора. Это тот же принцип, что и AnswerCallbackQuery из девятого урока: телеметрия нажатий — половина ценности интерактивных режимов.

### Проверка: где работает inline

**Вопрос:** Где пользователь видит результаты inline-режима бота?

- Только в личном чате с ботом
- ✅ Над полем ввода в любом чате, где он набирает @имябота
- В закреплённом сообщении группы
- В отдельном приложении бота

Inline-режим не требует заходить в диалог с ботом: подсказки появляются над полем ввода любого чата при наборе имени бота. Нажатие вставляет текст результата в сообщение этого чата.

### Проверка: тишина в ответ на запросы

**Вопрос:** Обработчик InlineQuery корректен, но подсказки не приходят. Первое, что стоит проверить?

- Версию Go на сервере
- ✅ Включён ли inline-режим у бота в BotFather
- Длину имени бота
- Количество подписчиков

Без /setinline в BotFather Telegram вообще не присылает InlineQuery — код не выполняется и ошибок не даёт. Включение режима с плейсхолдером возвращает бота к жизни без единой правки кода.

### Сколько подсказок найдёт фильтр

```go
terms := []string{"map", "slice", "make", "maps"}
q := "ma"
found := 0

for _, t := range terms {
        if strings.Contains(t, q) {
                found++
        }
}

fmt.Println(found)
```

**Что выведет программа?**

- 2
- ✅ 3
- 4
- 0

strings.Contains находит ma в map, make и maps — slice не содержит подстроку, счётчик равен 3. Тот же фильтр с ToLower с двух сторон стоит в обработчике inline-запросов словаря из кейса урока.

### Соберите ветку inline-ответа

Впишите проверку апдейта, конструктор статьи и метод отправки ответа.

```go
if update.___ != nil {
        iq := update.InlineQuery
        r := tgbotapi.___("1", "Карта", "map[K]V")
        cfg := tgbotapi.InlineQueryConfig{
                InlineQueryID: iq.ID,
                Results:       []interface{}{r},
        }
        bot.___(cfg)
}
```

Подсказка: Апдейт проверяется по полю InlineQuery, статья создаётся NewInlineQueryResultArticle, ответ уходит через AnswerInlineQuery.

Правильные ответы в пропусках: `InlineQuery`, `NewInlineQueryResultArticle`, `AnswerInlineQuery`

### Шесть строк inline-ответа

Расставьте ответ на запрос: от чтения апдейта до отправки результата.

Правильный порядок строк:

1. `iq := update.InlineQuery`
2. `r := tgbotapi.NewInlineQueryResultArticle("1", "Карта", "map[K]V")`
3. `cfg := tgbotapi.InlineQueryConfig{InlineQueryID: iq.ID}`
4. `cfg.Results = []interface{}{r}`
5. `bot.AnswerInlineQuery(cfg)`
6. `log.Println("answered:", iq.Query)`

### Карточки: inline-режим

- **InlineQuery** — Апдейт запроса подсказок: ID, From, Query, Offset. Приходит, когда пользователь набирает @имябота в поле ввода любого чата.
- **AnswerInlineQuery** — Ответ на запрос: до 50 результатов в InlineQueryConfig по ID запроса. Обязателен на каждый запрос, включая пустой.
- **NewInlineQueryResultArticle** — Простейший результат: идентификатор, заголовок и текст, который вставится в сообщение при выборе.
- **/setinline** — Команда BotFather, включающая inline-режим: без неё InlineQuery не приходят вовсе, при том что код безошибочен.
- **CacheTime** — Срок кэша результатов в секундах, по умолчанию пять минут. Статичным подсказкам помогает, живым данным вредит.
- **ChosenInlineResult** — Апдейт о выбранной подсказке с ResultID: включается /setinlinefeedback, даёт статистику реального выбора.

Бот появился в поле поиска любого чата — следующий шаг по расширению присутствия: [Mini Apps](/courses/telegram-bot/lessons/21-mini-apps-obzor/) открывают внутри Telegram полноценный веб-интерфейс, а [рассылки из четырнадцатой части](/courses/telegram-bot/lessons/14-rassylki/) остаются главным способом вернуться к пользователю, который подсказками только пользуется. Механику нажатий и callback-ответов напомнит [урок про inline-клавиатуры](/courses/telegram-bot/lessons/09-inline-klaviatura/), кэширование результатов поиска — тема [урока про кэширование в go-redis](/courses/go-redis/lessons/06-keshirovanie-zaprosov/). Полный маршрут курса — на [его странице](/courses/telegram-bot/).

## Коротко: что запомнить

- Inline-режим отвечает на @имябота в поле ввода любого чата: бот становится службой подсказок.
- InlineQuery — апдейт без Message: ветка update.InlineQuery != nil ставится перед веткой сообщений.
- Ответ AnswerInlineQuery обязателен на каждый запрос, включая пустой, и несёт до 50 результатов-статей.
- Режим включается /setinline в BotFather: без него апдейты не приходят при полностью рабочем коде.
- CacheTime кэширует ответы по умолчанию на пять минут: статичным словарям на пользу, живым данным во вред.
- IsPersonal прячет результаты от участников чата — обязательно для личных данных.
- ChosenInlineResult с включённым /setinlinefeedback рассказывает, какие подсказки реально выбирают.

## Домашнее задание

Включите inline-режим у локального бота через /setinline и соберите словарь из пяти терминов Go: фильтр по Query с ToLower, до трёх статей в ответе, CacheTime 300. Проверьте в любом чате: ввод @имябота с частью термина показывает подсказки, нажатие вставляет значение, пустой запрос оставляет поле чистым. Затем включите /setinlinefeedback и посмотрите в логах, какой ResultID выбирают.

## Вопросы и ответы по уроку

### Можно ли менять подсказки в зависимости от пользователя?

Да: From даёт ID, и для каждого пользователя формируется свой список — плюс IsPersonal прячет результаты от других участников чата. Для общих словарей персонализация обычно лишняя, а для личных документов обязательна.

### Какие типы результатов бывают, кроме статей?

Bot API поддерживает фото, видео, GIF, аудио, документы и голосовые результаты — в библиотеке у каждого свой конструктор вида NewInlineQueryResultPhoto. Статья остаётся самым простым типом и покрывает большинство текстовых сценариев.

### Почему у Results тип []interface{}, а не []InlineQueryResultArticle?

Ответ может содержать результаты разных типов — статьи, фото, документы, — и сигнатура библиотеки принимает их одним срезом интерфейсов. Статьи кладутся туда без проблем, при смешанных списках просто добавляются конструкторы разных типов.
