Skip to content

Aiogram 3: Finite State Machine - автомата состояний

Тема реализации конечных автоматов состояний FSM в телеграм-ботах на Aiogram 3.x.

Поднятие Redis сервера: Полное руководство (Redis будем использовать в качестве хранилища для FSM)

Также советую развернуть Redis сервер и использовать его в качестве хранилища данных для FSM. Но, если не хотите заморачиваться Redis, я покажу, как использовать его аналог MemoryStorage и расскажу почему этого лучше не делать.

Что мы будем делать сегодня?

Сегодня разберем FSM на конкретном примере: настроим анкету пользователя, захватим его логин, имя, возраст, фото и информацию о себе, а затем отобразим эти данные.

После изучения материала вы полностью овладеете навыком взаимодействия с FSM в Aiogram 3.x, и останется только научиться записывать эти данные в базу данных PostgreSQL.

Установка необходимых модулей

Для начала установим все необходимые модули:

shell
pip install aiogram python-decouple redis

redis — для взаимодействия с базой данных Redis. python-decouple — для работы с .env файлами. aiogram — библиотека для создания ботов.

Доступ к Redis

  • Хост
  • Порт
  • Номер базы данных (по умолчанию от 0 до 15)
  • (Опционально) Логин и пароль, если они заданы

Пример строки подключения к Redis без логина и пароля:

shell
redis://HOST:PORT/NUM_DB

Пример строки подключения к Redis с логином и паролем:

python
redis://LOGIN:PASSWORD@HOST:PORT/NUM_DB

Настройка create_bot.py

В файле create_bot.py создадим объект storage для хранения данных FSM. Импортируем переменные из .env файла:

python
from decouple import config
redis_url = config('REDIS_URL')

Настройка Storage

Импортируем модуль RedisStorage:

python
from aiogram.fsm.storage.redis import RedisStorage

Это импортирует класс RedisStorage из библиотеки aiogram, который используется для хранения данных конечного автомата состояний FSM в Redis.

Создаем объект RedisStorage:

python
storage = RedisStorage.from_url(config('REDIS_URL'))

Здесь мы создаем объект RedisStorage, используя URL подключения к Redis, который берем из переменной окружения REDIS_URL, загруженной с помощью config из библиотеки python-decouple.

Инициализация Dispatcher с RedisStorage:

python
dp = Dispatcher(storage=storage)

Мы создаем объект Dispatcher, передавая ему наш storage для хранения состояния конечного автомата в Redis. Это позволяет боту сохранять и восстанавливать состояния пользователей, используя Redis как хранилище.

Если вы не хотите использовать Redis в качестве хранилища, можно использовать MemoryStorage:

python
from aiogram.fsm.storage.memory import MemoryStorage

dp = Dispatcher(storage=MemoryStorage())

MemoryStorage использует оперативную память, что приводит к полной потере данных при любом сбое на сервере или в боте.

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

Таким образом, использование RedisStorage в FSM для Telegram-ботов обеспечивает высокую производительность и надежность, что делает его предпочтительным выбором для любых телеграмм ботов, но, в учебных целях, можете использовать MemoryStorage.

Сейчас приведу 2 примера, которые явно продемонстрируют отличия.

  1. Запускаем некий сценарий анкетирования (всего 5 вопросов)
  2. После третьего вопроса перезагружаем бота

Если использовалась MemoryStorage, то все данные будут потеряны и сценарий нужно будет начинать сначала. При использовании Redis - сценарий для каждого пользователя продолжится с того места, где тот остановился.

Что такое FSM?

FSM, или конечный автомат состояний Finite State Machine, — это простой способ управлять сложными взаимодействиями в вашем Telegram боте. Он помогает боту "запомнить", на каком шаге процесса находится пользователь и что делать дальше.

К примеру создадим анкету, которая будет вести пользователя через следующие шаги:

  1. Сначала спрашивает пол.
  2. Затем возраст.
  3. Потом имя и фамилию.
  4. Далее логин.
  5. Попросит отправить фото.
  6. И наконец, попросит добавить описание о себе.

