Отмена и навигация по шагам

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

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

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

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

Представь: ты затеял в боте анкету для записи в школьный киберклуб. Бот спрашивает ник, потом любимую игру, потом ранг. Ты дошёл до середины — и понял, что вообще-то хотел не записаться, а посмотреть расписание турниров. А бот тупо стоит и долбит: «Введи свой ранг». Ты пишешь «расписание» — он не понимает, ведь он ждёт ранг. Пишешь «/start» — а у некоторых ботов и это не помогает, потому что FSM всё ещё держит тебя на шаге «ранг». Ты застрял.

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

Вот к чему мы придём. Пользователь в любой момент пишет /cancel или жмёт кнопку — и спокойно выходит:

@chick_helper_bot
Введи свой игровой ник:
> Pixel
Какая любимая игра?
> /cancel
Окей, отменил анкету 🐤 Если что — я тут, набери /join, чтобы начать заново.

Результат: в чате бот в любой момент диалога примет команду /cancel, забудет всё, что ты успел ввести, и вернёт тебя в обычный режим, где снова работают все остальные команды.

Что вообще такое «выйти из диалога»

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

Так вот, «выйти из диалога» — это просто убрать закладку. Состояние становится пустым (на жаргоне aiogram говорят None), и бот перестаёт воспринимать сообщения как ответы анкеты. Снова работают обычные команды, снова можно начать что-то новое.

За эту «закладку» в хэндлерах отвечает объект state — он прилетает в функцию-обработчик, если та работает внутри FSM. У него есть метод, который стирает всё начисто:

state.clear() — стирает у пользователя и текущее состояние (закладку), и все данные, которые он успел ввести по ходу диалога. После этого бот считает, что никакого диалога не было.

Это ключевой инструмент урока. Запомни: не state.set_state(None) руками, не какие-то хитрые сбросы — есть готовый state.clear(), и он делает ровно то, что надо: и шаг сбрасывает, и данные подчищает.

Пример 1: команда /cancel

Сделаем самое нужное — глобальную отмену. Идея простая: один хэндлер, который ловит команду /cancel в любом состоянии и сбрасывает диалог. Вот он:

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

class Join(StatesGroup):
    nick = State()
    game = State()
    rank = State()

@dp.message(Command("cancel"), StateFilter("*"))
async def cancel_dialog(message: Message, state: FSMContext):
    current = await state.get_state()
    if current is None:
        await message.answer("Сейчас нечего отменять 🐤")
        return
    await state.clear()
    await message.answer(
        "Окей, отменил анкету 🐤 Если что — набери /join, чтобы начать заново."
    )

Результат: на каком бы шаге анкеты ни находился пользователь, команда /cancel сбросит диалог и вернёт его в обычный режим. А если он напишет /cancel, когда никакой анкеты и не было, бот честно скажет, что отменять нечего.

Разберём по косточкам, тут три важные детали:

  1. StateFilter("*") — звёздочка означает «в любом состоянии». Без неё хэндлер сработал бы только в каком-то одном конкретном шаге. А нам нужно ловить /cancel и на «нике», и на «игре», и на «ранге» — на любом. Звёздочка как раз про это: «мне всё равно, на каком шаге, лови команду везде».
  2. state.get_state() — спрашивает, какое сейчас состояние у пользователя. Если он не в диалоге, вернётся None. Мы это проверяем, чтобы не говорить «отменил анкету» тому, кто ничего не начинал.
  3. state.clear() — собственно отмена: стирает закладку и данные. После него FSM пуста.

Обрати внимание на FSMContext в аргументах функции — это тип объекта state. aiogram сам подставит его в хэндлер, тебе нужно лишь указать аргумент с этим типом. Никакой магии: попросил state: FSMContext — получил доступ к закладке пользователя.

Пример 2: кнопка «Отмена» вместо команды

Команду /cancel ещё надо вспомнить и набрать. Гораздо дружелюбнее, когда прямо под вопросом висит кнопка. Сделаем reply-клавиатуру с одной кнопкой «❌ Отмена», которая показывается на каждом шаге анкеты. Помнишь reply-клавиатуру из модуля про кнопки? Кнопка при нажатии просто отправляет свой текст как обычное сообщение — этим и воспользуемся.

