---
title: "Состояние диалога: анкета по шагам"
description: "Состояние диалога Telegram-бота на Go: FSM на карте map[int64]string с мьютексом, анкета по шагам и сброс по /cancel — примеры кода и переход к Redis."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/10-sostoyanie-dialoga/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 20
---

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

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

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

### 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 отдаёт апдейты последовательно, и гонок на карте нет. Но состояние будут трогать не только сообщения. В [девятнадцатом уроке](/courses/telegram-bot/lessons/19-napominaniya/) появится горутина с тикером, которая шлёт напоминания и тоже захочет читать карту; в уроке про рассылки — фоновая отправка. Параллельная запись в map в Go не «грязная», а смертельная: runtime убивает процесс с fatal error: concurrent map writes, и поймать это нельзя — только не допускать. sync.Mutex вокруг каждой операции с картой стоит наносекунды и делает состояние безопасным заранее. Мьютексы разобраны в [отдельном уроке продвинутого курса](/courses/go-advanced/lessons/08-muteksy/) — там же про 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, и команды обрабатываются здесь же — это следующая тема.

> **Анкета съела /help.** Сценарий: пользователь на шаге «имя» пишет /help — а бот отвечает «Сколько вам лет?». Команды провалились в case шага анкеты: состояние проверено раньше команды. Лечение: команды — /start, /help, /cancel — обрабатываются до свитча состояний, и /cancel обязан сбрасывать анкету. Иначе бот выглядит зависшим, хотя формально всё работает как запрограммировано.

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

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

### Сброс анкеты по /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 или базу, а карта с мьютексом остаётся учебным каркасом. Вторая цена — масштаб: один процесс бота держит одну карту, и горизонтальное масштабирование с ней не работает — ещё один аргумент за внешнее хранилище. Третья — данные: имя и возраст пользователя — персональные данные, и держать их в оперативке вечно плохая идея по определению. До всего этого мы дойдём в [уроке про хранение данных](/courses/telegram-bot/lessons/11-hranenie-dannyh/), а [итоговый проект курса](/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/) соберёт бота-трекер привычек уже на постоянном хранилище. FSM при этом останется тем же: поменяется только backend у пары методов.

### Спорное мнение: библиотеки FSM боту не нужны

На каждую технологию найдётся фреймворк конечных автоматов, и для Go-ботов их предлагают регулярно. Я не пользуюсь ими до последнего: карта со строками и switch прозрачна, дебажится печатью состояния и не тянет зависимостей. Граница, где мнение сдаётся: десятки сценариев с ветвлениями внутри сценариев, когда переходы зависят и от шага, и от собранных данных, — там граф состояний хочется видеть декларацией, а не россыпью case. Созревание простое: пока код автомата помещается на один экран, библиотека — лишний слой. Как только экран кончился, сначала выносите сценарии в отдельные функции, и лишь потом смотрите в стороны фреймворков: чаще всего хватает именно функций.

### Шаги анкеты как строки автомата

| Состояние | Бот ждёт | После ответа |
| --- | --- | --- |
| пустая строка — свободен | команду /anketa | анкета:имя |
| анкета:имя | имя любым текстом | анкета:возраст |
| анкета:возраст | возраст числом | удаление состояния — снова свободен |

### Проверка: ключ карты

**Вопрос:** Какой тип ключа у карты состояний бота в этом уроке?

- string — username пользователя
- ✅ int64 — chat_id
- int — порядковый номер
- CallbackQuery.ID

chat_id — int64, тот же тип, что и в NewMessage: карта связывает адрес чата с шагом анкеты. Username нестабилен и может отсутствовать, порядковых номеров никто не выдаёт.

### Проверка: delete из карты

**Вопрос:** Что делает delete(states, chatID), если ключа в карте нет?

- Паникует
- ✅ Ничего: отсутствие ключа — нормальный случай
- Создаёт ключ с пустым значением
- Возвращает false как признак неудачи

delete безопасен для отсутствующих ключей — это штатный способ сброса состояния. Чтение по отсутствующему ключу тоже без паники вернёт нулевое значение: пустую строку, которую автомат трактует как «пользователь свободен».

### Что напечатает карта

```go
states := make(map[int64]string)
states[100] = "анкета:имя"

states[100] = "анкета:возраст"
delete(states, 200)

fmt.Println(states[100], states[200])
```

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

- ✅ анкета:возраст и пустая строка
- анкета:имя и паника
- анкета:возраст и паника
- Две пустые строки

Перезапись ключа 100 переводит пользователя на шаг возраста. delete по несуществующему 200 проходит молча, а чтение возвращает пустую строку — печать покажет значение и пробел. Ровно эта механика держит сброс анкеты.

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

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

```go
var mu ___

states := make(map[int64]string)

mu.___()
states[chatID] = "анкета:имя"
defer mu.___()
```

Подсказка: Тип — sync.Mutex; перед записью Lock, а defer Unlock отпускает замок при выходе из функции.

Правильные ответы в пропусках: `sync.Mutex`, `Lock`, `Unlock`

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

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

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

1. `mu.Lock()`
2. `state, ok := states[chatID]`
3. `mu.Unlock()`
4. `if !ok {`
5. `  state = "свободен"`
6. `}`

> **Половину урока можно тренировать в песочнице.** Карта, delete, мьютекс — чистый Go, и переходы автомата проигрываются без Telegram: механику состояний доводите до автоматизма в песочнице. Полный бот — только локально: нужен Go, go mod init и настоящий токен от BotFather, живую анкету проверяете в Telegram.

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

- **FSM** — Конечный автомат: набор состояний, начальное условие и правила переходов. У бота — строковые константы и switch по текущему шагу.
- **Состояние** — Запись «на каком шаге находится пользователь». Отсутствие записи — свободное состояние: чтение вернёт пустую строку.
- **map[int64]string** — Карта «chat_id → шаг»: ключ — int64, тот же тип, что у Chat.ID. У каждого пользователя своё независимое состояние.
- **sync.Mutex** — Замок вокруг карты: Lock перед операцией, Unlock после или через defer. Защищает от concurrent map writes, когда появятся горутины.
- **delete из карты** — Штатный сброс состояния: delete(states, chatID) безопасен даже для отсутствующего ключа и не возвращает значений.
- **Гонка данных** — Параллельная запись в map убивает процесс с fatal error: concurrent map writes. Ловится race detector-ом, лечится мьютексом заранее.

Состояние — последний кирпич базовой архитектуры бота: эхо, команды, клавиатуры и память складываются в полноценный сервис. Дальше курс идёт вглубь: [хранение данных](/courses/telegram-bot/lessons/11-hranenie-dannyh/) заменит карту на Redis, медиа и форматирование добавят выразительности, а финальный [трекер привычек](/courses/telegram-bot/lessons/25-itogovyj-proekt-bot/) соберёт всё вместе. Inline-кнопки из [девятого урока](/courses/telegram-bot/lessons/09-inline-klaviatura/) в итоговом проекте получат собственные состояния — переходы из этого урока применятся к ним буквально. Карта курса с двадцатью пятью уроками — на [странице курса](/courses/telegram-bot/).

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

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