FSM помогает боту отслеживать, на каком из этих шагов находится пользователь, и что спросить дальше. Если пользователь отправил имя, бот "запоминает" это и знает, что следующий шаг — запросить логин.

Настройка первого скрипта FSM

Импортируем необходимые модули:

python
from aiogram.dispatcher import FSMContext
from aiogram.dispatcher.filters.state import State, StatesGroup

Определим состояния:

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

Под каждое состояние напишем отдельные хендлеры, которые будут реагировать на ввод текста (имя и возраст):

python
import asyncio
from create_bot import bot
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from aiogram.utils.chat_action import ChatActionSender
import re

def extract_number(text):
    match = re.search(r'\b(\d+)\b', text)
    return int(match.group(1)) if match else None
      
class Form(StatesGroup):
    name = State()
    age = State()
    
questionnaire_router = Router()

@questionnaire_router.message(Command('start_questionnaire'))
async def start_questionnaire_process(message: Message, state: FSMContext):
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Привет. Напиши как тебя зовут: ')
    await state.set_state(Form.name)
    
@questionnaire_router.message(F.text, Form.name)
async def capture_name(message: Message, state: FSMContext):
    await state.update_data(name=message.text)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Супер! А теперь напиши сколько тебе полных лет: ')
    await state.set_state(Form.age)
    
@questionnaire_router.message(F.text, Form.age)
async def capture_age(message: Message, state: FSMContext):
    check_age = extract_number(message.text)

    if not check_age or not (1 <= check_age <= 100):
        await message.reply('Пожалуйста, введите корректный возраст (число от 1 до 100).')
        return
    await state.update_data(age=check_age)

    data = await state.get_data()
    msg_text = (f'Вас зовут <b>{data.get("name")}</b> и вам <b>{data.get("age")}</b> лет. '
                f'Спасибо за то что ответили на мои вопросы.')
    await message.answer(msg_text)
    await state.clear()

Импортировали FSMContext из aiogram.dispatcher, а также State и StatesGroup из aiogram.dispatcher.filters.state для работы с конечным автоматом состояний FSM в нашем боте.

  1. FSMContext:

    • Это специальный объект, который помогает нам управлять состояниями пользователя. Он хранит данные о текущем состоянии пользователя и позволяет изменять их, перемещая пользователя между различными состояниями.
    • Пример использования: С помощью FSMContext можем сохранить имя пользователя и затем перейти к следующему шагу, где спросим возраст.
  2. State и StatesGroup:

    • State представляет собой конкретное состояние, в котором может находиться пользователь.
    • StatesGroup позволяет объединять несколько состояний в логическую группу.
    • Пример использования: создаем класс Form, который наследуется от StatesGroup, и внутри него определяем состояния name и age. Это помогает нам структурировать и управлять последовательностью шагов анкеты.

Функция extract_number извлекает число из текста. Полезно на случай если пользователь вместо 20 будет писать мне 20 лет. Данная функция достанет 20 и сразу трансформирует это запись в int.

Теперь можете видеть, что у нас появился новый аргумент в функции — state. Он позволяет управлять состояниями пользователя, перемещать пользователя по состояниям и прочее.

Также под анкету создал новый роутер. Он нам пригодится далее, когда мы будем делать «боевую» анкету.

Для красоты добавил имитацию набора текста. Самое важное — это конструкция такого типа: await state.set_state(Form.name).

Эта запись указывает, что пользователь, когда дойдет до этого момента функции, окажется в состоянии Form.name, а значит, что его отправка данных (в нашем случае это ввод имени) окажется уже в этом состоянии.

Идем далее:

python
@questionnaire_router.message(F.text, Form.name)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(name=message.text)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Супер! А теперь напиши сколько тебе полных лет:')
    await state.set_state(Form.age)

Здесь применили новый метод, а именно — сохранение данных от пользователя в переменную name при помощи state.update_data. После, просто перенесли пользователя в новое состояние Form.age.