from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove

cancel_kb = ReplyKeyboardMarkup(
    keyboard=[[KeyboardButton(text="❌ Отмена")]],
    resize_keyboard=True,
)

@dp.message(Command("join"))
async def start_join(message: Message, state: FSMContext):
    await state.set_state(Join.nick)
    await message.answer("Введи свой игровой ник:", reply_markup=cancel_kb)

@dp.message(F.text == "❌ Отмена", StateFilter("*"))
async def cancel_button(message: Message, state: FSMContext):
    if await state.get_state() is None:
        return
    await state.clear()
    await message.answer(
        "Отменил анкету 🐤",
        reply_markup=ReplyKeyboardRemove(),
    )

Результат: когда пользователь начинает анкету по /join, под полем ввода появляется кнопка «❌ Отмена». Нажал на неё на любом шаге — бот сбросит диалог и уберёт клавиатуру, чтобы кнопка не висела зря.

Что здесь нового по сравнению с командой:

  • Ловим не команду, а текст кнопки: F.text == "❌ Отмена". Это магический фильтр F из прошлых уроков — он проверяет, что текст сообщения в точности равен надписи на кнопке.
  • ReplyKeyboardRemove() в ответе — убирает клавиатуру с кнопкой. Анкета кончилась, кнопка больше не нужна, незачем ей маячить под полем ввода.

Полезная привычка: ставь хэндлер отмены выше хэндлеров шагов в файле. aiogram проверяет хэндлеры сверху вниз и берёт первый подходящий. Если обработчик шага «ник» окажется выше отмены, он перехватит текст «❌ Отмена» и попытается сохранить его как ник. Про этот порядок ещё поговорим в ошибках.

Пример 3: кнопка «Назад» к предыдущему шагу

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

В aiogram у состояний внутри одной группы (StatesGroup) нет встроенной кнопки «назад» — он не знает, какой шаг был до текущего. Поэтому мы сами скажем: «если ты на шаге игра — вернись на ник». Делается это через тот же state.set_state(), которым мы вообще двигаем диалог вперёд:

@dp.message(F.text == "⬅️ Назад", Join.game)
async def back_to_nick(message: Message, state: FSMContext):
    await state.set_state(Join.nick)
    await message.answer("Окей, вернулись. Введи свой игровой ник заново:")

@dp.message(F.text == "⬅️ Назад", Join.rank)
async def back_to_game(message: Message, state: FSMContext):
    await state.set_state(Join.game)
    await message.answer("Вернулись на шаг назад. Какая любимая игра?")

Результат: если пользователь на шаге «игра» нажмёт «⬅️ Назад», бот переключит его обратно на шаг «ник» и снова спросит ник. С шага «ранг» кнопка вернёт на «игру». Так можно гулять по анкете туда-сюда.

Главное отличие от отмены: тут нет state.clear(). Мы не выходим из FSM, мы лишь меняем закладку на более ранний шаг через set_state. Закладка переехала назад — и следующий ответ пользователя бот примет уже как ответ на тот, предыдущий шаг.

Заметь и другое: фильтр тут не StateFilter("*"), а конкретный шаг — Join.game и Join.rank. Потому что «назад» из разных шагов ведёт в разные места: из «игры» — на «ник», из «ранга» — на «игру». Один общий хэндлер тут не подойдёт, у каждого шага свой адресат.

Маленький чистый пример: куда ведёт «назад»

Логику «какой шаг идёт перед каким» можно записать обычным словарём и проверить вообще без Telegram — это чистый Python:

steps = ["nick", "game", "rank"]
prev_step = {}
for i in range(1, len(steps)):
    prev_step[steps[i]] = steps[i - 1]

print(prev_step)
print("С шага rank кнопка Назад ведёт на:", prev_step["rank"])
print("А с первого шага nick назад идти некуда:", prev_step.get("nick"))

Вывод:

