Хранение состояния пользователя

FSM запоминает, на каком шаге диалога находится пользователь, а storage — это место, где эта память физически лежит; данные между шагами копят через state.update_data и забирают через state.get_data.
Storage (хранилище состояний) — место, где FSM держит текущие состояния и данные пользователей: память, Redis и т. п.

Зачем тебе это нужно

В прошлом уроке «FSM: машина состояний» наш «Цыплёнок-помощник» научился вести диалог по шагам: спросил имя, дождался ответа, спросил возраст, дождался ответа. FSM как анкета, где поля заполняются по очереди. Но тут возникает важный вопрос, который новички обычно пропускают: а где, собственно, бот всё это запоминает?

Представь: ты пишешь боту имя «Макс», он переходит к вопросу про возраст. Между этими двумя сообщениями могут пройти секунды, а могут и минуты — ты отвлёкся на мем в другом чате. Бот всё это время должен помнить: «этот пользователь сейчас на шаге „ждём возраст“, и кстати его уже зовут Макс». Эта память где-то хранится. Место, где она хранится, называется storage (хранилище состояний), и от выбора storage зависит одна неприятная вещь: переживёт ли бот перезапуск.

Вот к чему мы придём в этом уроке — бот соберёт анкету по кусочкам, а в конце выдаст всё разом:

Бот:  Как тебя зовут?
Ты:   Макс
Бот:  Отлично, Макс! Сколько тебе лет?
Ты:   15
Бот:  Записал! Любимый предмет?
Ты:   информатика

Бот:  🐤 Готово! Вот твоя анкета:
      Имя: Макс
      Возраст: 15
      Любимый предмет: информатика

Результат: в чате бот задаёт три вопроса по очереди, на каждый ответ запоминает кусочек данных, а в самом конце собирает всё вместе и присылает заполненную анкету. Чтобы так уметь, нужно понять три вещи: что такое storage и какие они бывают, как докладывать данные на каждом шаге через state.update_data и как забирать всё накопленное через state.get_data. Разберём по порядку.

Storage — это камера хранения для диалогов

Вспомни камеру хранения на вокзале. Ты приходишь с рюкзаком, тебе говорят: «ячейка номер 7, вот ключ». Уходишь гулять налегке, а через час возвращаешься, открываешь свою ячейку — рюкзак на месте. Storage в FSM работает ровно так же. Только вместо рюкзака — твои данные (имя, возраст), а вместо номера ячейки — пара «кто ты + в каком ты чате».

Каждому пользователю FSM выделяет свою «ячейку». В неё кладутся две вещи: текущее состояние (на каком шаге анкеты человек сейчас находится) и накопленные данные (что он уже успел ввести). Когда приходит новое сообщение, aiogram сначала лезет в storage, достаёт «а что мы знали про этого пользователя?», и только потом решает, какой хэндлер вызвать.

State (состояние) — конкретный шаг диалога в FSM, например «ждём имя» или «ждём возраст».

Важно понять: storage и state — это не одно и то же. State — это что лежит в ячейке («сейчас ждём возраст»). Storage — это сама камера хранения, само здание с ячейками. И вот зданий бывает несколько видов, и выбор между ними — главная тема урока.

Два вида камер: MemoryStorage и Redis

В aiogram storage задаётся один раз — при создании диспетчера dp. Самый простой вариант — MemoryStorage, хранилище прямо в оперативной памяти программы.

from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage

bot = Bot(token=TOKEN)
dp = Dispatcher(storage=MemoryStorage())

Результат: диспетчер dp создаётся с хранилищем в памяти. Бот будет нормально вести диалоги и помнить состояния — ровно до тех пор, пока программа работает.

MemoryStorage — это как камера хранения, которую снесли вместе со всеми ячейками, едва ты ушёл за угол. Звучит дико, но именно так: данные лежат в оперативной памяти процесса. Стоит боту перезапуститься (ты обновил код, упал сервер, кончился интернет) — память очищается, и все «ячейки» исчезают. Пользователь, который был на середине анкеты, после перезапуска окажется как будто впервые зашёл: бот его не помнит.

Второй вариант — Redis. Это отдельная программа-хранилище, которая держит данные так, что они переживают перезапуск твоего бота. Redis как настоящая камера хранения в отдельном здании: твой бот может закрыться на ночь и открыться утром, а ячейки с рюкзаками никуда не делись.

from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage

bot = Bot(token=TOKEN)
storage = RedisStorage.from_url("redis://localhost:6379/0")
dp = Dispatcher(storage=storage)

Результат: тот же диспетчер dp, но теперь состояния и данные пользователей хранятся в Redis. После перезапуска бота человек, заполнявший анкету, продолжит с того же места — бот его «вспомнит».

Сравним по-честному, когда что брать:

ХранилищеПереживает перезапуск?Нужна ли отдельная программа?Когда брать
MemoryStorageНет — всё стираетсяНет, работает из коробкиУчёба, тесты, первые боты
RedisStorageДа — данные сохраняютсяДа, надо поднять RedisБоевой бот на сервере

Для нашего «Цыплёнка-помощника», пока ты учишься и гоняешь его на своём ноутбуке, MemoryStorage — то что надо: ничего ставить не нужно, всё работает сразу. А когда в финале курса мы выкатим бота на настоящий сервер, где он должен жить месяцами и переживать перезапуски, — переедем на Redis. Заметь главное: код хэндлеров при этом не меняется ни на строчку. Ты подменяешь storage в одном месте при создании dp, а вся работа с данными (update_data, get_data) остаётся прежней. В этом и красота: storage — это сменный «движок» под капотом.

Пример 1: докладываем данные через state.update_data

Теперь самое полезное — как класть данные в эту «ячейку». На каждом шаге анкеты мы хотим запомнить ответ пользователя, чтобы в конце собрать всё вместе. Делается это методом state.update_data(...) — он докладывает данные в ячейку, не стирая то, что уже там лежало.

Возьмём наш FSM из прошлого урока. Состояния опишем привычным образом, а в хэндлерах будем по кусочку наполнять «ячейку»:

from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message

router = Router()


class Form(StatesGroup):
    name = State()
    age = State()
    subject = State()


@router.message(Command("anketa"))
async def start_form(message: Message, state: FSMContext):
    await state.set_state(Form.name)
    await message.answer("Как тебя зовут?")


@router.message(Form.name)
async def got_name(message: Message, state: FSMContext):
    await state.update_data(name=message.text)   # докладываем имя
    await state.set_state(Form.age)
    await message.answer(f"Отлично, {message.text}! Сколько тебе лет?")


@router.message(Form.age)
async def got_age(message: Message, state: FSMContext):
    await state.update_data(age=message.text)    # докладываем возраст
    await state.set_state(Form.subject)
    await message.answer("Записал! Любимый предмет?")

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

Разберём ключевые места:

  1. state: FSMContext — этот аргумент aiogram сам передаёт в хэндлер. FSMContext — твой «ключ от ячейки» конкретного пользователя: через него и читают, и пишут данные.
  2. await state.set_state(Form.name) — переводим пользователя на шаг «ждём имя». Это про состояние, не про данные.
  3. await state.update_data(name=message.text) — а вот это про данные. Мы кладём в ячейку пару «name → то, что написал пользователь». Имя ключа (name) придумываешь ты сам.
  4. Дальше update_data(age=...) добавляет в ту же ячейку второй ключ. Важно: имя никуда не делосьupdate_data именно докладывает, а не перезаписывает всё подряд. После двух вызовов в ячейке лежит и name, и age.

Запомни аналогию: update_data — это как кидать вещи в один и тот же рюкзак. Кинул шапку, потом кинул перчатки — шапка осталась на месте, просто рядом легли перчатки.

Пример 2: забираем всё накопленное через state.get_data

Данные мы накопили — пора их достать и показать. Это делает state.get_data(): он возвращает обычный словарь Python со всем, что ты накладывал через update_data. Допишем последний хэндлер анкеты:

@router.message(Form.subject)
async def got_subject(message: Message, state: FSMContext):
    await state.update_data(subject=message.text)  # докладываем предмет

    data = await state.get_data()                  # забираем всё разом
    name = data["name"]
    age = data["age"]
    subject = data["subject"]

    await message.answer(
        "🐤 Готово! Вот твоя анкета:\n"
        f"Имя: {name}\n"
        f"Возраст: {age}\n"
        f"Любимый предмет: {subject}"
    )
    await state.clear()                            # очищаем ячейку

Результат: в чате после ответа на третий вопрос бот соберёт все три ответа и пришлёт заполненную анкету: «Имя: Макс / Возраст: 15 / Любимый предмет: информатика». После этого диалог завершается, и ячейка пользователя освобождается.

