Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~20 мин +20 XP

Состояние диалога: анкета по шагам

Состояние диалога превращает бота из автомата-ответчика в собеседника: FSM на карте map[int64]string помнит, на каком шаге анкеты находится каждый пользователь, а sync.Mutex защищает карту от гонок. Соберём анкету из трёх шагов, аккуратно сбросим её по /cancel и поймём, когда памяти бота перестанет хватать.

Содержание урока · 11 разделов

FSM телеграм бота: машина состояний на карте#

Всё, что бот умел до сих пор, работало без памяти: каждое сообщение обрабатывалось само по себе. Но стоит попросить пользователя заполнить анкету — имя, потом возраст, потом город, — и этого мало: бот обязан помнить, что уже спросил и что дождался. Это и есть состояние диалога, а в чистом виде — конечный автомат, FSM: ограниченный набор состояний и правила переходов между ними. Для телеграм бота на Go словарь «пользователь → шаг» естественно живёт в map[int64]string: ключ — chat_id, значение — имя текущего состояния. Вокруг карты вырастает вся дисциплина урока: кто и когда пишет в неё, как сбрасывать зависшего пользователя и что произойдёт при перезапуске процесса. К концу урока бот проведёт анкету по шагам, не спутается, если посреди неё написать /help, и честно потеряет все данные при рестарте — так мы увидим, зачем существует следующий урок про хранение данных.

Конечный автомат из учебника выглядит пугающе, но для бота это три вещи. Набор состояний — строковые константы: stepName, stepAge, stepCity. Начальное условие — отсутствие записи в карте: пустая строка, которую Go возвращает при чтении несуществующего ключа, означает «пользователь свободен, анкеты нет». Переходы — присваивания: после ответа на имя кладём в карту шаг возраста. Никаких библиотек для первого FSM не нужно: карта, константы, switch — весь каркас. Состояния называйте с префиксом сценария: анкета:имя, опрос:вопрос2 — по логу сразу видно, в каком сценарии живёт пользователь. Главное правило автомата: в каждый момент у пользователя ровно одно состояние, и все ветвления в обработчике идут по нему, а не по эвристикам вида «а что он писал раньше». Как только в коде появляется второе место, где решается, на каком шаге пользователь, автомат сломан — состояние должно читаться из карты, а не угадываться.

Каркас бота с состоянием

GO
type 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 честнее и проще в отладке.

Обработчик шагов анкеты

GO
func (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, и команды обрабатываются здесь же — это следующая тема.

Переходы и сброс состояния#

Состояние без пути назад — тюрьма для пользователя. Сброс делается delete(b.states, chatID): запись исчезает, следующее чтение вернёт пустую строку, автомат считает человека свободным. Важно, что delete безопасен и для отсутствующего ключа — паники нет, проверять «а был ли он» не нужно. Правило порядка: сначала команды, потом состояния. /start и /cancel сбрасывают анкету целиком, /help отвечает справкой независимо от шага. Отдельная проблема, о которой молчат туториалы, — время. Карта не умеет стареть: пользователь бросил анкету на шаге возраста и ушёл на неделю — запись висит, и когда он вернётся, бот всё ещё требует возраст. Для учебного бота это приемлемо; в продакшене к состоянию добавляют время последней активности и чистят устаревшее — механика появится вместе с Redis в следующем уроке. Второй честный вариант — вообще не хранить состояние в памяти, а восстанавливать его из базы по факту.

Сброс анкеты по /cancel

GO
func (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])

Защитите запись в карту

Впишите тип мьютекса и парные операции захвата и освобождения замка.

var mu states := make(map[int64]string) mu.() states[chatID] = "анкета:имя" defer mu.()

Шесть строк чтения состояния

Расставьте строки чтения состояния с проверкой наличия ключа.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: состояние диалога

Термин

FSM

нажмите, чтобы перевернуть
Определение

Конечный автомат: набор состояний, начальное условие и правила переходов. У бота — строковые константы и switch по текущему шагу.

1 / 6

Состояние — последний кирпич базовой архитектуры бота: эхо, команды, клавиатуры и память складываются в полноценный сервис. Дальше курс идёт вглубь: хранение данных заменит карту на 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Состояние диалога: анкета по шагам)

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

Почему не sync.Map, если бот многопользовательский?

Многопользовательность тут не при чём: GetUpdatesChan отдаёт апдейты последовательно, а sync.Map рассчитан на кэши, где пишут редко. У бота запись на каждое сообщение — обычный mutex и проще, и предсказуемее.

Что будет, если пользователь бросил анкету и вернулся через неделю?

Карта всё ещё помнит его шаг: бот продолжит требовать возраст. Учебный вариант — сбрасывать состояние по /start и /cancel; продакшен-вариант — хранить время последней активности и чистить устаревшее.

Можно ли хранить в карте не строку, а структуру?

Да: map[int64]UserState со структурой — следующий шаг после урока, так вместе со состоянием живут и собранные данные анкеты. Механика мьютекса при этом не меняется.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99