{'game': 'nick', 'rank': 'game'}
С шага rank кнопка Назад ведёт на: game
А с первого шага nick назад идти некуда: None

Видишь: у первого шага предыдущего нет — поэтому на самом первом вопросе кнопку «Назад» либо не показывают вовсе, либо она работает как отмена. Это типичное решение в реальных ботах.

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

Даже самая удобная отмена бесполезна, если пользователь о ней не знает. Хороший бот всегда подсказывает, что можно сделать прямо сейчас. Поэтому добавим к каждому вопросу короткую приписку — и кнопки навигации в одну клавиатуру:

def nav_kb(with_back: bool):
    rows = []
    if with_back:
        rows.append([KeyboardButton(text="⬅️ Назад")])
    rows.append([KeyboardButton(text="❌ Отмена")])
    return ReplyKeyboardMarkup(keyboard=rows, resize_keyboard=True)

@dp.message(Command("join"))
async def start_join(message: Message, state: FSMContext):
    await state.set_state(Join.nick)
    await message.answer(
        "Введи свой игровой ник.\n\nВ любой момент: ❌ Отмена — выйти из анкеты.",
        reply_markup=nav_kb(with_back=False),
    )

@dp.message(Join.nick, F.text)
async def got_nick(message: Message, state: FSMContext):
    await state.update_data(nick=message.text)
    await state.set_state(Join.game)
    await message.answer(
        "Принял! Какая любимая игра?\n\n⬅️ Назад — поправить ник, ❌ Отмена — выйти.",
        reply_markup=nav_kb(with_back=True),
    )

Результат: на первом шаге бот показывает только кнопку «Отмена» (назад идти некуда) и подсказывает про неё текстом. На следующих шагах добавляется «⬅️ Назад», и подсказка под вопросом объясняет, что делает каждая кнопка.

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

А state.update_data(nick=message.text) — это сохранение ответа в данные FSM (мы проходили его в уроке про FSM). Важно помнить: эти данные живут ровно до state.clear(). Отменил анкету — данные стёрлись вместе с состоянием, и это правильно: незачем хранить полузаполненную анкету.

Частые ошибки и подводные камни

1. Забыли StateFilter("*") у отмены

Самая частая засада. Пишут хэндлер /cancel без StateFilter("*") — и он не срабатывает внутри диалога. Почему? Потому что когда у пользователя стоит состояние, aiogram отдаёт его сообщения только хэндлерам с подходящим состоянием. Обычный хэндлер команды без фильтра состояния считается «для тех, кто не в диалоге». Хочешь ловить команду в любом шаге — явно ставь StateFilter("*").

2. Сбрасывают состояние, но забывают данные

Иногда новички вместо state.clear() пишут state.set_state(None). Состояние-то сбросится, а вот данные, накопленные через update_data, останутся висеть. В следующей анкете всплывёт старый, недозаполненный мусор. Запомни простое правило: для полной отмены — всегда state.clear(), он чистит и закладку, и данные разом.

3. Хэндлер шага стоит выше хэндлера отмены

aiogram перебирает хэндлеры сверху вниз и останавливается на первом подходящем. Если обработчик шага «ник» (@dp.message(Join.nick, F.text)) написан в файле выше кнопки «Отмена», то текст «❌ Отмена» попадёт в него — и сохранится как ник, вместо того чтобы отменить анкету. Лечение: хэндлеры навигации (/cancel, «Отмена», «Назад») держи выше хэндлеров шагов.

4. Кнопка «Назад» с первого шага

Если показать «⬅️ Назад» на самом первом вопросе, нажатие либо ничего не сделает (нет хэндлера), либо упадёт, если ты попытаешься перейти на несуществующий «предыдущий» шаг. На первом шаге либо не показывай «Назад» вовсе (как мы и сделали через nav_kb(with_back=False)), либо пусть она работает как отмена.

5. После отмены не убрали reply-клавиатуру

Анкета закончилась, а кнопки «Назад» и «Отмена» так и висят под полем ввода. Пользователь жмёт их вне диалога — а они уже ничего не делают, выглядит как баг. После state.clear() в ответе передавай reply_markup=ReplyKeyboardRemove(), чтобы убрать ставшую ненужной клавиатуру.