python
@questionnaire_router.message(F.text, Form.age)
async def start_questionnaire_process(message: Message, state: FSMContext):
    check_age = extract_number(message.text)

    if not check_age or not (1 <= int(message.text) <= 100):
        await message.reply("Пожалуйста, введите корректный возраст (число от 1 до 100).")
        await state.set_state(Form.age)
    else:
        await state.update_data(age=check_age)

        data = await state.get_data()
        msg_text = (f'Вас зовут <b>{data.get("name")}</b> и вам <b>{data.get("age")}</b> лет. '
                    f'Спасибо за то, что ответили на мои вопросы.')
        await message.answer(msg_text)
        await state.clear()

Здесь сначала мы проверили, есть ли в последнем сообщении пользователя возраст и находится ли он в диапазоне от 1 до 100. Если это не так, мы отправляем пользователя в состояние ввода возраста.

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

python
if not check_age or not (1 <= int(message.text) <= 100):
    await message.reply("Пожалуйста, введите корректный возраст (число от 1 до 100).")
    return

Тем самым укажем, что нужно остаться там же, но я в таких случаях обычно явно прописываю, в какое состояние нужно отправиться (когда через полгода, например, к своему проекту возвращаешься — это становится очень полезным).

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

Для того чтобы достать данные из хранилища, использовали запись:

python
data = await state.get_data()

В данном случае data — это обычный питоновский словарь, с которым можно делать все, что можно делать со словарями. Например, доставать значения по ключу.

Обратите особое внимание на:

python
await state.clear()

Это нужно делать обязательно, когда завершаете сценарий. Если вы не завершите сценарий, бот не поймет, что возраст получен, и будет ждать его до бесконечности.

Сейчас рассмотрим самый частый случай у новичков.

Пользователь заполняет анкету, а после передумывает. Нажимает через командное меню /start, а у него ничего не происходит. Дело в том, что сценарий, который мы запустили, не завершился.

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

Чтобы эту проблему избежать, стоит в своей архитектуре закладывать возможность выхода из сценария анкетирования. Всегда закладывайте в команде /start и в прочих командах (в их хендлерах) сброс сценария. Для этого необходимо следующее:

python
async def cmd_start(message: Message, state: FSMContext):
    await state.clear()

В таком случае вы автоматически ставите закрытие сценария анкетирования, и пользователь, нажав на старт, просто получит сброс данных.

Также советую добавлять возможность выхода по клику на кнопку клавиатуры. Это может быть текстовая кнопка с надписью «Отмена» или инлайн-кнопка с call_data = cancel, а далее просто обработчик, который будет закрывать (очищать) хранилище, тем самым выбивая пользователя из сценария.

На это акцентируйте особое внимание, так как проблем, связанных с «не выходом» из сценария, случается предостаточно.

Вот что получилось.

img.png

Надеюсь, что к настоящему моменту вы уловили общие принципы взаимодействия с FSM, а значит что мы можем приступать к заполнению «боевой» анкеты. Бот пойдет по такому сценарию:

  1. Сначала спрашивает пол (текстовая клавиатура с вариантами пола)
  2. Затем возраст (удалим клавиатуру)
  3. Потом имя и фамилию
  4. Далее логин (инлайн клавиатура с возможностью выбрать свой логин с профиля телеграмм если он есть)
  5. Попросит отправить фото (тут смысл в том чтоб захватить file_id фото)
  6. И наконец, попросит добавить описание о себе.

Сейчас просто перепишем код, добавив новые состояния, а в результате выведем данные, введенные пользователем, с вопросом «Все верно?» и вариантами «Все верно» и «Заполнить сначала» (этот вариант будет сбрасывать анкету и запускать её с момента ввода имени).

Текстовая клавиатура выбора пола:

python
def gender_kb():
    kb_list = [
        [KeyboardButton(text="👨‍🦱Мужчина")], [KeyboardButton(text="👩‍🦱Женщина")]
    ]
    keyboard = ReplyKeyboardMarkup(keyboard=kb_list,
                                   resize_keyboard=True,
                                   one_time_keyboard=True,
                                   input_field_placeholder="Выбери пол:")
    return keyboard

