Стейты (состояния пользователя): waiters, длинные стейты
- Глава
- 9 / 12
- Время
- 5 мин
- Код
- 11
Это один из самых важных разделов для реального бота.
Почти любой бот рано или поздно сталкивается с такими ситуациями:
- пользователь начал сценарий, но не закончил его за одно сообщение
- бот ждёт следующий ответ
- у пользователя есть "режим", в котором он сейчас находится
- интерфейс зависит от предыдущих действий
Именно здесь появляются состояния.
В telegrinder для этого есть несколько уровней инструментов:
State(...)+ storage для долгоживущих состояний- waiter machine для коротких ожиданий
choiceиcheckboxдля готовых интерактивных шаговstate_mutatorдля более сложной и типизированной state-модели
Если коротко:
- storage это "у пользователя сейчас такое состояние"
- waiter это "я жду следующий шаг"
- state mutator это "я хочу описывать переходы между состояниями как нормальную модель"
Самый простой старт: storage#
Если вы только начинаете, проще всего стартовать с MemoryStateStorage.
import enum
from telegrinder import MemoryStateStorage, Message, StateData
from telegrinder.rules import StateMeta, Text
states = MemoryStateStorage()
class StateEnum(enum.StrEnum):
CURSED = "cursed"
BLESSED = "blessed"
@bot.on.message(
Text("/curse"),
# Команду /curse можно вызвать, если пользователь blessed
# или если у него вообще ещё нет состояния.
states.State(StateEnum.BLESSED) | states.State(StateMeta.NO_STATE),
)
async def curse_handler(message: Message) -> None:
await states.set(message.from_user.id, StateEnum.CURSED, {})
await message.answer("Теперь ты cursed.")
@bot.on.message(Text("/bless"), states.State(StateMeta.NO_STATE))
async def bless_handler(message: Message) -> None:
await states.set(message.from_user.id, StateEnum.BLESSED, {})
await message.answer("Теперь ты blessed.")
@bot.on.message()
async def any_message_handler(message: Message) -> None:
state = await states.get(message.from_user.id)
vibe = state.unwrap_or(StateData("normal", {})).key
await message.answer(f"Сейчас ты {vibe}.")Что здесь важно#
Storage хранит StateData, в котором есть:
key— имя состоянияpayload— дополнительные данные
Проверять состояние можно через rule:
states.State(StateEnum.CURSED)
states.State(StateMeta.NO_STATE)
states.State(StateMeta.ANY)Это уже закрывает очень много прикладных задач.
Например:
- пользователь в режиме оформления заказа
- пользователь проходит onboarding
- пользователь уже авторизован
- пользователь сейчас редактирует профиль
Чтение, запись и удаление состояния#
Самая частая тройка операций:
set(...)get(...)delete(...)
await states.set(user_id, StateEnum.BLESSED, {"source": "admin"})
state = await states.get(user_id)
await states.delete(user_id)MemoryStateStorage хорош для:
- локальной разработки
- тестов
- маленьких ботов
Но он не переживает рестарт процесса.
Если состояние должно жить дольше, обычно делают свою реализацию ABCStateStorage поверх Redis, базы данных или другого внешнего хранилища.
Когда storage удобнее всего#
Storage очень хорош, когда состояние похоже на "флажок" или "режим":
- пользователь сейчас в wizard-е
- пользователь заблокирован
- пользователь выбрал язык
- пользователь находится на шаге 3 из 5
Если состояние нужно просто проверить правилом перед хендлером, storage обычно самый прямой и понятный выбор.
Waiters: когда бот ждёт следующий шаг#
Не всегда хочется хранить состояние долго.
Иногда бот делает что-то гораздо проще:
- задал вопрос
- ждёт один следующий ответ
- продолжает сценарий
Вот для этого нужна waiter machine.
Она уже встроена в dispatch и views, поэтому отдельно поднимать её обычно не нужно.
Практически это даёт возможность:
- дождаться следующего сообщения
- дождаться нажатия inline-кнопки
- ограничить время ожидания
- применить фильтры к тому, что считается "подходящим" ответом
Это особенно полезно для коротких воронок и интерактивных шагов.
Готовые сценарии Choice и Checkbox#
Очень часто руками собирать waiter вообще не нужно.
Choice#
Это хороший вариант, когда в конце должен быть выбран ровно один вариант.
@bot.on.message(Text("/choice"))
async def action(message: Message) -> None:
chosen, message_id = await (
bot.dispatch.choice(message.chat.id, message="Choose something", max_in_row=1)
# Первый текст — обычный, второй — вид выбранного варианта.
.add_option("apple", "Apple 🔴", "Apple 🟢")
.add_option("banana", "Banana 🔴", "Banana 🟢", is_picked=True)
.add_option("pear", "Pear 🔴", "Pear 🟢")
.wait(message.api)
)
await message.edit(
text=f"You chose: {chosen}",
message_id=message_id,
)Checkbox#
Это вариант, когда можно выбрать несколько элементов.
@bot.on.message(Text("/checkbox"))
async def action(message: Message) -> None:
picked, message_id = await (
bot.dispatch.checkbox(
message.chat_id,
message="Check your checkbox",
cancel_text="Cancel",
max_in_row=2,
)
.add_option("apple", "Apple", "Apple 🍏")
.add_option("banana", "Banana", "Banana 🍌", is_picked=True)
.add_option("pear", "Pear", "Pear 🍐")
.wait(message.api)
)
await message.edit(
text="You picked: {}".format(", ".join(key for key, value in picked.items() if value)),
chat_id=message.chat.id,
message_id=message_id,
)Это очень дружелюбный путь для новичка:
- не нужно руками проектировать callback flow
- не нужно вручную собирать keyboard state
- не нужно самостоятельно держать короткое состояние шага
Где тут state_mutator#
Вот здесь начинается другой уровень.
Если storage отвечает на вопрос:
"какое у пользователя состояние сейчас?"
то state_mutator отвечает на вопрос:
"как вообще устроена модель состояний и переходов между ними?"
Это полезно, когда у вас не просто "одна строка в storage", а действительно сценарий с переходами.
Примеры:
- плеер:
Stopped -> Playing -> Paused - персонаж в игре:
Alive -> Dead -> Alive - заказ:
Draft -> WaitingPayment -> Paid -> Shipped
В таких случаях state_mutator делает модель гораздо приятнее и безопаснее.
Первая модель на state_mutator#
Начнём с дружелюбного примера "жив / мёртв".
import dataclasses
import datetime
from telegrinder.tools.state_mutator import State, StateMutator, mutation
@dataclasses.dataclass
class AliveState(State):
__description__ = "живой"
# Можно хранить полезные данные прямо в состоянии.
since: datetime.datetime = dataclasses.field(default_factory=datetime.datetime.now)
@mutation
def die(self, reason: str) -> "DeadState":
# Переход Alive -> Dead
return DeadState(reason)
@dataclasses.dataclass
class DeadState(State):
reason: str
@mutation
def resurrect(self) -> "AliveState":
# Переход Dead -> Alive
return AliveState()
@property
def __description__(self) -> str:
return f"мёртв, причина: {self.reason}"Здесь уже видно главное:
- состояние описывается классом
- переходы описываются методами с
@mutation - возвращаемый тип показывает, в какое состояние идёт переход
То есть код начинает читаться почти как предметная модель.
Использование StateMutator в хендлерах#
Вот как это подключается в боте:
@bot.on.message(Text("/die"))
async def die_handler(alive: AliveState) -> str:
# Если обработчик вызвался, значит текущее состояние уже AliveState.
new_state = await alive.die("sadness")
return f"Теперь ты {new_state.__description__}"
@bot.on.message(Text("/resurrect"))
async def resurrect_handler(dead: DeadState) -> str:
await dead.resurrect()
return "Ты воскрес"
@bot.on.message()
async def in_state_handler(state: AliveState | DeadState) -> str:
# Можно принять union и реагировать на несколько допустимых состояний.
return f"Сейчас ты {state.__description__}"Что тут особенно приятно:
- если в хендлере принимается
alive: AliveState, он сработает только когда пользователь действительно в этом состоянии - переходы вызываются как методы объекта состояния
- код получается очень близким к языку предметной области
Начальное состояние и "внешние" мутации#
Мутация может быть не только методом класса.
from telegrinder.tools.state_mutator import mutation
be_born = mutation(AliveState)
@mutation
def login_as_ghost(silently: bool = False):
if not silently:
print("Ghost just logged in ~*_*~")
return DeadState(reason="~*being a ghost*~")Это полезно, когда переход:
- не принадлежит конкретному состоянию
- является точкой входа в сценарий
- должен запускаться "снаружи"
Использование:
@bot.on.message(Text("/be_born"))
async def be_born_handler(mutator: StateMutator) -> str:
await be_born(mutator)
return "Ты родился"
@bot.on.message(Text("Gh0$T_рa$$w0rd"))
async def ghost_handler(mutator: StateMutator) -> str:
await login_as_ghost(mutator, silently=True)
return "Теперь ты призрак"Здесь StateMutator выступает как объект, который умеет применить переход к текущему пользователю.
Более практичный пример: мини-плеер#
Теперь посмотрим на сценарий, который больше похож на реальную модель.
import datetime
from dataclasses import dataclass
from telegrinder.tools.state_mutator import State, mutation
@dataclass
class Stopped(State):
@mutation
def play(self, song: str, offset: datetime.timedelta = datetime.timedelta(0)) -> "Playing":
return Playing(song, offset, datetime.datetime.now())
@dataclass
class Playing(State):
song: str
offset: datetime.timedelta
started_at: datetime.datetime
@mutation
def stop(self) -> "Stopped":
return Stopped()
@mutation
def pause(self) -> "Paused":
offset = datetime.datetime.now() - self.started_at
return Paused(song=self.song, offset=offset + self.offset, stopped_at=datetime.datetime.now())
@dataclass
class Paused(State):
song: str
offset: datetime.timedelta
stopped_at: datetime.datetime
@mutation
def stop(self) -> "Stopped":
return Stopped()
@mutation
def play(self) -> "Playing":
return Playing(self.song, self.offset, datetime.datetime.now())А теперь хендлеры:
@bot.on.message(Command("play", Argument("song_name", optional=True)))
async def play_song_handler(state: Stopped | Paused | Playing, song_name: str | None = None) -> str:
match state:
case Stopped():
if song_name is None:
return "Нужно указать песню"
await state.play(song_name)
return f"Запустил {song_name}"
case Paused():
await state.play()
return f"Продолжаю {state.song}"
case Playing():
return "Уже играет"Почему это хорошо:
- переходы описаны прямо на состояниях
- логика становится очень читаемой
- меньше строковых сравнений
- типы начинают реально помогать
Для сложных ботов это намного приятнее, чем хранить вручную строки вроде "playing" и "paused" плюс отдельно всё распутывать.
Когда state_mutator стоит использовать#
Он особенно полезен, если:
- состояний несколько и у них есть явные переходы
- в состоянии нужно хранить данные
- вы хотите, чтобы код читалcя как модель процесса
- обычный storage начинает превращаться в сетку
if/elif
Если же у вас просто один режим "waiting_for_name", "waiting_for_email" и всё, storage может быть проще.
Как выбирать инструмент#
Очень практическое правило:
- если нужен просто флаг или режим пользователя, берите storage
- если бот ждёт один ближайший ответ, берите waiter
- если нужен быстрый UX через inline-кнопки, берите
choiceилиcheckbox - если у вас настоящая машина состояний с переходами, смотрите на
state_mutator
Не обязательно использовать всё сразу.
Нормально начать с storage и прийти к state_mutator только тогда, когда простой подход реально перестал тянуть.
Что запомнить#
- states в telegrinder бывают на нескольких уровнях абстракции
MemoryStateStorageэто самый простой вход в тему- waiter machine полезна для коротких интерактивных шагов
choiceиcheckboxзакрывают много UX-задач почти без ручной работыstate_mutatorхорош там, где состояние это уже не просто строка, а полноценная модель переходов