Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Документация Yandex Bot API (Golang)

yandex-bot-api — библиотека для разработки ботов в Яндекс Мессенджере на языке Go.

Предоставляет удобное взаимодействие с API Яндекс Мессенджера, роутер, пагинацию и управление состояниями (анкетами).

Что внутри

  • 100% Покрытие Yandex API: Сообщения, чаты, опросы, файлы/медиа, вебхуки, пользователи.
  • Роутер & FSM: Обработка команд, кнопок, состояний (анкет) и встроенный сборщик мусора по TTL.
  • Отказоустойчивость: Экспоненциальный backoff с jitter для Long-Polling и маскирование OAuth-токенов в логах (***REDACTED***).
  • Интерфейс & DX: Построение клавиатур через KeyboardBuilder, безопасные геттеры и пагинация.

Введение

yandex-bot-api — библиотека для разработки ботов в Яндекс Мессенджере на языке Go. Предоставляет удобное взаимодействие с API Яндекс Мессенджера, роутер, пагинацию и управление состояниями (анкетами).

Установка

Для установки библиотеки в ваш проект используйте команду go get:

go get github.com/go-yandex-bot-api/yandex-bot-api

Требуется версия Go 1.23 или выше.

Быстрый старт (Эхо-бот)

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

package main

import (
	"context"
	"log"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
)

func main() {
	// 1. Инициализация бота. Рекомендуется включать WithDebug при разработке.
	bot, err := yabotapi.NewBot("YOUR_TOKEN_HERE", yabotapi.WithDebug(true))
	if err != nil {
		log.Fatal("Ошибка инициализации бота:", err)
	}
	
	ctx := context.Background()

	// 2. Запуск Short-Polling
	updatesChannel, err := bot.Updates.GetUpdatesChannel(ctx, yabotapi.NewUpdateConfig(0))
	if err != nil {
		log.Fatal("Ошибка запуска пуллинга:", err)
	}

	log.Println("Бот запущен в режиме Short-Polling... Нажмите Ctrl+C для остановки.")

	// 3. Обработка входящих обновлений в цикле
	for update := range updatesChannel {
		log.Printf("Получено обновление с ID: %d", update.UpdateID)

		// Если это текстовое сообщение (а не системное или callback от кнопки)
		if update.Text != "" {
			// Формируем ответ (эхо)
			reply := yabotapi.NewReply(update, "Вы сказали: "+update.Text)
			
			// Отправляем сообщение обратно
			if _, err := bot.Messages.SendText(ctx, reply); err != nil {
				log.Println("Ошибка отправки ответа:", err)
			}
		}
	}
}

Ядро и получение обновлений

В этом разделе описывается процесс инициализации экземпляра бота и два основных способа получения обновлений от серверов Яндекс Мессенджера: Short-Polling и Webhooks.

Инициализация бота

Точкой входа в библиотеку является функция yabotapi.NewBot(token, options...). Она возвращает инициализированный объект бота и ошибку, если токен пустой.