Тут я использовал текстовую клавиатуру просто для примера. Обычно стараюсь использовать инлайн-клавиатуры.

Инлайн-клавиатура проверки заполнения данных:

python
def check_data():
    kb_list = [
        [InlineKeyboardButton(text="✅Все верно", callback_data='correct')],
        [InlineKeyboardButton(text="❌Заполнить сначала", callback_data='incorrect')]
    ]
    keyboard = InlineKeyboardMarkup(inline_keyboard=kb_list)
    return keyboard

Инлайн-клавиатура, которая позволит при клике использовать логин, указанный пользователем в ТГ:

python
def get_login_tg():
    kb_list = [
        [InlineKeyboardButton(text="Использовать мой логин с ТГ", callback_data='in_login')]
    ]
    keyboard = InlineKeyboardMarkup(inline_keyboard=kb_list)
    return keyboard

Тут ещё добавим проверку, чтобы, если логина в Телеграме не было, его необходимо было указать.

Вот полный код анкеты:

python
import asyncio
from create_bot import bot
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message, ReplyKeyboardRemove, CallbackQuery
from aiogram.utils.chat_action import ChatActionSender
from keyboards.all_kb import gender_kb, get_login_tg, check_data
from utils.utils import extract_number


class Form(StatesGroup):
    gender = State()
    age = State()
    full_name = State()
    user_login = State()
    photo = State()
    about = State()
    check_state = State()


questionnaire_router = Router()


@questionnaire_router.message(Command('start_questionnaire'))
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.clear()
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Привет. Для начала выбери свой пол: ', reply_markup=gender_kb())
    await state.set_state(Form.gender)


@questionnaire_router.message((F.text.lower().contains('мужчина')) | (F.text.lower().contains('женщина')), Form.gender)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(gender=message.text, user_id=message.from_user.id)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Супер! А теперь напиши сколько тебе полных лет: ', reply_markup=ReplyKeyboardRemove())
    await state.set_state(Form.age)


@questionnaire_router.message(F.text, Form.gender)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(name=message.text)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Пожалуйста, выбери вариант из тех что в клавиатуре: ', reply_markup=gender_kb())
    await state.set_state(Form.gender)


@questionnaire_router.message(F.text, Form.age)
async def start_questionnaire_process(message: Message, state: FSMContext):
    check_age = extract_number(message.text)

    if not check_age or not (1 <= int(message.text) <= 100):
        await message.reply("Пожалуйста, введите корректный возраст (число от 1 до 100).")
        return

    await state.update_data(age=check_age)
    await message.answer('Теперь укажите свое полное имя:')
    await state.set_state(Form.full_name)


@questionnaire_router.message(F.text, Form.full_name)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(full_name=message.text)
    text = 'Теперь укажите ваш логин, который будет использоваться в боте'

    if message.from_user.username:
        text += ' или нажмите на кнопку ниже и в этом случае вашим логином будет логин из вашего телеграмм: '
        await message.answer(text, reply_markup=get_login_tg())
    else:
        text += ' : '
        await message.answer(text)

    await state.set_state(Form.user_login)

# вариант когда мы берем логин из профиля телеграмм
@questionnaire_router.callback_query(F.data, Form.user_login)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Беру логин с телеграмм профиля')
    await call.message.edit_reply_markup(reply_markup=None)
    await state.update_data(user_login=call.from_user.username)
    await call.message.answer('А теперь отправьте фото, которое будет использоваться в вашем профиле: ')
    await state.set_state(Form.photo)


# вариант когда мы берем логин из введенного пользователем
@questionnaire_router.message(F.text, Form.user_login)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(user_login=message.from_user.username)
    await message.answer('А теперь отправьте фото, которое будет использоваться в вашем профиле: ')
    await state.set_state(Form.photo)


