Содержание урока · 11 разделов
- FSM телеграм бота: машина состояний на карте
- Вариация: состояние по chat_id или по user.ID
- Почему мьютекс: конкурентность в боте
- Переходы и сброс состояния
- Типичная ошибка: шаг без проверки ответа
- Память бота: что не так с map
- Спорное мнение: библиотеки FSM боту не нужны
- Шаги анкеты как строки автомата
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
FSM телеграм бота: машина состояний на карте#
Всё, что бот умел до сих пор, работало без памяти: каждое сообщение обрабатывалось само по себе. Но стоит попросить пользователя заполнить анкету — имя, потом возраст, потом город, — и этого мало: бот обязан помнить, что уже спросил и что дождался. Это и есть состояние диалога, а в чистом виде — конечный автомат, FSM: ограниченный набор состояний и правила переходов между ними. Для телеграм бота на Go словарь «пользователь → шаг» естественно живёт в map[int64]string: ключ — chat_id, значение — имя текущего состояния. Вокруг карты вырастает вся дисциплина урока: кто и когда пишет в неё, как сбрасывать зависшего пользователя и что произойдёт при перезапуске процесса. К концу урока бот проведёт анкету по шагам, не спутается, если посреди неё написать /help, и честно потеряет все данные при рестарте — так мы увидим, зачем существует следующий урок про хранение данных.
Конечный автомат из учебника выглядит пугающе, но для бота это три вещи. Набор состояний — строковые константы: stepName, stepAge, stepCity. Начальное условие — отсутствие записи в карте: пустая строка, которую Go возвращает при чтении несуществующего ключа, означает «пользователь свободен, анкеты нет». Переходы — присваивания: после ответа на имя кладём в карту шаг возраста. Никаких библиотек для первого FSM не нужно: карта, константы, switch — весь каркас. Состояния называйте с префиксом сценария: анкета:имя, опрос:вопрос2 — по логу сразу видно, в каком сценарии живёт пользователь. Главное правило автомата: в каждый момент у пользователя ровно одно состояние, и все ветвления в обработчике идут по нему, а не по эвристикам вида «а что он писал раньше». Как только в коде появляется второе место, где решается, на каком шаге пользователь, автомат сломан — состояние должно читаться из карты, а не угадываться.
Каркас бота с состоянием
GOtype Bot struct {
api *tgbotapi.BotAPI
mu sync.Mutex
states map[int64]string
}
func NewBot(api *tgbotapi.BotAPI) *Bot {
return &Bot{
api: api,
states: make(map[int64]string),
}
}
const (
stepName = "анкета:имя"
stepAge = "анкета:возраст"
)Состояние принадлежит боту, а не глобальной переменной: карта и мьютекс живут в структуре рядом, и ни один метод не может их потерять. Ключ карты — chat_id типа int64, значение — строка состояния. Константы шагов с префиксом сценария читаются в логах сами по себе. make обязателен: nil-карта падает при первой же записи.
Вариация: состояние по chat_id или по user.ID#
Ключом карты в уроке выбран chat_id, и в личных чатах это совпадает с From.ID — числа одинаковые. Разница проявляется в группах: у чата Chat.ID свой, отрицательный, а From.ID — личный номер автора сообщения. Анкета с ключом chat_id в группе станет общей на всех: один человек назовёт имя, второй попадёт на шаг возраста с чужим вопросом. Правило выбора: сценарий на диалог — ключ chat_id, сценарий на человека — From.ID. Опрос участников, личные настройки, чья-то анкета — всегда From.ID. Счётчик сообщений в группе, режим молчания чата, общая заметка — chat_id. Для уверенности храните оба: структура UserState с полями ChatID и UserID живёт дольше учебного проекта и не требует миграций, когда сценарии разделятся.
Почему мьютекс: конкурентность в боте#
Сейчас цикл обновлений один: GetUpdatesChan отдаёт апдейты последовательно, и гонок на карте нет. Но состояние будут трогать не только сообщения. В девятнадцатом уроке появится горутина с тикером, которая шлёт напоминания и тоже захочет читать карту; в уроке про рассылки — фоновая отправка. Параллельная запись в map в Go не «грязная», а смертельная: runtime убивает процесс с fatal error: concurrent map writes, и поймать это нельзя — только не допускать. sync.Mutex вокруг каждой операции с картой стоит наносекунды и делает состояние безопасным заранее. Мьютексы разобраны в отдельном уроке продвинутого курса — там же про race detector, который ловит такие баги под нагрузкой. Спорное мнение напоследок: sync.Map для этого случая не нужен — он рассчитан на кэши с редкой записью, а у бота запись на каждое сообщение. Обычный mutex с defer Unlock честнее и проще в отладке.
Обработчик шагов анкеты
GOfunc (b *Bot) handleUpdate(update tgbotapi.Update) {
if update.Message == nil {
return
}
chatID := update.Message.Chat.ID
b.mu.Lock()
state := b.states[chatID]
b.mu.Unlock()
switch state {
case stepName:
b.mu.Lock()
b.states[chatID] = stepAge
b.mu.Unlock()
b.reply(chatID, "Сколько вам лет?")
case stepAge:
b.mu.Lock()
delete(b.states, chatID)
b.mu.Unlock()
b.reply(chatID, "Готово! Анкета заполнена.")
default:
if update.Message.Command() == "anketa" {
b.mu.Lock()
b.states[chatID] = stepName
b.mu.Unlock()
b.reply(chatID, "Анкета. Как вас зовут?")
}
}
}Первым делом читаем состояние под мьютексом и ветвимся по нему: каждый case — один шаг анкеты. Записи тоже короткие: взяли замок, положили, отпустили — держать мьютекс на время отправки сообщения не нужно, сеть с ним не сравнится. Ветвь default — свободный пользователь: анкета стартует только по команде /anketa, и команды обрабатываются здесь же — это следующая тема.
Сценарий: пользователь на шаге «имя» пишет /help — а бот отвечает «Сколько вам лет?». Команды провалились в case шага анкеты: состояние проверено раньше команды. Лечение: команды — /start, /help, /cancel — обрабатываются до свитча состояний, и /cancel обязан сбрасывать анкету. Иначе бот выглядит зависшим, хотя формально всё работает как запрограммировано.
Переходы и сброс состояния#
Состояние без пути назад — тюрьма для пользователя. Сброс делается delete(b.states, chatID): запись исчезает, следующее чтение вернёт пустую строку, автомат считает человека свободным. Важно, что delete безопасен и для отсутствующего ключа — паники нет, проверять «а был ли он» не нужно. Правило порядка: сначала команды, потом состояния. /start и /cancel сбрасывают анкету целиком, /help отвечает справкой независимо от шага. Отдельная проблема, о которой молчат туториалы, — время. Карта не умеет стареть: пользователь бросил анкету на шаге возраста и ушёл на неделю — запись висит, и когда он вернётся, бот всё ещё требует возраст. Для учебного бота это приемлемо; в продакшене к состоянию добавляют время последней активности и чистят устаревшее — механика появится вместе с Redis в следующем уроке. Второй честный вариант — вообще не хранить состояние в памяти, а восстанавливать его из базы по факту.
Сброс анкеты по /cancel
GOfunc (b *Bot) reset(chatID int64) {
b.mu.Lock()
delete(b.states, chatID)
b.mu.Unlock()
}
// в диспетчере команд, до свитча состояний:
case "cancel":
b.reset(chatID)
b.reply(chatID, "Анкета отменена. /anketa - начать заново")Сброс вынесен в метод с тем же протоколом мьютекса, что и у всех обращений к карте. delete не требует проверки существования ключа: отсутствие записи и есть сброс. Команда /cancel в диспетчере срабатывает до свитча состояний — поэтому работает даже посреди анкеты, в этом и её смысл.
Типичная ошибка: шаг без проверки ответа#
Анкета из урока на шаге возраста примет что угодно: «двадцать», «qwerty», случайный стикер превратится в пустую строку — и все эти ответы уедут дальше как возраст. Диагноз универсальный: переход выполнен, валидации не было. Лечение по схеме: на каждом шаге сначала проверяем формат, при неудаче остаёмся в том же состоянии и объясняем, что нужно; переход — только за корректный ответ. Возраст — strconv.Atoi плюс границы, город — непустая строка после TrimSpace, время — разбор по шаблону. Вторая половина ошибки — данные: три анкеты из ста с «qwerty» в поле возраста испортят любую статистику, а чистить продакшен-таблицу никто не любит. Проверка на входе стоит одну функцию и работает годами — редкое вложение с такой доходностью.
Память бота: что не так с map#
Карта состояний живёт в памяти процесса, и это компромисс с известной ценой. Перезапуск бота — деплой, падение, обновление на VPS — стирает всех, кто стоял посреди анкеты: пользователи получат не диалог, а молчание и заново начатую анкету. Для учебного проекта это честная цена, для продакшена нет: состояние переезжает в Redis или базу, а карта с мьютексом остаётся учебным каркасом. Вторая цена — масштаб: один процесс бота держит одну карту, и горизонтальное масштабирование с ней не работает — ещё один аргумент за внешнее хранилище. Третья — данные: имя и возраст пользователя — персональные данные, и держать их в оперативке вечно плохая идея по определению. До всего этого мы дойдём в уроке про хранение данных, а итоговый проект курса соберёт бота-трекер привычек уже на постоянном хранилище. FSM при этом останется тем же: поменяется только backend у пары методов.
Спорное мнение: библиотеки FSM боту не нужны#
На каждую технологию найдётся фреймворк конечных автоматов, и для Go-ботов их предлагают регулярно. Я не пользуюсь ими до последнего: карта со строками и switch прозрачна, дебажится печатью состояния и не тянет зависимостей. Граница, где мнение сдаётся: десятки сценариев с ветвлениями внутри сценариев, когда переходы зависят и от шага, и от собранных данных, — там граф состояний хочется видеть декларацией, а не россыпью case. Созревание простое: пока код автомата помещается на один экран, библиотека — лишний слой. Как только экран кончился, сначала выносите сценарии в отдельные функции, и лишь потом смотрите в стороны фреймворков: чаще всего хватает именно функций.
Шаги анкеты как строки автомата#
| Состояние | Бот ждёт | После ответа |
|---|---|---|
| пустая строка — свободен | команду /anketa | анкета:имя |
| анкета:имя | имя любым текстом | анкета:возраст |
| анкета:возраст | возраст числом | удаление состояния — снова свободен |
Проверка: ключ карты
Какой тип ключа у карты состояний бота в этом уроке?
Проверка: delete из карты
Что делает delete(states, chatID), если ключа в карте нет?
Что напечатает карта
Прочитайте код и выберите, что он выведет:
states := make(map[int64]string)
states[100] = "анкета:имя"
states[100] = "анкета:возраст"
delete(states, 200)
fmt.Println(states[100], states[200])Защитите запись в карту
Впишите тип мьютекса и парные операции захвата и освобождения замка.
Шесть строк чтения состояния
Расставьте строки чтения состояния с проверкой наличия ключа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карта, delete, мьютекс — чистый Go, и переходы автомата проигрываются без Telegram: механику состояний доводите до автоматизма в песочнице. Полный бот — только локально: нужен Go, go mod init и настоящий токен от BotFather, живую анкету проверяете в Telegram.
Карточки: состояние диалога
Состояние — последний кирпич базовой архитектуры бота: эхо, команды, клавиатуры и память складываются в полноценный сервис. Дальше курс идёт вглубь: хранение данных заменит карту на Redis, медиа и форматирование добавят выразительности, а финальный трекер привычек соберёт всё вместе. Inline-кнопки из девятого урока в итоговом проекте получат собственные состояния — переходы из этого урока применятся к ним буквально. Карта курса с двадцатью пятью уроками — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Состояние диалога — конечный автомат: набор строк-состояний, карта «chat_id → шаг» и переходы присваиванием.
- Отсутствие записи в карте — свободный пользователь: нулевая строка работает как начальное состояние.
- Каждое обращение к карте — под sync.Mutex: параллельная запись убивает процесс с fatal error: concurrent map writes.
- Команды — /start, /help, /cancel — обрабатываются до свитча состояний; /cancel сбрасывает анкету delete-ом.
- delete безопасен для отсутствующих ключей, чтение отсутствующего ключа возвращает пустую строку.
- Карта в памяти теряется при перезапуске — продакшен просит Redis или базу, каркас FSM не меняется.
- Механику переходов можно тренировать в песочнице; живой бот — только локальный Go с реальным токеном.
Домашнее задание
Соберите локально анкету из трёх шагов: имя, возраст, город — с проверкой, что возраст число, и отказом принимать его как имя. Добавьте /cancel, который работает на любом шаге, и проверьте сценарий из урока: /help посреди анкеты отвечает справкой, а не требует имя. Перезапустите бота и убедитесь, что карта опустела — это подготовка к уроку про Redis.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему не sync.Map, если бот многопользовательский?
Многопользовательность тут не при чём: GetUpdatesChan отдаёт апдейты последовательно, а sync.Map рассчитан на кэши, где пишут редко. У бота запись на каждое сообщение — обычный mutex и проще, и предсказуемее.
Что будет, если пользователь бросил анкету и вернулся через неделю?
Карта всё ещё помнит его шаг: бот продолжит требовать возраст. Учебный вариант — сбрасывать состояние по /start и /cancel; продакшен-вариант — хранить время последней активности и чистить устаревшее.
Можно ли хранить в карте не строку, а структуру?
Да: map[int64]UserState со структурой — следующий шаг после урока, так вместе со состоянием живут и собранные данные анкеты. Механика мьютекса при этом не меняется.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99