Почему не стоит запихивать хендлеры внутрь других хендлеров

Почему нельзя делать так?

@dp.message_handler(commands=['weather'])
async def weather_handler(message: types.Message):
    await bot.send_message(message.chat.id, text='Введи название города...')

    @dp.message_handler()
    async def get_city_name(message: types.Message):
        # обработка введённых данных от юзера
        pass

PS. Табуляция верна.
PS_2. Сделал вопрос чтобы на него ссылаться в будущем.


Ответы (1 шт):

Автор решения: oleksandrigo

Ответ в большинстве своем подходит как для aiogram так и для pytelegrambotapi.

Для начала опишу примерный ход действий бота при получении сообщения.

  1. Вы запускаете бота.
  2. Он проходит по всему видимому коду, и записывает(видимые) хендлеры в свой внутренний список.
  3. Юзер отправляет команду /weather
  4. Бот проходит по списку хендлеров, и начинает сверяться с фильтрами внутри хендлеров, по типу content_types=[...], regex="...", ну или некую лябмюду func=lambda msg: msg.text=='...'
  5. Натыкается на commands=['weather'] и тригерит функцию weather_handler
  6. И тут начинается хрень. Программа проходит по коду weather_handler и видит еще один хендлер, который не имеет фильтра (по факту будет иметь content_types=['text'] и state=None(Aiogram)), и само собой записывает его в свой список хендлеров (смотри пункт 2).
  7. Дальше юзер вводит текст, который с большей долей вероятности попадает в get_city_name и вроде бы код даже работает как надо, и в целом можно забить на эту тему, при условии что ваш бот имеет только одну такую шляпу.
    Проблемы возникают когда появляется еще одна функция без фильтров.

К примеру такой код:

@dp.message_handler(commands=['weather'])
async def weather_handler(message: types.Message):
    await bot.send_message(message.chat.id, text='Введи название города...')

    @dp.message_handler()
    async def get_city_name(message: types.Message):
        # обработка введённых данных от юзера
        pass

@dp.message_handler()
async def get_all_text(message: types.Message):
    pass

Будет всё ломать. Функция get_city_name никогда не сработает, так как в список хендлеров попадет позже чем get_all_text и вы будете грустить, что не продумали это заранее.

Итак, как это исправить, чтобы это работало наверняка?

pytelegrambotapi

Для pytelegrambotapi нужно юзать register_next_step_handler.

@bot.message_handler(commands=['weather'])
def weather_handler(message: types.Message):
    bot.send_message(message.chat.id, text='Введи название города...')
    bot.register_next_step_handler(message, get_city_name)


def get_city_name(message: types.Message):
    # обработка введённых данных от юзера
    pass


@bot.message_handler()
def get_all_text(message: types.Message):
    pass

Логика работы следующая:
в register_next_step_handler пихаете первым аргументом объект Message который в целом можно брать текущий без создания нового по типу

@bot.message_handler(commands=['weather'])
def weather_handler(message: types.Message):
    msg = bot.send_message(message.chat.id, text='Введи название города...')
    bot.register_next_step_handler(msg, get_city_name)

Видел как так делают, но особого смысла нет. Разве что вы пересылаете месседж в какой-то другой чат, то тогда стоит делать именно так.
А другим аргументом название функции, которая должна сработать. Само собой в той функции должен быть обязательный аргумент message: types.Message иначе будет ошибка.
Также вы можете передавать аргументы в следующую функцию. К примеру так.

@bot.message_handler(commands=['weather'])
def weather_handler(message: types.Message):
    bot.send_message(message.chat.id, text='Введи название города...')
    bot.register_next_step_handler(message, get_city_name)


def get_city_name(message: types.Message):
    bot.send_message(message.chat.id, "Введи что-то еще")
    bot.register_next_step_handler(message, get_extra_data, message.text)


def get_extra_data(message: types.Message, city_name: str):
    print(city_name, message.text)

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

Возможные проблемы

Ну во первых нельзя красиво сделать так чтобы прервать этот register_next_step_handler.
То есть если вы вдруг захотите во время получения названия города прекратить это, то вам придется либо дойти до конца цепочки, либо в каждой такой функции писать что-то типа

def get_city_name(message: types.Message):
    if message.text == "/cancel":
        bot.send_message(message.chat.id, "Вы прервали ввод")
        return

    bot.send_message(message.chat.id, "Введи что-то еще")
    bot.register_next_step_handler(message, get_extra_data, message.text)

Что выглядит довольно убого.

Aiogram

Для aiogram стоит использовать state. Пример.

from aiogram.contrib.fsm_storage.memory import MemoryStorage
from aiogram.dispatcher import FSMContext
from aiogram.dispatcher.filters.state import StatesGroup, State

bot = Bot(token="token")
dp = Dispatcher(bot, storage=MemoryStorage())

class WeatherStates(StatesGroup):
    get_city_name = State()
    get_extra_data = State()


@dp.message_handler(commands=['weather'])
async def weather_handler(message: types.Message):
    await bot.send_message(message.chat.id, text='Введите название города...')
    await WeatherStates.get_city_name.set()


@dp.message_handler(state=WeatherStates.get_city_name)
async def get_city_name(message: types.Message, state: FSMContext):
    await bot.send_message(message.chat.id, text='Введите что-то еще...')
    await WeatherStates.next()
    async with state.proxy() as data:
        data['city_name'] = message.text


@dp.message_handler(state=WeatherStates.get_extra_data)
async def get_extra_data(message: types.Message, state: FSMContext):
    async with state.proxy() as data:
        print(data['city_name'], message.text)
    await state.finish()

Выглядит длиннее, но могу уверять что значительно надёжнее. Что мы тут делаем, импортируем нужные модули, и после делаем диспатчер с аргументом storage=MemoryStorage() нужно для того чтобы боту было где хранить данные стейтов и просто нужные данные.
Дальше делаем класс для стейтов.
После мы вместо register_next_step_handler пишем await WeatherStates.get_city_name.set() как-бы запускаем конкретное состояние для определённого юзера(а еще для определенного чата).
После юзер пишет название города и попадает в get_city_name где мы сразу меняем стейт await WeatherStates.next() на следующий в группе WeatherStates и записываем то что написал юзер в словарь.

async with state.proxy() as data:
    data['city_name'] = message.text

Примерная схема хранения такая:

память_стейта = {
    "какой-то_чат_айди": {
        "какой-то_юзер_айди": {
            "state": "",
            "data": {}# тут хранятся данные 
        }
    }
}

Ну а потом юзер снова что-то пишет мы попадаем в get_extra_data и снова заходим в хранилище и оперируем данными, как мы хотим.
И напоследок await state.finish() сбрасывает текущий стейт, и все данные в хранилище для чата->юзера.
Ну это нужно делать тогда когда вам точно не нужны данные в хранилище стейта, и сам стейт. Можно конечно сбросить только данные так await state.reset_data() или только стейт await state.reset_state(with_data=False)

→ Ссылка