@questionnaire_router.message(F.photo, Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    photo_id = message.photo[-1].file_id
    await state.update_data(photo=photo_id)
    await message.answer('А теперь расскажите пару слов о себе: ')
    await state.set_state(Form.about)


@questionnaire_router.message(F.document.mime_type.startswith('image/'), Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    photo_id = message.document.file_id
    await state.update_data(photo=photo_id)
    await message.answer('А теперь расскажите пару слов о себе: ')
    await state.set_state(Form.about)


@questionnaire_router.message(F.document, Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await message.answer('Пожалуйста, отправьте фото!')
    await state.set_state(Form.photo)


@questionnaire_router.message(F.text, Form.about)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(about=message.text)

    data = await state.get_data()

    caption = f'Пожалуйста, проверьте все ли верно: \n\n' \
              f'<b>Полное имя</b>: {data.get("full_name")}\n' \
              f'<b>Пол</b>: {data.get("gender")}\n' \
              f'<b>Возраст</b>: {data.get("age")} лет\n' \
              f'<b>Логин в боте</b>: {data.get("user_login")}\n' \
              f'<b>О себе</b>: {data.get("about")}'

    await message.answer_photo(photo=data.get('photo'), caption=caption, reply_markup=check_data())
    await state.set_state(Form.check_state)

# сохраняем данные
@questionnaire_router.callback_query(F.data == 'correct', Form.check_state)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Данные сохранены')
    await call.message.edit_reply_markup(reply_markup=None)
    await call.message.answer('Благодарю за регистрацию. Ваши данные успешно сохранены!')
    await state.clear()


# запускаем анкету сначала
@questionnaire_router.callback_query(F.data == 'incorrect', Form.check_state)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Запускаем сценарий с начала')
    await call.message.edit_reply_markup(reply_markup=None)
    await call.message.answer('Привет. Для начала выбери свой пол: ', reply_markup=gender_kb())
    await state.set_state(Form.gender)

Обратите внимание на изменения в классе Form, добавлены новые состояния:

python
class Form(StatesGroup):
    gender = State()
    age = State()
    full_name = State()
    user_login = State()
    photo = State()
    about = State()
    check_state = State()

Теперь рассмотрим обработчики:

python
@questionnaire_router.message((F.text.lower().contains('мужчина')) | (F.text.lower().contains('женщина')), Form.gender)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(gender=message.text)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Супер! А теперь напиши сколько тебе полных лет: ', reply_markup=ReplyKeyboardRemove())
    await state.set_state(Form.age)


@questionnaire_router.message(F.text, Form.gender)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(name=message.text)
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await asyncio.sleep(2)
        await message.answer('Пожалуйста, выбери вариант из тех что в клавиатуре: ', reply_markup=gender_kb())
    await state.set_state(Form.gender)

Тут в двух обработчиках указали Form.gender, но при этом в одном есть фильтры, которые позволяют идти дальше, а в другом их нет, и бот снова просит выбрать пол (подробно разбирали в теме магических фильтров почему так происходит).

Обратите внимание, что добавили в хранилище ещё и telegram_id пользователя под ключем user_id. Эта информация нам нужна будет для записи пользователя в базу данных.

Посмотрим на формат захвата логина пользователя.

python
text = 'Теперь укажите ваш логин, который будет использоваться в боте'

if message.from_user.username:
    text += ' или нажмите на кнопку ниже и в этом случае вашим логином будет логин из вашего телеграмм: '
    await message.answer(text, reply_markup=get_login_tg())
else:
    text += ‘ : ‘
    await message.answer(text)

Тут выполнили проверку на наличие у пользователя логина в профиле Телеграм. Если логин есть, то бот дает возможность использовать его через клик по кнопке. Если нет, то варианта с выбором логина с профиля нет.

python
# вариант когда мы берем логин из профиля телеграмм
@questionnaire_router.callback_query(F.data, Form.user_login)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Беру логин с телеграмм профиля')
    await call.message.edit_reply_markup(reply_markup=None)
    await state.update_data(user_login=call.from_user.username)
    await call.message.answer('А теперь отправьте фото, которое будет использоваться в вашем профиле: ')
    await state.set_state(Form.photo)


# вариант когда мы берем логин из введенного пользователем
@questionnaire_router.message(F.text, Form.user_login)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(user_login=message.from_user.username)
    await message.answer('А теперь отправьте фото, которое будет использоваться в вашем профиле: ')
    await state.set_state(Form.photo)

Обратите внимание, что тут в callback_query я использовал:

python
await call.message.edit_reply_markup(reply_markup=None)

Благодаря такой записи удалили инлайн клавиатуру после клика по ней.

Также обратите внимание на то, как я использовал фильтры. Отдельно обработаны ситуации работы с callback_query и с простым message.

По фото есть момент. Фотографии можно отправлять со сжатием (в таком случае бот будет видеть объект photo) или без сжатия (тогда фото будет отправлено как документ). В коде учтены все варианты, и вот что получилось:

python
@questionnaire_router.message(F.photo, Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    photo_id = message.photo[-1].file_id
    await state.update_data(photo=photo_id)
    await message.answer('А теперь расскажите пару слов о себе: ')
    await state.set_state(Form.about)

@questionnaire_router.message(F.document.mime_type.startswith('image/'), Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    photo_id = message.document.file_id
    await state.update_data(photo=photo_id)
    await message.answer('А теперь расскажите пару слов о себе: ')
    await state.set_state(Form.about)

@questionnaire_router.message(F.document, Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await message.answer('Пожалуйста, отправьте фото!')
    await state.set_state(Form.photo)

Когда тип контента — photo, все понятно. А в варианте с фото без сжатия использовали особый магический фильтр:

python
F.document.mime_type.startswith(‘image/’)

Он проверяет, является ли MIME-тип документа изображением. Если он начинается с image/, это показывает, что это изображение, и нам это подходит.

На случай, если был отправлен просто документ (например, pdf), прописали обработчик:

python
@questionnaire_router.message(F.document, Form.photo)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await message.answer(‘Пожалуйста, отправьте фото!’)
    await state.set_state(Form.photo)

Смысл в том, чтобы возвращать пользователя к отправке фото, если он прислал не то, что нужно.

Далее мы сохраняем описание о себе и получаем данные о пользователе из хранилища. Так как хотим отправить сообщение в формате анкеты, будем отвечать пользователю отправкой фото-сообщения.

python
@questionnaire_router.message(F.text, Form.about)
async def start_questionnaire_process(message: Message, state: FSMContext):
    await state.update_data(about=message.text)

    data = await state.get_data()

    caption = f'Пожалуйста, проверьте все ли верно: \n\n' \
              f'<b>Полное имя</b>: {data.get("full_name")}\n' \
              f'<b>Пол</b>: {data.get("gender")}\n' \
              f'<b>Возраст</b>: {data.get("age")} лет\n' \
              f'<b>Логин в боте</b>: {data.get("user_login")}\n' \
              f'<b>О себе</b>: {data.get("about")}'

    await message.answer_photo(photo=data.get('photo'), caption=caption, reply_markup=check_data())
    await state.set_state(Form.check_state)

Далее, в зависимости от варианта проверки, либо сохраним данные о пользователе в базе данных (следующая статья), либо запустим сценарий сначала.

python
# сохраняем данные
@questionnaire_router.callback_query(F.data == 'correct', Form.check_state)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Данные сохранены')
    await call.message.edit_reply_markup(reply_markup=None)
    await call.message.answer('Благодарю за регистрацию. Ваши данные успешно сохранены!')
    await state.clear()

# запускаем анкету сначала
@questionnaire_router.callback_query(F.data == 'incorrect', Form.check_state)
async def start_questionnaire_process(call: CallbackQuery, state: FSMContext):
    await call.answer('Запускаем сценарий с начала')
    await call.message.edit_reply_markup(reply_markup=None)
    await call.message.answer('Привет. Для начала выбери свой пол: ', reply_markup=gender_kb())
    await state.set_state(Form.gender)

Вот как анкета выглядит в действии на скринах:

img_1.png

img_2.png

Разобрали фундаментальную тему, которая откроет вам путь к созданию ботов с невероятно сложными и увлекательными сценариями FSM. Понимание этого материала — ключ к созданию действительно умных и интерактивных ботов.

Contacts: teffal@mail.ru