Валидация ввода
Учим «Цыплёнка-помощника» не верить пользователю на слово: проверять каждый ответ и вежливо просить переписать, если что-то не так.
Валидация ввода — это проверка того, что пользователь прислал именно то, что бот просил: где ждали число — пришло число, где ждали имя — пришёл непустой текст, а не пустая строка или стикер.
В уроке про FSM наш бот научился вести диалог по шагам, как анкета: сначала спрашивает имя, потом возраст. А в уроке про хранение состояния он стал запоминать ответы между шагами. Всё работало отлично — пока пользователь вёл себя хорошо. Сегодня мы разберёмся, что делать, когда он ведёт себя как живой человек: пишет «двадцать» вместо «20», отправляет пустое сообщение или вообще присылает мем вместо имени.
Зачем вообще проверять ввод
Представь анкету для бота-напоминалки: «Через сколько минут напомнить про домашку?». Ты ждёшь число, чтобы потом сделать int(text) и завести таймер. А пользователь пишет «через полчасика». Что произойдёт?
text = "через полчасика"
minutes = int(text)
print(minutes)
Вывод:
Traceback (most recent call last):
File "bot.py", line 2, in <module>
minutes = int(text)
ValueError: invalid literal for int() with base 10: 'через полчасика'Программа падает с ошибкой ValueError. В обычном скрипте это просто стектрейс в консоли. А в боте — это значит, что хэндлер свалился, пользователь не получил ответа, и со стороны кажется, будто бот завис или сломался. Один человек написал «полчасика» — и бот молчит для всех.
Вот к чему мы придём к концу урока. Бот, который не падает, а спокойно объясняет, что не так, и даёт переписать:
@chick_helper_bot
🐤 Сколько тебе лет?
ты: двадцать
🐤 Хм, мне нужно число, например 14. Попробуй ещё раз 🙂
ты: 14
🐤 Отлично, записал! Тебе 14 лет.
Результат: на неправильный ответ бот не падает и не идёт дальше, а вежливо просит ввести число заново. Как только пользователь присылает корректное значение — диалог продолжается.
Запомни главное правило: любой ввод от пользователя — это враждебная среда. Не в том смысле, что люди злые, а в том, что они обязательно пришлют что-то, чего ты не ждал. Опечатаются, отправят голосовое, нажмут не туда, промахнутся по клавиатуре, скопируют не ту строку. Хороший бот готов к этому заранее.
И дело тут не только в падениях. Даже если бот не свалится с ошибкой, мусорные данные тихо отравят анкету изнутри. Представь, что ты не проверил возраст и сохранил строку «двадцать» вместо числа. Бот не упал — он спокойно записал ерунду. А потом, через три шага, попытается сравнить этот «возраст» с числом или показать его в поздравлении — и вот тогда всё сломается, причём в совершенно другом месте, далеко от настоящей причины. Искать такой баг — мучение. Поэтому проще ловить плохие данные сразу на входе, пока ты ещё точно знаешь, где и почему они появились.
Валидация как фейс-контроль
Удобная аналогия: представь валидацию как охранника-фейс-контроль на входе в твой код. Сообщение пользователя хочет пройти дальше — к сохранению в анкету, к запуску таймера, к запросу погоды. Но сначала оно подходит к охраннику.
Охранник смотрит: «Так, ты число? Проходи. А ты — пустая строка? Извини, разворачивайся, попробуй ещё раз». Плохие данные он просто не пускает внутрь, а пользователю говорит, что именно не так. И — это важно — не выгоняет его из очереди совсем: человек остаётся на том же шаге и может ввести правильный ответ.
В терминах FSM это звучит так: при неверном вводе мы не переключаем состояние (State) на следующее. Пользователь остаётся в том же состоянии, бот шлёт сообщение об ошибке — и при следующем сообщении снова срабатывает тот же хэндлер. Получается аккуратная петля: «проверяй, пока не пришлёт нормально».
Пример 1: проверяем, что пришло число
Начнём с самого частого случая — нам нужно число (возраст, количество минут, номер варианта). Соберём кусочек анкеты, который спрашивает возраст. Состояния и FSM ты уже видел в прошлых уроках, поэтому сосредоточимся на проверке.
from aiogram import F
from aiogram.types import Message
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
class Form(StatesGroup):
name = State()
age = State()
@dp.message(Form.age)
async def process_age(message: Message, state: FSMContext):
text = message.text
if text is None or not text.isdigit():
await message.answer("Хм, мне нужно число, например 14. Попробуй ещё раз 🙂")
return
age = int(text)
await state.update_data(age=age)
await message.answer(f"Отлично, записал! Тебе {age} лет.")
await state.clear()
Результат: если пользователь пишет «двадцать» или присылает картинку, бот отвечает просьбой ввести число и остаётся на шаге возраста. Если пришли цифры — бот сохраняет возраст и завершает анкету.
Разберём по шагам, что тут происходит и почему именно так:
text = message.text— забираем текст сообщения. Если пользователь прислал стикер или фото,message.textбудетNone— поэтому первым делом проверяем наNone, иначе следующая строка сама упадёт.not text.isdigit()— методisdigit()возвращаетTrue, только если строка состоит из одних цифр. «14» пройдёт, «двадцать» и «14 лет» — нет.return— самая важная строка. Мы отправили сообщение об ошибке и сразу выходим из функции. Не меняем состояние, не сохраняем мусор. Пользователь остаётся вForm.age, и его следующее сообщение снова попадёт в этот же хэндлер.- Если же проверка пройдена — спокойно делаем
int(text)(мы уже знаем, что это сработает), сохраняем и идём дальше.
Обрати внимание на приём «ранний выход» (early return): сначала отсекаем все плохие случаи и выходим, а «правильный» код пишем без вложенности, после всех проверок. Так читать гораздо легче, чем городить большой if/else.
Подводный камень с isdigit и отрицательными числами
Метод isdigit() удобен, но у него есть характер. Проверь сам:
print("14".isdigit())
print("-5".isdigit())
print("3.5".isdigit())
print("".isdigit())
Вывод:
True False False False
Видишь? isdigit() считает числом только цифры без минуса и без точки. Для возраста это даже хорошо — отрицательного возраста не бывает. Но если тебе нужны дробные или отрицательные числа, isdigit() не подойдёт, и придётся ловить ошибку через try/except — как раз об этом следующий пример.
Пример 2: универсальная проверка через try/except
Способ с isdigit() прост, но работает только для целых неотрицательных чисел. Более универсальный приём — попробовать преобразовать и поймать ошибку, если не вышло. Это идеально подходит, например, для бота-напоминалки, где можно ввести и «30», и «-1» считать ошибкой осмысленно.
@dp.message(Form.minutes)
async def process_minutes(message: Message, state: FSMContext):
try:
minutes = int(message.text)
except (ValueError, TypeError):
await message.answer("Это не похоже на число минут. Напиши, например, 30 ⏰")
return
if minutes <= 0:
await message.answer("Минут должно быть больше нуля 🙂")
return
await state.update_data(minutes=minutes)
await message.answer(f"Окей, напомню через {minutes} минут!")
await state.clear()
Результат: если пришёл не текст или не число, бот просит ввести число минут. Если число есть, но оно ноль или меньше — бот объясняет, что нужно положительное. И только корректное значение проходит дальше.
Что здесь нового и важного:
try/exceptловит сразу две ошибки:ValueError(текст не превращается в число, как «полчасика») иTypeError(на случай, еслиmessage.textравенNone— тогдаint(None)кидает именноTypeError). Один блок закрывает оба плохих случая.- После успешного преобразования идёт вторая проверка — на смысл. Число-то получили, но «−5 минут» или «0 минут» бессмысленны. Валидация бывает двухслойной: сначала «это вообще число?», потом «а это число подходит по смыслу?».
- И снова наш герой —
returnпосле каждого сообщения об ошибке. Пользователь не сдвигается с шага, пока не пришлёт нормальное значение.
Почему вообще лучше «попробовать и поймать», а не заранее проверять строку вручную? Можно было бы написать целую простыню условий: «а вдруг тут минус, а вдруг точка, а вдруг пробел внутри». Но это легко забыть какой-нибудь хитрый случай, и однажды пользователь его найдёт. А int() и так точно знает все правила, по которым строка превращается в число. Поэтому честнее довериться ему: пусть сам решит, выйдет число или нет, а мы просто аккуратно подстелим соломку через except. В программировании этот подход даже в шутку называют «проще попросить прощения, чем разрешения» — сделай попытку, а на провал среагируй.
Кстати, замечаешь закономерность? В обоих хэндлерах структура одна и та же: достали значение, проверили, при ошибке — сообщение и return, и только в самом низу — сохранение. Это не случайность, а скелет почти любого шага анкеты. Запомнишь этот скелет — и любой новый шаг будешь писать почти на автомате.
Пример 3: проверяем, что текст непустой и не слишком длинный
Числа — не единственное, что нужно проверять. Часто бот ждёт текст: имя, название команды клана, текст напоминания. Тут две типичные беды: пустота (пользователь нажал отправить случайно, прислал пробел или вовсе стикер) и слишком длинная простыня на пять тысяч знаков.
@dp.message(Form.name)
async def process_name(message: Message, state: FSMContext):
text = message.text
if text is None:
await message.answer("Мне нужно имя текстом, а не картинкой 🙂")
return
name = text.strip()
if not name:
await message.answer("Кажется, ты прислал пустое сообщение. Как тебя зовут?")
return
if len(name) > 30:
await message.answer("Ого, какое длинное! Давай покороче, до 30 символов.")
return
await state.update_data(name=name)
await message.answer(f"Приятно познакомиться, {name}! Сколько тебе лет?")
await state.set_state(Form.age)
Результат: бот отбрасывает картинки вместо имени, пустые сообщения и слишком длинные строки, каждый раз объясняя, что не так. Нормальное имя он сохраняет и переходит к вопросу о возрасте.
Главный трюк здесь — text.strip(). Он срезает пробелы и переносы по краям. Зачем? Потому что сообщение из одних пробелов — это не None и не пустая строка сама по себе, оно выглядит «непустым». А вот после strip() от него останется "", и проверка if not name его поймает. Проверь логику на чистом Python:
raw = " "
name = raw.strip()
print(repr(name))
print(bool(name))
print(not name)
Вывод:
'' False True
После strip() от трёх пробелов осталась пустая строка, у пустой строки bool равен False, поэтому not name даёт True — наша проверка срабатывает и не пускает пустышку дальше. Заодно мы сохраняем уже очищенное имя без лишних пробелов по краям — мелочь, а приятно.
Обрати внимание: только в этом хэндлере, в самом конце, мы вызываем state.set_state(Form.age) — переключаем пользователя на следующий шаг. Делаем это лишь после того, как все проверки пройдены и данные сохранены. Это и есть смысл «оставлять пользователя на том же шаге при ошибке»: переход состояния стоит после всех return, до него плохой ввод просто не доходит.
Частые ошибки и подводные камни
1. Забыл return после сообщения об ошибке
Самая коварная ошибка. Ты пишешь message.answer("Введи число"), но забываешь return — и код едет дальше, к int(text), который тут же падает. Получается, бот и ругается на пользователя, и сам валится. Правило железное: после каждого сообщения об ошибке — сразу return. Без исключений.
2. Не проверил message.text на None
Ты ждёшь текст, а пользователь шлёт фото, стикер или геолокацию. У таких сообщений message.text равен None. Если сразу позвать None.strip() или None.isdigit(), бот упадёт с AttributeError. Поэтому проверка на None всегда идёт первой, до любых строковых методов.
3. Меняешь состояние до проверки
Если переключить set_state на следующий шаг до валидации, то даже на кривой ввод пользователь улетит вперёд по анкете с пустым или неверным полем. Запомни порядок: сначала проверь, потом сохрани, и только в самом конце переключай состояние. Все переходы — после всех return.
4. Слишком сухие или грубые сообщения об ошибке
«Ошибка ввода», «Неверный формат», «Invalid input» — так нельзя. Пользователь не знает, что от него хотят. Хорошее сообщение об ошибке делает три вещи: говорит что не так, почему, и приводит пример правильного ответа: «Мне нужно число, например 14». И тон — добрый, без обвинений. Человек не виноват, что бот его не понял.
5. Проверяешь только формат, но не смысл
«14» — это число, формально всё в порядке. Но если ты спрашиваешь «сколько процентов?», то «146» — число корректное, а по смыслу бред. Не забывай вторую, смысловую проверку: попадает ли число в разумный диапазон, не отрицательное ли оно, не слишком ли большое. Формат и смысл — это два разных охранника.
Мини-практика: анкета для записи в игровой клан
Теперь твой ход. Добавь «Цыплёнку-помощнику» анкету для записи в игровой клан. Бот по очереди спрашивает три вещи и каждую проверяет:
- Ник — непустой текст от 3 до 16 символов (не забудь
strip()и проверку наNone). - Уровень — целое число от 1 до 100. Если не число — попроси переписать; если число вне диапазона — объясни границы.
- Любимый режим — текст из списка: «PvP», «PvE» или «Рейды». Если пользователь прислал что-то другое — перечисли допустимые варианты и оставь его на этом шаге.
Подсказки: для уровня используй связку try/except из примера 2 плюс проверку диапазона if not (1 <= level <= 100). Для режима собери список modes = ["PvP", "PvE", "Рейды"] и проверяй if mode not in modes. После каждой ошибки — return, переход на следующий шаг через set_state — только в самом конце хэндлера. Когда заработает, проверь сам: попробуй прислать пустоту, число буквами, уровень 9999 и режим «гонки» — на каждое бот должен спокойно попросить переписать, не теряя уже введённые поля.
Итоги
Сегодня «Цыплёнок-помощник» повзрослел: он перестал слепо доверять пользователю и научился вежливо отбивать всё кривое. Что теперь в твоём арсенале:
- проверка на
Noneпервой строкой — на случай стикеров и фото вместо текста; isdigit()для простых целых неотрицательных чисел иtry/except (ValueError, TypeError)для всего остального;text.strip()иif not name— чтобы ловить пустые строки и пробелы;- двухслойная проверка: сначала «это вообще число/текст?», потом «а оно подходит по смыслу?»;
- приём «ранний выход»: сообщение об ошибке плюс
return, а переход состояния — только после всех проверок; - добрые и понятные сообщения об ошибке с примером правильного ответа.
Главная мысль урока: бот, который не падает от любого чиха пользователя, ощущается надёжным и живым. Один return в нужном месте превращает хрупкую анкету в крепкий диалог.
В следующем уроке мы научим бота в любой момент прерывать диалог по команде /cancel — чтобы пользователь, передумав на середине анкеты, мог красиво выйти, а не застрять навсегда на шаге «введи возраст». Заодно разберём, как сбрасывать состояние и начинать заново. До встречи!