Конструктор поддерживает паттерн опций (Option), позволяя гибко настраивать внутренний HTTP-клиент core.Client:

  • yabotapi.WithDebug(true) — включает логирование всех входящих и исходящих HTTP-запросов (полезно для отладки). Внимание: логгер автоматически скрывает токен авторизации (***REDACTED***), чтобы предотвратить его утечку, и предотвращает OOM при загрузке больших файлов, логируя тело только для application/json.
  • yabotapi.WithClient(client) — позволяет передать собственный http.Client.
  • yabotapi.WithMaxRetries(retries) — настраивает количество автоматических повторных попыток при сетевых ошибках или HTTP 429 (по умолчанию 3).
  • yabotapi.WithAPIURL(url) — позволяет переопределить базовый URL API Яндекса (по умолчанию https://botapi.messenger.yandex.net/bot/v1/).
  • yabotapi.WithErrorHandlingConfig(config) — позволяет переопределить логику того, на какие статусы ответов нужно делать retry (по умолчанию 429 и >= 500).
bot, err := yabotapi.NewBot("YOUR_TOKEN_HERE", 
	yabotapi.WithDebug(true),
	yabotapi.WithMaxRetries(5),
)
if err != nil {
	log.Fatal("Не удалось создать бота:", err)
}

Получение обновлений

Вы можете получать обновления от Яндекса двумя способами. Библиотека возвращает <-chan types.Update, из которого удобно вычитывать события в цикле.

1. Short-Polling (Пуллинг)

Идеально подходит для локальной разработки или в условиях, когда у вашего сервера нет публичного белого IP и SSL-сертификата. В библиотеке реализован надежный Short-Polling с использованием Jitter-backoff (от 500 мс до 5 секунд) при отсутствии обновлений или сетевых сбоях, чтобы избежать “spin-loop” и лишней нагрузки на CPU и сеть (поскольку серверы Яндекса сразу закрывают соединение, если новых событий нет).

// Конфиг пуллинга, начиная со смещения 0
config := yabotapi.NewUpdateConfig(0)

updatesChannel, err := bot.Updates.GetUpdatesChannel(ctx, config)
if err != nil {
	log.Fatal("Не удалось запустить пуллинг:", err)
}

// Чтение канала (блокирующая операция)
for update := range updatesChannel {
	// Обработка update
}

Обратите внимание: Если для бота ранее был установлен Webhook, метод GetUpdatesChannel вернет ошибку. Сначала нужно удалить вебхук.

2. Webhooks (Вебхуки)

Рекомендуемый подход для production-окружений. В этом режиме Яндекс сам отправляет HTTP POST запросы на ваш сервер при наступлении событий.

Библиотека предоставляет встроенный http.HandlerFunc, который безопасно читает JSON, предотвращает переполнение памяти (используя http.MaxBytesReader) и отдает 503 Service Unavailable, если ваш канал обработки переполнен (чтобы Яндекс повторил отправку позже).

webhookURL := "https://your-domain.com/webhook"

// 1. Сообщаем Яндексу наш URL
if err := bot.Webhooks.SetWebhook(ctx, webhookURL); err != nil {
	log.Fatal("Ошибка установки вебхука:", err)
}

// 2. Получаем канал и HTTP-обработчик от библиотеки
updatesChannel, handler := bot.Webhooks.ListenForWebhook(ctx)

// 3. Регистрируем обработчик в стандартном роутере
http.HandleFunc("/webhook", handler)

// 4. Запускаем сервер
go func() {
	srv := &http.Server{Addr: ":8080"}
	if err := srv.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}()

// 5. Обрабатываем обновления из канала, точно так же как и в Short-Polling
for update := range updatesChannel {
	// Обработка update
}

Работа с сообщениями

В библиотеке yandex-bot-api работа с сообщениями реализована через сервис Messages, доступный у экземпляра бота (bot.Messages). Однако, при использовании встроенного роутера (pkg/router), удобнее всего пользоваться методами контекста *router.Context (например, c.Reply()), так как они автоматически определяют, куда (какому пользователю или в какой чат) отправлять ответ, а также учитывают контекст тредов.


1. Отправка текстовых сообщений

Использование bot.Messages.SendText (Низкоуровневый подход)

Метод SendText позволяет гибко настраивать отправляемое сообщение. Вы можете отправить сообщение как в определенный чат (по chat_id), так и конкретному пользователю (по login).

package main

import (
	"context"
	"log"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/api/messages"
	"github.com/go-yandex-bot-api/yandex-bot-api/types"
)

func main() {
	bot, _ := yabotapi.NewBot("YOUR_TOKEN_HERE")
	ctx := context.Background()

	// Отправка сообщения в чат по его ID
	resp, err := bot.Messages.SendText(ctx, messages.SendTextRequest{
		ChatID: "0/0/chat_id_here",
		Text:   "Привет из Yandex Bot API!",
		// Опциональные параметры:
		// Important: true,
		// DisableNotification: true,
	})
	if err != nil {
		log.Println("Ошибка отправки:", err)
		return
	}
	
	log.Println("Сообщение отправлено, ID:", resp.MessageID)
}

Важно: Согласно специфике Яндекса, вы должны указать либо ChatID, либо Login получателя.

Использование router.Context.Reply (Удобный подход)

Если вы используете роутер, контекст автоматически подставит нужный ChatID или Login, чтобы ответить туда же, откуда пришло сообщение.

r.HandleCommand("start", func(c *router.Context) error {
	// Отправит текст в тот же чат/пользователю
	return c.Reply("Привет! Я бот. 👋")
})

Для отправки клавиатур используется c.ReplyWithKeyboard:

r.HandleButton("btn_hi", func(c *router.Context) error {
	// Отправка сообщения вместе с клавиатурой
	return c.ReplyWithKeyboard("Выберите действие:", keyboard)
})

2. Системные сообщения

Системные сообщения обычно выглядят как сервисные уведомления в чате (как правило, центрированные). Для них используется метод SendSystemMessage.

resp, err := bot.Messages.SendSystemMessage(ctx, messages.SendSystemMessageRequest{
	ChatID: "0/0/chat_id_here",
	Text:   "Пользователь Иван присоединился к чату",
})

3. Отправка стикеров

Стикеры отправляются с помощью метода SendSticker. Для этого нужно знать ID стикерпака (StickerSetID) и ID самого стикера (StickerID).

resp, err := bot.Messages.SendSticker(ctx, messages.SendStickerRequest{
	ChatID:       "0/0/chat_id_here",
	StickerSetID: "yandex_stickers_id",
	StickerID:    "sticker_123",
	// Можно передать ReplyMessageID, чтобы стикер был отправлен как реплай
})

4. Индикатор набора текста

Если ваш бот выполняет долгую операцию, рекомендуется отправить индикатор набора текста (typing), чтобы пользователь понимал, что бот “думает”.

err := bot.Messages.SendTyping(ctx, messages.SendTypingRequest{
	ChatID: "0/0/chat_id_here",
})
// После этого выполняем долгую работу и затем присылаем ответ

5. Управление сообщениями (Удаление, Закрепление, Открепление)

API предоставляет возможности для управления уже отправленными сообщениями. Все эти методы принимают ID чата/пользователя и MessageID целевого сообщения.

Удаление сообщения

err := bot.Messages.Delete(ctx, messages.DeleteMessageRequest{
	ChatID:    "0/0/chat_id_here",
	MessageID: 123456789,
})

Закрепление сообщения (Pin)

err := bot.Messages.Pin(ctx, messages.PinMessageRequest{
	ChatID:    "0/0/chat_id_here",
	MessageID: 123456789,
})

Открепление сообщения (Unpin)

err := bot.Messages.Unpin(ctx, messages.UnpinMessageRequest{
	ChatID:    "0/0/chat_id_here",
	MessageID: 123456789,
})

Резюме

  • Используйте методы router.Context (c.Reply, c.ReplyWithKeyboard) для быстрого ответа на входящие обновления.
  • Используйте методы сервиса bot.Messages (SendText, SendSticker, Delete и т.д.) для фоновой отправки, отложенных рассылок или более тонкого управления параметрами сообщения.

Клавиатуры и форматирование

В этом разделе описывается работа с клавиатурами (inline-кнопками), форматированием текста и встроенной системой пагинации в Yandex Bot API.

Клавиатуры (Suggest Buttons)

API Яндекса поддерживает клавиатуры, которые отображаются под сообщением пользователя. Вы можете создавать их как одномерный список (в один ряд) или в виде двумерной сетки (в несколько рядов).

Для этого используются функции:

  • yabotapi.NewSuggestButtons(persist bool, buttons ...InlineSuggestButton) — для создания простого ряда кнопок.
  • yabotapi.NewSuggestButtonsGrid(persist bool, rows ...[]InlineSuggestButton) — для создания многоуровневой сетки кнопок.

Флаг Persist (Поведение клавиатуры)

Первым аргументом в функции создания клавиатуры передается флаг persist, который отвечает за ее долговечность:

  • false (Одноразовая): Клавиатура исчезает после того, как пользователь нажимает на любую кнопку или отправляет текстовое сообщение в чат. Это стандартное поведение для опросов или быстрых ответов.
  • true (Постоянная): Клавиатура остается прикрепленной к полю ввода и не исчезает после взаимодействия. Пользователь может нажимать на кнопки многократно.

Директивы и удобные хелперы кнопок

Для максимального упрощения создания кнопок в библиотеке предусмотрены готовые функции-конструкторы:

  • yabotapi.NewSimpleActionButton(title, actionName string) — создает простую кнопку действия без нагрузки payload.
  • yabotapi.NewActionButton(title, actionName string, payload any) — создает кнопку действия с контекстной полезной нагрузкой payload.
  • yabotapi.NewURLButton(title, uri string) — создает кнопку-ссылку, открывающую внешнюю URL (NewOpenURIDirective).
  • yabotapi.NewTextButton(title, text string) — создает кнопку быстрой отправки текста (NewSendMessageDirective).

Вы также можете конструировать кастомные директивы напрямую:

  • yabotapi.NewOpenURIDirective(uri string)
  • yabotapi.NewSendMessageDirective(text string, payload any)
  • yabotapi.NewServerActionDirective(name string, payload any)
  • yabotapi.NewSetElementsStateDirective(ids []string, state string, timeout int)

Динамическое построение клавиатур (KeyboardBuilder)

Для удобной сборки кнопочных сеток в циклах или динамических меню используется KeyboardBuilder:

  • .Columns(cols int) — задает максимальное число кнопок в одном ряду.
  • .AddSimpleButton(title, actionName) — добавляет кнопку клика.
  • .AddButton(title, actionName, payload) — добавляет кнопку с payload.
  • .AddURLButton(title, uri) — добавляет кнопку-ссылку.
  • .AddTextButton(title, text) — добавляет текстовую кнопку.
  • .AddRawButton(btn) — добавляет произвольно сконструированную InlineSuggestButton.
  • .AddRow(buttons...) — добавляет готовый ряд кнопок.
  • .Build() *SuggestButtons — результирует готовую к отправке клавиатуру.
kb := yabotapi.NewKeyboardBuilder(true).Columns(2) // 2 кнопки в каждом ряду
for _, product := range products {
    kb.AddButton(product.Name, "buy_product", product.ID)
}
keyboard := kb.Build()

Пример создания клавиатуры

package main

import (
	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/types"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/router"
)

func sendKeyboard(c *router.Context) error {
	keyboard := yabotapi.NewSuggestButtonsGrid(
		true, // persist = true (клавиатура не исчезнет после нажатия)
		[]types.InlineSuggestButton{
			yabotapi.NewSimpleActionButton("Поздороваться", "btn_hi"),
			yabotapi.NewSimpleActionButton("Попрощаться", "btn_bye"),
		},
		[]types.InlineSuggestButton{
			yabotapi.NewURLButton("Открыть Яндекс", "https://ya.ru"),
			yabotapi.NewTextButton("Отправить контакты", "Покажи контакты"),
		},
	)

	return c.ReplyWithKeyboard("Выберите действие:", keyboard)
}
}