Мини-практика: анкета с полной навигацией

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

  • По /join бот спрашивает ник, потом любимую игру, потом ранг — это твоя StatesGroup с тремя State().
  • На каждом шаге под вопросом — кнопка «❌ Отмена», а начиная со второго шага ещё и «⬅️ Назад».
  • /cancel и кнопка «❌ Отмена» в любом состоянии делают state.clear() и убирают клавиатуру.
  • «⬅️ Назад» возвращает на предыдущий шаг через set_state, не теряя остальные ответы.
  • На последнем шаге, когда ранг введён, бот показывает собранную анкету (state.get_data()) и делает state.clear().

Подсказки: хэндлеры навигации ставь выше хэндлеров шагов. Для «Назад» сделай по хэндлеру на каждый шаг, кроме первого, с фильтром на конкретное состояние (Join.game, Join.rank). Клавиатуру собирай функцией nav_kb(with_back), как в примере 4. Не забудь подсказку текстом под каждым вопросом — пользователь должен видеть, что можно выйти или вернуться.

Когда заработает — добавь команду /help с фильтром StateFilter("*"), которая прямо посреди анкеты напоминает: «Ты заполняешь анкету. /cancel — выйти, ⬅️ Назад — на шаг назад». Так пользователь не потеряется, даже если забыл, где находится.

Итоги

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

  • state.clear() — полная отмена: стирает и состояние, и накопленные данные;
  • StateFilter("*") — ловить отмену (и помощь) в любом шаге диалога;
  • state.set_state(предыдущий_шаг) — навигация «Назад» без потери уже введённого;
  • проверка state.get_state() is None — чтобы не «отменять» того, кто ничего не начинал;
  • привычки: хэндлеры навигации выше хэндлеров шагов, кнопка «Назад» не на первом шаге, ReplyKeyboardRemove() после выхода и подсказки текстом.

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

В следующем уроке мы научим «Цыплёнка» запоминать данные между запусками — пока что всё, что собрала анкета, живёт в памяти и пропадает при перезапуске бота. Подключим хранилище и сделаем так, чтобы введённое не терялось. До встречи!

Проверьте себя
1. Что делает метод state.clear() в FSM aiogram?
AУдаляет сообщение пользователя из чата
BСбрасывает текущее состояние и все накопленные данные пользователя
CПереключает пользователя на следующий шаг диалога
DМеняет только кнопки под сообщением
2. Зачем хэндлеру команды /cancel нужен фильтр StateFilter("*")?
AЧтобы команда работала только на первом шаге анкеты
BЧтобы автоматически убрать клавиатуру
CЧтобы команда срабатывала в любом состоянии диалога, а не в каком-то одном
DЧтобы сохранить данные перед сбросом
3. Чем навигация «Назад» отличается от отмены в коде?
AНазад вызывает state.clear(), а отмена — set_state
BНазад использует set_state на предыдущий шаг и НЕ стирает данные, а отмена делает clear()
CОни делают абсолютно одно и то же
DНазад удаляет сообщение, а отмена нет
4. Почему важно поставить хэндлер кнопки «❌ Отмена» выше хэндлеров шагов в файле?
AИначе aiogram вообще не запустит бота
BПотому что aiogram идёт по хэндлерам сверху вниз, и хэндлер шага раньше перехватит текст «❌ Отмена» как обычный ответ
CЧтобы кнопка работала только в группах
DЧтобы данные сохранялись после отмены
5. Как узнать, находится ли пользователь сейчас в каком-либо диалоге?
AВызвать state.clear() и посмотреть на результат
BПроверить await state.get_state() — если None, диалога нет
CПосчитать количество сообщений в чате
DЭто узнать невозможно
6. Почему кнопку «⬅️ Назад» обычно не показывают на самом первом шаге анкеты?
AПотому что у первого шага нет предыдущего, возвращаться некуда
BПотому что Telegram запрещает кнопки на первом сообщении
CПотому что first-шаг нельзя редактировать
DПотому что это замедляет бота