Содержание урока · 11 разделов
- InlineQuery: апдейт без сообщения
- AnswerInlineQuery и статьи
- CacheTime и IsPersonal
- Кейс: поиск по словарю терминов
- Вариация: форматированные результаты
- Типичная ошибка: тяжёлый поиск на каждую букву
- Поля InlineQuery
- ChosenInlineResult: что выбрал пользователь
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 в цикле обновлений
GOfor 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 хранит результаты в кэше и не ждёт вечности.
Ответ тремя подсказками
GOfunc 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 при этом не изменится.
Поиск по словарю и ответ
GOtype 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. Для учебного словаря хватает первых двух слоёв, но привычка считать запросы на каждую букву экономит потом ночные инциденты с настоящей базой.
Фильтр словаря, срезы, 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-режима бота?
Проверка: тишина в ответ на запросы
Обработчик InlineQuery корректен, но подсказки не приходят. Первое, что стоит проверить?
Сколько подсказок найдёт фильтр
Прочитайте код и выберите, что он выведет:
terms := []string{"map", "slice", "make", "maps"}
q := "ma"
found := 0
for _, t := range terms {
if strings.Contains(t, q) {
found++
}
}
fmt.Println(found)Соберите ветку inline-ответа
Впишите проверку апдейта, конструктор статьи и метод отправки ответа.
Шесть строк inline-ответа
Расставьте ответ на запрос: от чтения апдейта до отправки результата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: inline-режим
Бот появился в поле поиска любого чата — следующий шаг по расширению присутствия: Mini Apps открывают внутри Telegram полноценный веб-интерфейс, а рассылки из четырнадцатой части остаются главным способом вернуться к пользователю, который подсказками только пользуется. Механику нажатий и callback-ответов напомнит урок про inline-клавиатуры, кэширование результатов поиска — тема урока про кэширование в go-redis. Полный маршрут курса — на его странице.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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?
Ответ может содержать результаты разных типов — статьи, фото, документы, — и сигнатура библиотеки принимает их одним срезом интерфейсов. Статьи кладутся туда без проблем, при смешанных списках просто добавляются конструкторы разных типов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99