Что здесь происходит по шагам:

  1. await state.update_data(subject=message.text) — докладываем третий и последний кусочек.
  2. data = await state.get_data() — открываем ячейку и забираем весь её содержимый словарь. Теперь data — это {"name": "Макс", "age": "15", "subject": "информатика"}.
  3. data["name"] — достаём конкретные значения по ключам, ровно как из любого словаря.
  4. await state.clear() — очень важная строка. Она сбрасывает и состояние, и данные пользователя: ячейка пустеет, диалог считается законченным. Без неё бот так и останется висеть на шаге «ждём предмет».

Раз get_data() возвращает обычный словарь, всё, что ты умеешь делать со словарями в Python, работает и тут. Вот тот же разбор данных анкеты на чистом Python — этот сниппет можно запустить прямо в браузере:

# как будто это вернул state.get_data()
data = {"name": "Макс", "age": "15", "subject": "информатика"}

name = data["name"]
subject = data.get("subject", "не указан")
favorite = data.get("city", "не указан")   # такого ключа нет

print("Имя:", name)
print("Предмет:", subject)
print("Город:", favorite)

Вывод:

Имя: Макс
Предмет: информатика
Город: не указан

Обрати внимание на data.get("city", "не указан"): если ключа в словаре нет, .get вернёт запасное значение вместо ошибки. Это пригодится, когда ты не уверен, что нужный кусочек анкеты вообще был заполнен — например, поле необязательное. С data["city"] на отсутствующем ключе программа упала бы с ошибкой, а .get аккуратно подставит дефолт.

Пример 3: можно докладывать сразу несколько полей

Иногда на одном шаге логично сохранить не один кусочек, а сразу пару. update_data легко принимает несколько ключей за раз — просто перечисли их через запятую:

@router.message(Form.name)
async def got_name(message: Message, state: FSMContext):
    await state.update_data(
        name=message.text,
        name_length=len(message.text),   # заодно длину имени
    )
    await state.set_state(Form.age)
    await message.answer("Сколько тебе лет?")

Результат: в чате бот по-прежнему просто спросит возраст, но в ячейке пользователя теперь окажутся сразу два ключа — name и name_length — добавленные одним вызовом. Снаружи ничего не видно, зато в конце анкеты оба значения доступны через get_data().

Это удобно, когда часть данных ты вычисляешь сам (длину, текущую дату, выбранный язык) и хочешь сохранить рядом с тем, что ввёл пользователь. Главное правило не меняется: каждый вызов update_data добавляет ключи в общую ячейку, а не стирает то, что было раньше.

Частые ошибки новичков

  • Создал диспетчер вообще без storage. Если написать Dispatcher() без аргумента storage, в свежих версиях aiogram по умолчанию подставится MemoryStorage, но полагаться на это вслепую не стоит. Указывай storage явно: Dispatcher(storage=MemoryStorage()) — так сразу видно, какое хранилище у бота, и легче переехать на Redis позже.
  • Думаешь, что MemoryStorage сохраняет данные навсегда. Самое болезненное открытие новичка. Бот месяц копил данные пользователей, ты перезапустил его ради маленькой правки — и всё пропало. MemoryStorage живёт ровно столько, сколько работает процесс. Нужна память «навсегда» — это Redis (для FSM) или своя база данных (об этом — в модуле про SQLite).
  • Перепутал update_data и set_data. update_data докладывает ключи, сохраняя старые. А вот set_data заменяет содержимое ячейки целиком — все прежние ключи стираются. Если случайно позвать set_data(age=...) вместо update_data, ранее сохранённое имя исчезнет. В анкетах почти всегда нужен именно update_data.
  • Забыл await state.clear() в конце. Анкета вроде заполнилась, бот ответил — но пользователь так и остался в состоянии «ждём предмет». Любое его следующее сообщение снова попадёт в последний хэндлер. Завершай диалог через await state.clear() — это освобождает ячейку и возвращает пользователя в обычный режим.
  • Достаёшь данные через data["key"], которого может не быть. Если поле необязательное и пользователь его пропустил, обращение data["city"] уронит хэндлер с ошибкой KeyError. Для необязательных полей бери data.get("city", "не указан") — вернётся дефолт, и бот не упадёт.

Мини-практика: анкета для игрового клана

