Отмена и навигация по шагам
Учимся выпускать пользователя из диалога-анкеты в любой момент и возвращать его на шаг назад — чтобы бот ощущался как нормальное приложение, а не как ловушка.
Отмена диалога — это когда пользователь говорит боту «всё, передумал» прямо посреди анкеты, а бот вежливо сбрасывает все недозаполненные данные и выходит из машины состояний, как будто диалога и не начиналось.
В уроке про 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, когда никакой анкеты и не было, бот честно скажет, что отменять нечего.
Разберём по косточкам, тут три важные детали:
StateFilter("*")— звёздочка означает «в любом состоянии». Без неё хэндлер сработал бы только в каком-то одном конкретном шаге. А нам нужно ловить/cancelи на «нике», и на «игре», и на «ранге» — на любом. Звёздочка как раз про это: «мне всё равно, на каком шаге, лови команду везде».state.get_state()— спрашивает, какое сейчас состояние у пользователя. Если он не в диалоге, вернётсяNone. Мы это проверяем, чтобы не говорить «отменил анкету» тому, кто ничего не начинал.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()после выхода и подсказки текстом.
Главная мысль урока: у пользователя всегда должна быть кнопка «выход». Диалог, из которого нельзя выйти, — это не диалог, а тупик. Дай человеку отмену и «назад» — и твой бот сразу станет ощущаться как взрослое приложение.
В следующем уроке мы научим «Цыплёнка» запоминать данные между запусками — пока что всё, что собрала анкета, живёт в памяти и пропадает при перезапуске бота. Подключим хранилище и сделаем так, чтобы введённое не терялось. До встречи!