Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion docs/guides/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,45 @@ async def age_handler(event: MessageCreated, context: MemoryContext):
- `set_state(state)` — установить состояние (State или None для сброса)
- `get_state()` — получить текущее состояние
- `get_data()` — получить все данные контекста
- `update_data(**kwargs)` — обновить данные
- `update_data(**kwargs)` — обновить данные и вернуть актуальный словарь
- `set_data(data)` — полностью заменить данные
- `clear()` — очистить контекст и сбросить состояние

## Получение контекста вне хендлера

```python
context = dp.fsm.get_context(
chat_id=chat_id,
user_id=user_id,
)
```
Comment on lines +47 to +52

Для простых операций можно не получать объект контекста вручную:

```python
await dp.fsm.set_state(
chat_id=chat_id,
user_id=user_id,
state=Form.name,
)
await dp.fsm.update_data(
chat_id=chat_id,
user_id=user_id,
name="Макс",
)
await dp.fsm.update_data(
chat_id=chat_id,
user_id=user_id,
data={"chat_id": "значение в данных"},
)
data = await dp.fsm.get_data(chat_id=chat_id, user_id=user_id)
await dp.fsm.clear(chat_id=chat_id, user_id=user_id)
```

Метод использует то же хранилище, TTL и LRU-кеш, что и обычная
обработка событий. FSM manager доступен через `Dispatcher`: используйте
`dp.fsm`, а не `router.fsm`.

## TTL для контекста

Для автоматической очистки неактивных контекстов можно передать `ttl`
Expand Down
2 changes: 2 additions & 0 deletions maxapi/context/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from ..context.state_machine import State, StatesGroup
from .base import BaseContext
from .context import MemoryContext, RedisContext
from .manager import ContextManager

