Валидация ввода

Учим «Цыплёнка-помощника» не верить пользователю на слово: проверять каждый ответ и вежливо просить переписать, если что-то не так.

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

В уроке про 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()

Результат: если пользователь пишет «двадцать» или присылает картинку, бот отвечает просьбой ввести число и остаётся на шаге возраста. Если пришли цифры — бот сохраняет возраст и завершает анкету.

Разберём по шагам, что тут происходит и почему именно так:

  1. text = message.text — забираем текст сообщения. Если пользователь прислал стикер или фото, message.text будет None — поэтому первым делом проверяем на None, иначе следующая строка сама упадёт.
  2. not text.isdigit() — метод isdigit() возвращает True, только если строка состоит из одних цифр. «14» пройдёт, «двадцать» и «14 лет» — нет.
  3. returnсамая важная строка. Мы отправили сообщение об ошибке и сразу выходим из функции. Не меняем состояние, не сохраняем мусор. Пользователь остаётся в Form.age, и его следующее сообщение снова попадёт в этот же хэндлер.
  4. Если же проверка пройдена — спокойно делаем 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 — чтобы пользователь, передумав на середине анкеты, мог красиво выйти, а не застрять навсегда на шаге «введи возраст». Заодно разберём, как сбрасывать состояние и начинать заново. До встречи!

Проверьте себя
1. Зачем вообще проверять ввод пользователя в боте?
AЧтобы бот работал быстрее
BПотому что пользователь может прислать неожиданное (текст вместо числа, стикер, пустоту), и без проверки хэндлер упадёт с ошибкой
CЭтого требует синтаксис aiogram
DЧтобы сэкономить место в базе данных
2. Какая строка самая важная в хэндлере после отправки сообщения об ошибке?
Aawait state.clear()
Bawait state.set_state(...)
Creturn — чтобы выйти из функции и не выполнять код для корректного ввода
Dprint(message.text)
3. Почему перед вызовом text.isdigit() или text.strip() нужно проверить message.text на None?
AЭто требование стиля PEP 8
BПотому что у фото, стикеров и голосовых message.text равен None, и вызов строкового метода у None упадёт с AttributeError
CЧтобы сообщение быстрее дошло до Telegram
DПотому что None нельзя сохранить в FSM
4. Что вернёт "-5".isdigit()?
ATrue, потому что это число
BFalse, потому что isdigit() считает числом только цифры без минуса и точки
CОшибку ValueError
DNone
5. Зачем перед проверкой имени вызывают text.strip()?
AЧтобы перевести строку в число
BЧтобы срезать пробелы по краям: сообщение из одних пробелов после strip() станет пустой строкой и будет отброшено
CЧтобы ограничить длину до 30 символов
DЭто обязательный метод aiogram для всех хэндлеров
6. В какой момент в хэндлере правильно переключать пользователя на следующий шаг через set_state?
AВ самом начале, до любых проверок
BСразу после получения message.text
CТолько в самом конце, после того как все проверки пройдены и данные сохранены
DВнутри каждого блока с сообщением об ошибке