Форматирование текста

Яндекс Мессенджер использует свой диалект Markdown для разметки сообщений. Чтобы не запоминать синтаксис и случайно не забыть экранировать спецсимволы, библиотека предоставляет готовый пакет pkg/format.

import "github.com/go-yandex-bot-api/yandex-bot-api/pkg/format"

1. Атомарные функции форматирования

  • format.Escape(text string) — Экранирование специальных символов Markdown (*, _, ~, +, `, [, ], \).
  • format.Bold(text string)Жирный текст (**text**).
  • format.Italic(text string)Курсив (__text__).
  • format.Strikethrough(text string)Зачеркнутый текст (~~text~~).
  • format.Underline(text string) — ++Подчеркнутый++ текст (++text++).
  • format.Code(text string) — Строчный фрагмент кода.
  • format.CodeBlock(text, language string) — Многострочный блок кода с поддержкой подсветки синтаксиса.
  • format.Link(text, url string)Кликабельная ссылка.
  • format.Quote(text string) — Цитата (> text). Поддерживает многострочный текст.
  • format.BulletList(items []string) — Маркированный список (• item).
  • format.NumberedList(items []string) — Нумерованный список (1. item).
  • format.Header(text string) — Заголовок первого уровня (# text).
  • format.HeaderLevel(level int, text string) — Заголовок уровня 1–6 (## text).
  • format.KeyVal(key, value string) — Пара ключ-значение (**Key:** Value).
  • format.Divider() — Горизонтальная линия-разделитель (━━━━━━).

2. Fluent Builder API (format.NewBuilder())

Для удобной и производительной сборки сложных сообщений без ручной конкатенации строк используется Builder с цепочкой методов:

func sendDashboard(c *router.Context) error {
	msg := format.NewBuilder().
		Header("Отчет по серверу").
		NewLine().
		Divider().
		NewLine().
		KeyVal("Статус", "Active").
		NewLine().
		KeyVal("Аптайм", "99.9%").
		NewLine().
		Quote("Все службы работают без сбоев.").
		NewLine().
		Bold("Компоненты:").
		NewLine().
		BulletList([]string{
			"PostgreSQL: OK",
			"Redis: OK",
		}).
		String()

	return c.Reply(msg)
}

3. Автоматическая разбивка длинных сообщений (format.Split)

Максимальная длина текстового сообщения в Яндекс Мессенджере составляет 6000 символов. Если сообщение превышает этот лимит, отправка завершится ошибкой. Функция format.Split аккуратно разбивает длинный текст по абзацам (\n\n), строкам (\n) или пробелам без разрыва UTF-8 символов.

// SplitDefault разбивает текст по дефолтному лимиту Яндекса (6000 символов)
chunks := format.SplitDefault(veryLongText)

for _, chunk := range chunks {
	_, _ = bot.Messages.SendText(ctx, messages.NewSendTextRequest().SetChatID(chatID).SetText(chunk))
}

Пример использования

func sendFormattedText(c *router.Context) error {
	text := format.Bold("Внимание!") + "\n" +
		"Пожалуйста, ознакомьтесь с нашей документацией на " + format.Link("GitHub", "https://github.com") + "\n\n" +
		"Пример использования:\n" +
		format.CodeBlock("fmt.Println(\"Hello World\")", "go") + "\n" +
		"Пользователь с ником " + format.Escape("_user*name_") + " подключился."
		
	return c.Reply(text)
}

Пагинация

Для упрощения создания страниц с результатами и удобной навигации по ним, в библиотеке предусмотрен пакет pkg/pagination.

import "github.com/go-yandex-bot-api/yandex-bot-api/pkg/pagination"

Пакет содержит две основные функции:

  1. pagination.PaginateSlice(totalItems, page, limit int) (start, end, totalPages) — Помогает безопасно вычислять границы среза (slice[start:end]) в зависимости от текущей страницы и лимита элементов на страницу. Защищает от выхода за границы массива.
  2. pagination.NewPaginationRow(currentPage, totalPages int, actionName string) []InlineSuggestButton — Генерирует готовый ряд кнопок: ⬅️ Назад, Текущая / Всего, Вперед ➡️. При нажатии на кнопки отправляется серверное действие (ServerAction) с названием actionName и payload-ом pagination.Payload{Page: ...}.

Полный пример пагинации

package main

import (
	"fmt"
	"log"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/router"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/pagination"
)

var dataItems = []string{"Яблоко", "Банан", "Апельсин", "Груша", "Киви", "Манго", "Персик", "Слива"}
const ItemsPerPage = 3

func sendItemsPage(c *router.Context, page int) error {
	// 1. Вычисляем безопасные границы для слайса
	start, end, totalPages := pagination.PaginateSlice(len(dataItems), page, ItemsPerPage)

	// 2. Формируем текст сообщения
	text := fmt.Sprintf("📄 Страница %d из %d:\n\n", page, totalPages)
	for i := start; i < end; i++ {
		text += fmt.Sprintf("- %s\n", dataItems[i])
	}

	// 3. Создаем ряд кнопок навигации. 
	// При нажатии будет вызван хендлер кнопки с именем "items_page"
	navRow := pagination.NewPaginationRow(page, totalPages, "items_page")

	// 4. Генерируем клавиатуру. Устанавливаем persist=true, чтобы она не исчезала.
	keyboard := yabotapi.NewSuggestButtonsGrid(true, navRow)

	return c.ReplyWithKeyboard(text, keyboard)
}

func main() {
	bot, _ := yabotapi.NewBot("TOKEN")
	r := router.NewRouter(bot)

	// Пользователь вызывает команду /start
	r.HandleCommand("start", func(c *router.Context) error {
		return sendItemsPage(c, 1)
	})

	// Хендлер для кнопок пагинации (имя совпадает с actionName из NewPaginationRow)
	r.HandleButton("items_page", func(c *router.Context) error {
		var payload pagination.Payload
		
		// Автоматически парсим JSON, прикрепленный к кнопке
		if err := c.BindPayload(&payload); err != nil {
			return err
		}
		
		// Отправляем запрошенную страницу
		return sendItemsPage(c, payload.Page)
	})

	// ... Запуск роутера
}

Работа с файлами и медиа

Библиотека предоставляет обширные возможности для работы с файлами, изображениями и галереями. Вы можете отправлять локальные файлы, передавать потоки данных в реальном времени, а также скачивать файлы и пересылать их по идентификаторам (file_id).

Отправка файлов и изображений

Для отправки медиафайлов используются методы SendFile и SendImage. Вы можете передать файл двумя способами:

  1. Через путь к локальному файлу (FilePath) — библиотека сама откроет файл и подготовит его к отправке.
  2. Через поток (Stream: io.Reader) — удобно, если вы скачиваете файл из интернета, генерируете его в памяти или читаете из облачного хранилища, не сохраняя на диск.

Пример: Отправка локального файла

req := files.SendFileRequest{
	ChatID:   u.GetChatID(), // Безопасный геттер (важно использовать именно его)
	FilePath: "report.txt",  // Путь к файлу на диске
	Text:     "Вот ваш отчет!",
}

// Если ChatID пустой (например, в личных сообщениях), используем Login
if req.ChatID == "" {
	req.Login = u.GetFromLogin()
}

resp, err := bot.Files.SendFile(ctx, req)
if err != nil {
	log.Println("Ошибка отправки файла:", err)
}

Пример: Отправка изображения из потока

file, err := os.Open("avatar.png")
if err != nil {
	return err
}
// Вы должны закрыть локальный файл после использования в своем коде.
defer file.Close()

req := files.SendImageRequest{
	ChatID: u.GetChatID(),
	Stream: file,             // Передаем io.Reader напрямую
	Text:   "Аватарка из потока",
}

if req.ChatID == "" {
	req.Login = u.GetFromLogin()
}

resp, err := bot.Files.SendImage(ctx, req)

Отправка галерей

Для отправки нескольких изображений в одном сообщении используется метод SendGallery. Аналогично одиночным файлам, поддерживаются пути к файлам и потоки. Вы также можете комбинировать их в одном запросе.

req := files.SendGalleryRequest{
	ChatID: u.GetChatID(),
	FilePaths: []string{
		"image1.jpg",
		"image2.png",
	},
	// Можно также передать Streams: []io.Reader{...}
	Text: "Фотоотчет о проделанной работе",
}

if req.ChatID == "" {
	req.Login = u.GetFromLogin()
}

resp, err := bot.Files.SendGallery(ctx, req)

Пересылка файлов по ID (Share)

Если файл уже был загружен на сервера Яндекса (например, вы его отправили ранее или получили от пользователя), вы можете переслать его с помощью file_id. Это работает намного быстрее, так как не требует повторной загрузки бинарных данных.

Доступные методы: ShareFile, ShareImage, ShareGallery.

Important

Специфика Яндекс Мессенджера для ShareImage и ShareGallery: При повторной отправке картинок по file_id сервер Яндекса строго требует явного указания полей Width и Height (целые числа в пикселях). Если пропустить их, сервер вернет ошибку 400 Bad Request.

Пример: ShareFile

req := files.ShareFileRequest{
	ChatID: u.GetChatID(),
}
req.File.FileID = "existing_file_id"

resp, err := bot.Files.ShareFile(ctx, req)

Пример: ShareImage

req := files.ShareImageRequest{
	ChatID: u.GetChatID(),
	Text:   "Посмотри на это фото!",
}
// Указываем ID ранее загруженного файла и обязательные размеры!
req.Image.FileID = "some_existing_file_id"
req.Image.Width = 800
req.Image.Height = 600

if req.ChatID == "" {
	req.Login = u.GetFromLogin()
}

resp, err := bot.Files.ShareImage(ctx, req)

Скачивание файлов

Когда пользователь присылает файл, в объекте Update заполняется поле File. Вы можете получить содержимое этого файла с помощью метода GetFile.

Метод GetFile возвращает поток io.ReadCloser. Вы должны обязательно закрыть этот поток, вызвав Close(), чтобы избежать утечек ресурсов.

Пример: Скачивание входящего файла

// Проверяем, есть ли прикрепленный файл в сообщении
if update.File != nil && update.File.ID != "" {
	// Получаем поток данных файла напрямую по FileID (io.ReadCloser)
	stream, err := bot.Files.GetFileByID(ctx, update.File.ID)
	if err != nil {
		log.Println("Ошибка скачивания файла:", err)
		return
	}
	// ОБЯЗАТЕЛЬНО закрываем поток!
	defer stream.Close()

	// Сохраняем на локальный диск
	out, err := os.Create("downloaded_" + update.File.Name)
	if err != nil {
		return err
	}
	defer out.Close()

	// Копируем данные из ответа в файл
	if _, err := io.Copy(out, stream); err != nil {
		log.Println("Ошибка сохранения файла:", err)
	} else {
		log.Println("Файл успешно скачан и сохранен!")
	}
}

Архитектурные защиты (Under the Hood)

В библиотеке реализовано несколько важных механизмов безопасности и оптимизации для надежной работы с медиа:

  1. Защита от OOM (Out Of Memory) при логировании: Внутренний middleware логирования HTTP-клиента (logging_client.go) проверяет заголовок Content-Type. При отправке файлов используется multipart/form-data. Логгер клонирует и читает тело запроса в память (io.ReadAll) только для application/json. Это предотвращает аварийное завершение приложения из-за нехватки оперативной памяти при отправке больших файлов, так как их бинарное содержимое не попадает в буфер логгера.

  2. Защита от утечек файловых дескрипторов (Resource Leaks): При формировании multipart запросов на загрузку файлов (в методе MakeMultipartRequest), библиотека гарантирует безусловное закрытие всех переданных файловых потоков в самом начале обработки (через внутренний механизм defer и коллекцию openFiles). Даже если на раннем этапе подготовки запроса (например, при маршалинге JSON) произойдет ошибка и функция прервет выполнение, все открытые файлы и потоки будут гарантированно закрыты.

Работа с чатами и опросами

В этом разделе описано, как использовать Yandex Bot API для управления чатами (создание, получение списка участников, обновление ролей) и создания опросов, а также получения их результатов.

Управление чатами

Все методы для работы с чатами доступны через сервис bot.Chats.

Создание чата или канала

Для создания чата используется метод CreateChat. Обратите внимание на важную особенность Yandex API: поля Channel и Public имеют тип bool без тега omitempty. Это значит, что вы можете явно передавать false для создания нужного типа чата.

Ключевые комбинации Channel и Public:

  • Channel: false, Public: false — Создает закрытую группу.
  • Channel: false, Public: true — Создает открытую группу (доступна по ссылке).
  • Channel: true, Public: false — Создает закрытый канал.
  • Channel: true, Public: true — Создает открытый канал.

Внимание: В зависимости от значения Channel, для добавления пользователей при создании необходимо использовать разные поля:

  • Если Channel: false, используйте поле Members.
  • Если Channel: true, используйте поле Subscribers.

Пример создания закрытой группы:

package main

import (
	"context"
	"log"

	"github.com/go-yandex-bot-api/yandex-bot-api/api/chats"
	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
)

func main() {
	bot, _ := yabotapi.NewBot("YOUR_TOKEN_HERE")
	ctx := context.Background()

	req := chats.CreateChatRequest{
		Name:        "Новая закрытая группа",
		Description: "Описание группы",
		Channel:     false, // Создаем группу, а не канал
		Public:      false, // Закрытая
		Members: []chats.User{
			{Login: "user_login_1"},
		},
	}

	resp, err := bot.Chats.CreateChat(ctx, req)
	if err != nil {
		log.Fatalf("Ошибка создания чата: %v", err)
	}

	log.Printf("Чат создан! ID: %s", resp.ChatID)
}

Получение списка чатов бота (GetChats)

Для получения списка всех чатов и каналов, в которых состоит бот, используется метод GetChats:

chats, err := bot.Chats.GetChats(ctx, chats.GetChatsRequest{}.WithLimit(50))
if err != nil {
	log.Fatalf("Ошибка получения чатов: %v", err)
}

for _, c := range chats {
	log.Printf("Чат: %s, ID: %s, Канал: %v", c.Name, c.ID, c.Channel)
}

Получение списка участников и администраторов

Для получения участников чата используйте метод GetMembers. Можно отфильтровать пользователей по роли (например, получить только администраторов).

req := chats.GetMembersRequest{
	ChatID: "chat_id_here",
	Role:   "admin", // Фильтрация по роли. Оставьте пустым для получения всех.
}.WithLimit(100) // Используем Builder метод для опциональных полей

members, err := bot.Chats.GetMembers(ctx, req)
if err != nil {
	log.Fatal(err)
}

for _, member := range members {
	log.Printf("Участник: %s, Роль: %s", member.Login, member.Role)
}

Добавление и удаление участников

Для обновления участников, администраторов и подписчиков используется метод UpdateMembers.

Важно: По умолчанию сервер Яндекса отправляет уведомления в чат о добавлении/удалении пользователей. Чтобы сделать это “тихо” (без системных сообщений), необходимо явно использовать метод-билдер WithSendNotifications(false).

req := chats.UpdateMembersRequest{
	ChatID: "chat_id_here",
	Members: []chats.User{
		{Login: "new_member_login"},
	},
}.WithSendNotifications(false) // Тихое добавление

err := bot.Chats.UpdateMembers(ctx, req)
if err != nil {
	log.Fatal(err)
}

Работа с опросами (Polls)

API опросов доступно через bot.Polls. Опросы можно отправлять как в группы, так и в личные сообщения пользователям.

Создание опроса

При создании опроса необходимо указать вопрос (Title) и список вариантов ответа (Answers). В качестве получателя нужно передать либо ChatID (для отправки в группу), либо Login (для личного сообщения).

package main

import (
	"context"
	"log"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/api/polls"
)

func main() {
	bot, _ := yabotapi.NewBot("YOUR_TOKEN_HERE")
	
	// Отправка опроса в чат
	req := polls.CreateRequest{
		ChatID:  "chat_id_here",
		Title:   "Какой ваш любимый язык программирования?",
		Answers: []string{"Go", "Python", "Rust", "Java"},
	}

	resp, err := bot.Polls.Create(context.Background(), req)
	if err != nil {
		log.Fatal("Ошибка отправки опроса:", err)
	}
	
	log.Printf("Опрос создан! Message ID: %v", resp.MessageID)
}

Получение результатов опроса

Если вам нужно узнать количество голосов и общую статистику по ответам, используйте метод GetResults, передав ID сообщения опроса:

req := polls.GetResultsRequest{
	ChatID:    "chat_id_here",
	MessageID: 123456789,
}

resp, err := bot.Polls.GetResults(context.Background(), req)
if err != nil {
	log.Fatal(err)
}

log.Printf("Всего голосов: %d", resp.VotedCount)
for answerText, votes := range resp.Answers {
	log.Printf("Ответ '%s': %d голосов", answerText, votes)
}

Получение списка проголосовавших (Voters)

Для получения поименного списка пользователей, проголосовавших за конкретный вариант ответа, используется метод GetVoters. Этот метод поддерживает пагинацию с помощью курсоров.

В запросе необходимо указать AnswerID — индекс ответа (начиная с 0, согласно порядку вариантов при создании опроса).

// Указываем индекс ответа (например, 0 для первого варианта "Go")
answerID := 0

req := polls.GetVotersRequest{
	ChatID:    "chat_id_here",
	MessageID: 123456789,
	AnswerID:  &answerID,
}.WithLimit(50)

for {
	resp, err := bot.Polls.GetVoters(context.Background(), req)
	if err != nil {
		log.Fatal(err)
	}

	for _, vote := range resp.Votes {
		log.Printf("Пользователь %s проголосовал! (Таймстемп: %d)", vote.User.Login, vote.Timestamp)
	}

	// Получаем следующий курсор. Если пустой - значит дошли до конца
	nextCursor := resp.NextCursor()
	if nextCursor == "" {
		break
	}
	
	// Устанавливаем курсор для следующего запроса
	req.Cursor = nextCursor
}

Обработка появления опросов (r.HandlePoll) и подчисление голосов

При создании нового опроса в чате Яндекс Мессенджер присылает объект Poll в Update. Роутер предоставляет удобный метод подписки:

r.HandlePoll(func(c *router.Context) error {
	poll := c.Update.Poll
	log.Printf("В чате появился новый опрос: '%s' с вариантами %v", poll.Title, poll.Answers)
	return nil
})

Важно: Сервер Яндекс Мессенджера не стримит Push-уведомления через Long-Polling (getUpdates) на каждый клик ответа в опросе. Для получения актуального количества голосов и списков проголосовавших пользователей используйте вызовы bot.Polls.GetResults и bot.Polls.GetVoters.

Пользователи и Информация о Боте (bot.Users)

Сервис bot.Users позволяет получать сведения о самом боте, а также генерировать ссылки (диплинки) на диалоги с пользователями.

Получение сведений о боте (GetMe / GetSelf)

Метод GetMe (или его алиас GetSelf) возвращает информацию о текущем боте (имя, логин):

botInfo, err := bot.Users.GetMe(ctx)
if err != nil {
    log.Fatalf("Ошибка получения данных бота: %v", err)
}

fmt.Printf("Имя бота: %s, Логин: %s\n", botInfo.Name, botInfo.Login)

Метод GetUserLink возвращает прямые ссылки на открытый чат или звонок с пользователем по его логину:

linkResp, err := bot.Users.GetUserLink(ctx, yabotapi.GetUserLinkRequest{
    Login: "john_doe",
})
if err != nil {
    log.Printf("Ошибка получения ссылки на пользователя: %v", err)
}

fmt.Printf("Ссылка на чат: %s\n", linkResp.ChatURL)

Роутер и Контекст (Router & Context)

Модуль router предоставляет удобный механизм для обработки входящих обновлений (Updates) от Яндекс Мессенджера. Он построен по принципу цепочки обязанностей с поддержкой middleware и строго типизированного контекста Context.

Архитектура Роутера

Роутер отвечает за распределение входящих обновлений по соответствующим обработчикам (хендлерам). Он обрабатывает обновления конкурентно, запуская каждый хендлер в отдельной горутине. Для предотвращения исчерпания ресурсов (например, при огромном наплыве событий) роутер использует семафор, который жестко ограничивает максимальное количество одновременных горутин (по умолчанию 50).

Регистрация обработчиков

Вы можете привязать обработчики к различным типам событий:

  • Команды (HandleCommand): Обработка текстовых сообщений, начинающихся со слеша (например, /start).
  • Кнопки (HandleButton): Обработка нажатий на инлайн-кнопки (Action buttons). Привязывается к имени серверного действия ServerAction.Name.
  • Опросы (HandlePoll): Обработка событий появления новых опросов в чате (u.Poll).
  • Текст (HandleText): Резервный обработчик для любых текстовых сообщений, которые не являются командами.
  • Состояния FSM (HandleState, HandleStateRegexp): Обработчики для пользователей, находящихся в определенном состоянии конечного автомата (FSM).
  • Middlewares (Use): Промежуточное ПО, которое выполняется до и/или после основного обработчика. Идеально подходит для логирования, сбора метрик или проверки прав.

Пример инициализации и маршрутизации

package main

import (
	"log"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/router"
)

func main() {
	bot, err := yabotapi.NewBot("YOUR_TOKEN")
	if err != nil {
		log.Fatalf("Ошибка инициализации: %v", err)
	}

	r := router.NewRouter(bot)

	// Добавление встроенных middleware (для логирования и перехвата паник)
	r.Use(router.LoggerMiddleware())
	r.Use(router.RecoverMiddleware())

	// Кастомный middleware (например, проверка прав)
	r.Use(func(next router.HandlerFunc) router.HandlerFunc {
		return func(c *router.Context) error {
			log.Printf("Получено обновление ID: %d", c.Update.UpdateID)
			return next(c) // Передача управления следующему хендлеру
		}
	})

	// Обработка команды /start
	r.HandleCommand("start", func(c *router.Context) error {
		return c.Reply("Привет! Я бот.")
	})

	// Обработка нажатия на кнопку с ServerAction.Name == "click_btn"
	r.HandleButton("click_btn", func(c *router.Context) error {
		return c.Reply("Вы нажали на кнопку!")
	})

	// Обработка любого другого текста (эхо-режим)
	r.HandleText(func(c *router.Context) error {
		return c.Reply("Вы сказали: " + c.Update.Text)
	})

	// Резервный обработчик, если ничего не подошло
	r.HandleDefault(func(c *router.Context) error {
		return c.Reply("Я не понимаю вас.")
	})
	
	// ... запуск получения обновлений (Short-Polling/Webhooks) и r.Start(ctx, ch)
}

Контекст (Context)

Объект Context передается в каждый хендлер и содержит всю необходимую информацию для работы с текущим обновлением. Он оборачивает само обновление (types.Update), экземпляр клиента API (*yabotapi.Bot) и предоставляет удобные вспомогательные методы.

Хелперы отправки сообщений

Вместо ручного формирования сложных объектов запроса, вы можете использовать встроенные методы контекста, которые автоматически подставляют нужные ID чата и треда:

  • c.Reply(text string): Отвечает текстовым сообщением в тот же чат с цитированием (reply_message_id) и поддержкой тредов.
  • c.Replyf(format string, args ...any): Отвечает форматированным сообщением с цитированием.
  • c.ReplyWithKeyboard(text string, keyboard *types.SuggestButtons): Отвечает сообщением с клавиатурой и цитированием.
  • c.Send(text string): Отправляет новое самостоятельное сообщение в текущий чат без цитирования.
  • c.Sendf(format string, args ...any): Отправляет форматированное новое сообщение без цитирования.
  • c.SendWithKeyboard(text string, keyboard *types.SuggestButtons): Отправляет новое сообщение с клавиатурой без цитирования.
  • c.SendTo(recipient string, text string): Отправляет сообщение в произвольный чат или логин пользователя.
  • c.BindPayload(dest interface{}): Удобно распаковывает JSON-полезную нагрузку из нажатой кнопки прямо в структуру.
  • c.FirstImage(): Возвращает указатель на первое изображение-вложение и true, если оно есть в сообщении.
  • c.FirstFile(): Возвращает указатель на файл-документ и true, если он прикреплен к сообщению.
  • r.StartPolling(ctx, offset): Запускает Long-Polling канал обновлений и обработку событий одной строкой кода.
  • router.GetFSMData[T](c, key): Строго типизированное извлечение сохраненных FSM-данных с помощью Generics без ручного type assertion.

Глобальный перехватчик ошибок (OnError)

Вы можете зарегистрировать глобальный обработчик ошибок на уровне роутера. Если любой хендлер вернет ошибку, она автоматически попадет в функцию OnError:

r.OnError(func(c *router.Context, err error) {
	log.Printf("[ОШИБКА] Обработка запроса от %s завершилась с ошибкой: %v", c.Update.GetFromLogin(), err)
	_ = c.Reply("⚠️ Произошла внутренняя ошибка. Попробуйте позже.")
})

⚠️ Безопасное извлечение ID (Критично!)

В Яндекс Мессенджере структура входящего обновления (Update) имеет специфичные особенности. В частности, при получении личных сообщений поле Chat может быть nil, а для определения получателя используется поле From.Login.

Из-за этого прямое обращение u.Chat.ID часто вызывает критическую панику: nil pointer dereference.

Строго запрещено использовать c.Update.Chat.ID напрямую!

Всегда используйте встроенные безопасные хелперы контекста:

  • c.IsPrivate() — возвращает true, если сообщение пришло в Личных Сообщениях (ЛС), и false, если из группы.
  • c.Update.IsGroup() — возвращает true, если сообщение отправлено в групповом чате или канале.
  • c.Update.Command() — возвращает имя поступившей команды без / (например, "start").
  • c.Update.CommandArguments() — возвращает строку аргументов, переданых с командой (например, "arg1 arg2" для /start arg1 arg2).
  • c.ChatID() — универсально возвращает ID чата (для групп) или Логин (для ЛС).
  • c.SenderLogin() — всегда безопасно возвращает логин отправителя.
if c.IsPrivate() {
    // Пользователь написал в ЛС
    return c.Send("Ответ в Личных Сообщениях")
} else {
    // Пользователь написал в группе/чате
    return c.Reply("Ответ в группу")
}

Для безопасного и быстрой работы с идентификаторами в контексте предусмотрены короткие методы-псевдонимы:

  • c.SenderLogin(): Безопасно возвращает types.UserLogin отправителя.
  • c.ChatID(): Безопасно возвращает types.ChatID текущего чата.
  • c.SenderLoginIs("admin"): Быстрая проверка логина отправителя без ручного приведения типа string(...).
func customHandler(c *router.Context) error {
	// Проверка прав администратора без приведения типов:
	if c.SenderLoginIs("admin_login") {
		return c.Reply("Доступ разрешен!")
	}

	login := c.SenderLogin()
	chatID := c.ChatID()
	// ...
	return nil
}

Специализированные обработчики контента и фильтры

Для быстрой подписки на события с определенным типом содержимого в Router предусмотрены отдельные вызовы:

  • r.HandleFile(fn) — перехватывает прикрепленные файлы/документы (c.IsFile()).
  • r.HandleImage(fn) — перехватывает изображения и альбомы (c.IsImage()).
  • r.HandleSticker(fn) — перехватывает стикеры (c.IsSticker()).
  • r.HandleVote(fn) — перехватывает события результатов голосований в опросах (c.IsVote()).
r.HandleFile(func(c *router.Context) error {
	return c.Replyf("Файл %s получен!", c.Update.File.Name)
})

r.HandleVote(func(c *router.Context) error {
	return c.Replyf("Спасибо за голос в опросе %s!", c.Update.Vote.PollID)
})

Graceful Shutdown (Изящная остановка)

При выключении приложения (по Ctrl+C или команде от Docker/Kubernetes) очень важно корректно завершить работу и доработать уже полученные обновления. В противном случае события от пользователей могут быть потеряны безвозвратно.

В метод Router.Start встроен мощный механизм изящной остановки (Graceful Shutdown):

  1. Предотвращение Deadlock: Семафор, управляющий пулом воркеров, обернут в select с ctx.Done(). Это защищает роутер от зависания (deadlock) при выключении, если пул воркеров был переполнен.
  2. Завершение обработки: Вычитанные из канала, но еще не взятые в работу апдейты, обязательно дорабатываются даже при отмене основного контекста. Для этого внутри воркера используется изолированный от отмены контекст context.WithoutCancel(ctx).
  3. Ограничение времени: Каждому дорабатывающему воркеру выдается тайм-аут в 30 секунд (context.WithTimeout), чтобы предотвратить вечное ожидание ответа от серверов Яндекса.
  4. Гарантия завершения: Роутер блокируется до тех пор, пока все запущенные горутины-воркеры не закончат свою работу (sync.WaitGroup.Wait()).

Пример корректного запуска:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

updatesCh := make(chan types.Update, 100)

// 1. Запуск роутера в отдельной горутине
go r.Start(ctx, updatesCh)

// 2. Запуск сервиса получения обновлений (Short-Polling / Webhooks)...
// ...

// 3. Ожидание сигнала выключения
<-ctx.Done()
log.Println("Получен сигнал выключения. Ожидание завершения обработки обновлений...")

// Роутер автоматически дождется завершения всех запущенных горутин
// и предотвратит потерю апдейтов.

Конечные автоматы (FSM)

Конечный автомат (Finite State Machine, FSM) — это механизм, позволяющий боту запоминать контекст общения с пользователем и выстраивать диалог в виде последовательных шагов (например, пошаговый опрос или создание сложной сущности).

Архитектура FSM

В библиотеке yandex-bot-api поддержка FSM интегрирована напрямую в пакет router. Основная идея состоит в том, что каждому пользователю назначается определенное «состояние» (State) в виде строки, а также может сохраняться дополнительная информация (Payload/Data). В зависимости от того, в каком состоянии находится пользователь, Router будет направлять его сообщения в соответствующий обработчик.

Хранилище состояния абстрагировано через интерфейс fsm.Storage:

type Storage interface {
	Set(userID string, state string)
	Get(userID string) string
	Delete(userID string)
	SetData(userID string, key string, value any)
	GetData(userID string, key string) (any, bool)
}

Вы можете использовать fsm.NewMemoryStorage() для хранения данных в оперативной памяти (с поддержкой TTL для автоматической очистки зависших состояний), или написать свою реализацию поверх Redis / PostgreSQL для распределенных систем.

Внедрение хранилища

Для активации FSM необходимо создать экземпляр хранилища и передать его в роутер через метод WithStorage():

import "github.com/go-yandex-bot-api/yandex-bot-api/pkg/fsm"
import "time"

// Создаем хранилище в памяти с очисткой неактивных сессий через 30 минут
storage := fsm.NewMemoryStorage(30 * time.Minute)
// При завершении работы приложения рекомендуется вызвать Stop(), чтобы остановить сборщик мусора
defer storage.Stop()

// Передаем хранилище в роутер
r := router.NewRouter(bot).WithStorage(storage)

Использование ContextFSM

В каждом обработчике доступен объект c.FSM(), который предоставляет изолированный контекст конечного автомата для конкретного отправителя (связки ChatID:Login).

Доступные методы:

  • c.FSM().SetState(state string) — устанавливает новое состояние пользователя.
  • c.FSM().GetState() string — возвращает текущее состояние.
  • c.FSM().SetData(key string, value any) — сохраняет произвольные данные (ключ-значение).
  • c.FSM().GetData(key string) (any, bool) — извлекает ранее сохраненные данные.
  • c.FSM().Clear() — полностью удаляет и состояние, и сохраненные данные пользователя.

Маршрутизация по состояниям

Роутер предоставляет специальные методы для перехвата сообщений от пользователей в определенных состояниях. В цикле роутинга обработчики состояний проверяются до обработчика обычного текста, но после обработчиков кнопок и команд (команды всегда выполняются приоритетно, что позволяет сбросить FSM в любой момент, написав /cancel).

  • HandleState(state string, handler router.HandlerFunc) — обрабатывает апдейты для точного совпадения состояния.
  • HandleStateRegexp(pattern string, handler router.HandlerFunc) error — обрабатывает апдейты для состояний, соответствующих регулярному выражению (полезно для динамических состояний, например item_edit_.*).

Комплексный пример: Анкета (Questionnaire)

Ниже представлен полноценный пример создания пошаговой анкеты. Бот собирает имя, возраст и любимый цвет пользователя по шагам, после чего выводит итоговую карточку.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	yabotapi "github.com/go-yandex-bot-api/yandex-bot-api"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/fsm"
	"github.com/go-yandex-bot-api/yandex-bot-api/pkg/router"
)

func main() {
	bot, err := yabotapi.NewBot("YOUR_TOKEN_HERE")
	if err != nil {
		log.Fatal("Failed to create bot:", err)
	}
	ctx := context.Background()

	updatesChannel, err := bot.Updates.GetUpdatesChannel(ctx, yabotapi.NewUpdateConfig(0))
	if err != nil {
		log.Fatal("Failed to start short-polling:", err)
	}

	// 1. Инициализируем хранилище (память очищается через 30 минут бездействия)
	storage := fsm.NewMemoryStorage(30 * time.Minute)
	defer storage.Stop()

	// 2. Подключаем хранилище к роутеру
	r := router.NewRouter(bot).WithStorage(storage)

	// Шаг 1: Пользователь отправляет команду /start -> спрашиваем имя
	r.HandleCommand("start", func(c *router.Context) error {
		// Переводим пользователя в состояние "step_name"
		c.FSM().SetState("step_name")
		return c.Reply("Привет! Давай познакомимся. Как тебя зовут?")
	})

	// Шаг 2: Обработка имени -> спрашиваем возраст
	r.HandleState("step_name", func(c *router.Context) error {
		name := c.Update.Text

		// Сохраняем имя в полезную нагрузку (payload)
		c.FSM().SetData("name", name)

		// Переводим пользователя на следующий шаг
		c.FSM().SetState("step_age")
		return c.Reply(fmt.Sprintf("Приятно познакомиться, %s! Сколько тебе лет?", name))
	})

	// Шаг 3: Обработка возраста -> спрашиваем любимый цвет
	r.HandleState("step_age", func(c *router.Context) error {
		age := c.Update.Text

		// Сохраняем возраст
		c.FSM().SetData("age", age)
		
		// Переводим пользователя на следующий шаг
		c.FSM().SetState("step_color")
		return c.Reply("Понял. А какой твой любимый цвет?")
	})

	// Шаг 4: Обработка цвета -> выводим результат и очищаем FSM
	r.HandleState("step_color", func(c *router.Context) error {
		color := c.Update.Text

		// Извлекаем ранее сохраненные данные
		var name string
		var age string

		if val, ok := c.FSM().GetData("name"); ok {
			name, _ = val.(string)
		}
		if val, ok := c.FSM().GetData("age"); ok {
			age, _ = val.(string)
		}

		// Отправляем итоговый результат
		summary := fmt.Sprintf("📝 **Твоя анкета**\nИмя: %s\nВозраст: %s\nЦвет: %s\n\nПрофиль сохранен!", name, age, color)
		err := c.Reply(summary)

		// Полностью очищаем состояние и сохраненные данные
		c.FSM().Clear()
		return err
	})

	// Резервный обработчик: если пользователь вне состояния пишет текст
	r.HandleDefault(func(c *router.Context) error {
		if c.Update.Text != "" && !c.Update.IsCommand() {
			return c.Reply("Отправьте /start, чтобы начать заполнение анкеты.")
		}
		return nil
	})

	log.Println("Бот-анкета запущен. Отправьте /start")
	r.Start(ctx, updatesChannel)
}