__all__ = [
"BaseContext",
"ContextManager",
"MemoryContext",
"RedisContext",
"State",
Expand Down
4 changes: 2 additions & 2 deletions maxapi/context/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ async def set_data(self, data: dict[str, Any]) -> None:
"""Полностью заменяет контекст данных."""

@abstractmethod
async def update_data(self, **kwargs: Any) -> None:
"""Обновляет контекст данных новыми значениями."""
async def update_data(self, **kwargs: Any) -> dict[str, Any]:
"""Обновляет данные и возвращает актуальный контекст."""

@abstractmethod
async def set_state(self, state: State | str | None = None) -> None:
Expand Down
20 changes: 15 additions & 5 deletions maxapi/context/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,22 @@ async def set_data(self, data: dict[str, Any]) -> None:
self._context = data
self._ttl_tracker.touch()

async def update_data(self, **kwargs: Any) -> None:
async def update_data(self, **kwargs: Any) -> dict[str, Any]:
"""
Обновляет контекст данных новыми значениями.

Args:
**kwargs: Пары ключ-значение для обновления

Returns:
Актуальный словарь данных.
"""

async with self._lock:
await self._expire_if_needed()
self._context.update(kwargs)
self._ttl_tracker.touch()
return self._context.copy()

async def set_state(self, state: State | str | None = None) -> None:
"""
Expand Down Expand Up @@ -142,9 +146,12 @@ async def set_data(self, data: dict[str, Any]) -> None:
await self.redis.set(self.data_key, payload, px=ttl_ms)
await self.redis.pexpire(self.state_key, ttl_ms)

async def update_data(self, **kwargs: Any) -> None:
async def update_data(self, **kwargs: Any) -> dict[str, Any]:
"""
Атомарно обновляет данные
Атомарно обновляет данные.

Returns:
Актуальный словарь данных.
"""
lua_script = """
local data = redis.call('get', KEYS[1])
Expand All @@ -161,10 +168,10 @@ async def update_data(self, **kwargs: Any) -> None:
else
redis.call('set', KEYS[1], cjson.encode(decoded))
end
return redis.status_reply("OK")
return cjson.encode(decoded)
"""
ttl_ms = _ttl_to_ms(self.ttl)
await self.redis.eval(
result = await self.redis.eval(
lua_script,
1,
self.data_key,
Expand All @@ -173,6 +180,9 @@ async def update_data(self, **kwargs: Any) -> None:
)
if ttl_ms is not None:
await self.redis.pexpire(self.state_key, ttl_ms)
if isinstance(result, bytes):
result = result.decode("utf-8")
return json.loads(result) if result else {}

async def set_state(self, state: State | str | None = None) -> None:
if state is None:
Expand Down
153 changes: 153 additions & 0 deletions maxapi/context/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any

from ..dispatcher import Dispatcher
from .base import BaseContext
from .state_machine import State


class ContextManager:
"""
Высокоуровневый доступ к контекстам диспетчера.
"""

def __init__(
self,
dispatcher: Dispatcher,
context_getter: Callable[[int | None, int | None], BaseContext],
) -> None:
self.dispatcher = dispatcher
self._context_getter = context_getter

def _get_context(
self, chat_id: int | None, user_id: int | None
) -> BaseContext:
"""Возвращает контекст по идентификаторам."""
return self._context_getter(chat_id, user_id)

def get_context(
self,
*,
chat_id: int | None,
user_id: int | None,
) -> BaseContext:
"""
Возвращает контекст пользователя по идентификаторам.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.

Returns:
Контекст.
"""
return self._get_context(chat_id, user_id)

async def set_state(
self,
*,
chat_id: int | None,
user_id: int | None,
state: State | str | None = None,
) -> None:
"""
Устанавливает состояние пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.
state: Новое состояние или None для сброса.
"""
context = self._get_context(chat_id, user_id)
await context.set_state(state)

async def get_state(
self, *, chat_id: int | None, user_id: int | None
) -> State | str | None:
"""
Возвращает состояние пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.

Returns:
Текущее состояние или None.
"""
context = self._get_context(chat_id, user_id)
return await context.get_state()

async def set_data(
self,
*,
chat_id: int | None,
user_id: int | None,
data: dict[str, Any],
) -> None:
"""
Полностью заменяет данные пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.
data: Новый словарь контекста.
"""
context = self._get_context(chat_id, user_id)
await context.set_data(data)

async def get_data(
self, *, chat_id: int | None, user_id: int | None
) -> dict[str, Any]:
"""
Возвращает данные пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.

Returns:
Словарь с данными контекста.
"""
context = self._get_context(chat_id, user_id)
return await context.get_data()

async def update_data(
self,
*,
chat_id: int | None,
user_id: int | None,
data: dict[str, Any] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
Обновляет данные пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.
data: Словарь значений для обновления.
**kwargs: Пары ключ-значение для обновления.

Returns:
Актуальный словарь данных.
"""
context = self._get_context(chat_id, user_id)
update = dict(data or {})
update.update(kwargs)
return await context.update_data(**update)

Comment on lines +119 to +143
Comment on lines +119 to +143
async def clear(self, *, chat_id: int | None, user_id: int | None) -> None:
"""
Очищает данные и состояние пользователя.

Args:
chat_id: Идентификатор чата.
user_id: Идентификатор пользователя.
"""
context = self._get_context(chat_id, user_id)
await context.clear()
20 changes: 18 additions & 2 deletions maxapi/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from aiohttp import ClientConnectorError

from .context import BaseContext, MemoryContext
from .context import BaseContext, ContextManager, MemoryContext
from .enums.update import UpdateType
from .exceptions.dispatcher import HandlerException, MiddlewareException
from .exceptions.max import InvalidToken, MaxApiError, MaxConnection
Expand Down Expand Up @@ -116,6 +116,7 @@ def __init__(
self.router_id = router_id
self.storage = storage
self.storage_kwargs = storage_kwargs
self._fsm = ContextManager(self, self.__get_context)

self.event_handlers: list[Handler] = []
self.handlers_by_type: dict[UpdateType, list[Handler]] | None = None
Expand Down Expand Up @@ -198,6 +199,13 @@ def __init__(
)
self.on_started = Event(update_type=UpdateType.ON_STARTED, router=self)

@property
def fsm(self) -> ContextManager:
"""
Менеджер FSM-контекстов диспетчера.
"""
return self._fsm

@property
def middlewares(self) -> list[BaseMiddleware]:
"""
Expand Down Expand Up @@ -510,7 +518,7 @@ def __get_context(
user_id: Идентификатор пользователя.

Returns:
BaseContext: Контекст.
Контекст.
"""

key = (chat_id, user_id)
Expand Down Expand Up @@ -1539,6 +1547,14 @@ def __init__(self, router_id: str | None = None):

super().__init__(router_id)

@property
def fsm(self) -> ContextManager:
"""
Роутер не владеет FSM-хранилищем.
"""
msg = "Router не владеет FSM-хранилищем. Используйте dp.fsm."
raise RuntimeError(msg)


class Event:
"""
Expand Down
Loading
Loading