Теперь твоя очередь. Возьми текущий bot.py и добавь Цыплёнку команду /zayavka — анкету для вступления в игровой клан. Бот должен по шагам спросить три вещи и в конце собрать заявку:

  1. Ник в игре — сохрани под ключом nick.
  2. Любимая игра — сохрани под ключом game.
  3. Сколько часов в неделю играешь — сохрани под ключом hours.

Условия задачи: опиши состояния через класс-наследник StatesGroup (как Form в примерах). На каждом шаге докладывай ответ через state.update_data(...), а в последнем хэндлере забери всё через state.get_data() и пришли итоговую заявку вида «Ник: …, Игра: …, Часов в неделю: …». Не забудь в самом конце await state.clear().

Проверь себя по вопросам: какой метод докладывает данные, не стирая старые? Что вернёт get_data() — список, строку или словарь? Что случится, если убрать state.clear()?

Задание для смелых: добавь необязательный четвёртый вопрос — «часовой пояс» — и сохрани его под ключом tz. Сделай так, чтобы при сборке заявки часовой пояс доставался через data.get("tz", "не указан"), и тогда, даже если ты потом решишь этот шаг пропускать, бот не упадёт, а напишет «Часовой пояс: не указан».

Итоги

Сегодня «Цыплёнок-помощник» научился по-настоящему запоминать ход диалога: где это хранится, как туда складывать данные и как их забирать. Главное, что стоит унести с собой:

  • Storage — это «камера хранения» FSM: место, где физически лежат и состояние пользователя, и его накопленные данные. Задаётся один раз при создании dp.
  • MemoryStorage хранит всё в памяти процесса — просто, без настройки, но данные стираются при каждом перезапуске бота. Идеально для учёбы и тестов.
  • RedisStorage хранит данные в отдельной программе, и они переживают перезапуск — выбор для боевого бота на сервере. Код хэндлеров при смене storage не меняется.
  • state.update_data(key=value) докладывает данные в ячейку пользователя, сохраняя то, что уже там было.
  • state.get_data() возвращает обычный словарь со всем накопленным; значения достаёшь по ключам, а для необязательных полей используешь .get(key, дефолт).
  • state.clear() в конце диалога сбрасывает и состояние, и данные — без него пользователь застрянет на последнем шаге.

В следующем уроке мы научим Цыплёнка делать диалоги умнее: добавим возможность отменить анкету на любом шаге командой /cancel и вернуться назад, а заодно разберём, как мягко обрабатывать неправильные ответы — например, когда вместо возраста пользователь пишет «много». А пока поэкспериментируй с update_data и get_data: именно они превращают набор отдельных вопросов в осмысленную анкету. До встречи! 🐣

Проверьте себя
1. Что такое storage в контексте FSM aiogram?
AЭто конкретный шаг диалога, например «ждём возраст»
BЭто место, где FSM физически хранит состояния и данные пользователей
CЭто библиотека для отправки сообщений в Telegram
DЭто декоратор для хэндлеров кнопок
2. Чем MemoryStorage отличается от RedisStorage?
AMemoryStorage хранит данные в памяти процесса и теряет их при перезапуске, а Redis сохраняет данные между перезапусками
BMemoryStorage надёжнее и используется на боевых серверах
CОни полностью одинаковы, отличается только название
DRedisStorage работает без установки дополнительных программ, а MemoryStorage требует сервер
3. Какой метод докладывает данные в ячейку пользователя, не стирая то, что уже там лежало?
Astate.set_state(...)
Bstate.get_data()
Cstate.update_data(key=value)
Dstate.clear()
4. Что возвращает вызов state.get_data()?
AСтроку с последним ответом пользователя
BОбычный словарь Python со всеми накопленными данными
CСписок состояний из StatesGroup
DНомер ячейки в storage
5. Что произойдёт, если в конце анкеты забыть вызвать await state.clear()?
AБот аварийно завершит работу
BВсе данные мгновенно удалятся
CПользователь останется в последнем состоянии, и его новые сообщения снова попадут в тот же хэндлер
DStorage переключится с Redis на MemoryStorage
6. Почему для необязательного поля безопаснее доставать значение через data.get("city", "не указан"), а не data["city"]?
Adata.get работает быстрее обращения по ключу
Bdata["city"] упадёт с ошибкой KeyError, если ключа нет, а .get вернёт запасное значение
Cdata.get автоматически сохраняет данные в Redis
DМежду ними нет разницы, обе формы одинаковы