python-trueconf-bot 1.2.3.dev1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, overload
5
+
6
+ from trueconf.fsm.key_builder import StorageKey
7
+ from trueconf.fsm.state import State
8
+ from trueconf.fsm.storage.base import BaseStorage
9
+
10
+
11
+ class FSMContext:
12
+ def __init__(self, storage: BaseStorage, key: StorageKey) -> None:
13
+ self._storage = storage
14
+ self._key = key
15
+
16
+ @property
17
+ def key(self) -> StorageKey:
18
+ return self._key
19
+
20
+ @property
21
+ def storage(self) -> BaseStorage:
22
+ return self._storage
23
+
24
+ async def get_state(self) -> str | None:
25
+ return await self._storage.get_state(self._key)
26
+
27
+ async def set_state(self, state: State | str | None) -> None:
28
+ value = str(state) if isinstance(state, State) else state
29
+ await self._storage.set_state(self._key, value)
30
+
31
+ async def get_data(self) -> dict[str, Any]:
32
+ return await self._storage.get_data(self._key)
33
+
34
+ async def set_data(self, data: Mapping[str, Any]) -> None:
35
+ await self._storage.set_data(self._key, dict(data))
36
+
37
+ @overload
38
+ async def get_value(self, key: str) -> Any | None: ...
39
+
40
+ @overload
41
+ async def get_value(self, key: str, default: Any) -> Any: ...
42
+
43
+ async def get_value(self, key: str, default: Any | None = None) -> Any | None:
44
+ return await self._storage.get_value(self._key, key, default)
45
+
46
+ async def update_data(
47
+ self,
48
+ data: Mapping[str, Any] | None = None,
49
+ **kwargs: Any,
50
+ ) -> dict[str, Any]:
51
+ if data:
52
+ kwargs.update(data)
53
+ return await self._storage.update_data(self._key, kwargs)
54
+
55
+ async def clear(self) -> None:
56
+ await self.set_state(None)
57
+ await self.set_data({})
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from trueconf.fsm.context import FSMContext
6
+ from trueconf.fsm.state import State
7
+
8
+
9
+ class StateFilter:
10
+ def __init__(self, *states: State | str | None) -> None:
11
+ self._states: set[str | None] = {
12
+ str(s) if isinstance(s, State) else s for s in states
13
+ }
14
+ self._wildcard = "*" in self._states
15
+
16
+ async def __call__(
17
+ self,
18
+ event: Any,
19
+ *,
20
+ state: FSMContext | None = None,
21
+ ) -> bool:
22
+ if state is None:
23
+ raise RuntimeError(
24
+ "StateFilter requires FSMContext in data['state'], but it was not found. "
25
+ "Make sure FSMMiddleware is registered as outer middleware "
26
+ "before any StateFilter is evaluated."
27
+ )
28
+ if self._wildcard:
29
+ return True
30
+ current = await state.get_state()
31
+ return current in self._states
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class StorageKey:
9
+ bot_id: str | None
10
+ chat_id: str
11
+ user_id: str
12
+ destiny: str = "default"
13
+
14
+
15
+ @runtime_checkable
16
+ class KeyBuilder(Protocol):
17
+ def build(self, bot: Any, event: Any) -> StorageKey: ...
18
+
19
+
20
+ class DefaultKeyBuilder:
21
+ def build(self, bot: Any, event: Any) -> StorageKey:
22
+ bot_id = getattr(bot, "me_id", None) or getattr(bot, "id", None)
23
+
24
+ chat_id = (
25
+ getattr(event, "chat_id", None)
26
+ or getattr(getattr(event, "chat", None), "id", None)
27
+ )
28
+
29
+ user = getattr(event, "from_user", None) or getattr(event, "author", None)
30
+ user_id = getattr(user, "id", None) if user else None
31
+
32
+ if chat_id is None or user_id is None:
33
+ raise RuntimeError(
34
+ f"Cannot build FSM StorageKey: event of type {type(event).__name__} "
35
+ f"has no chat_id ({chat_id}) or user_id ({user_id}). "
36
+ f"Provide a custom KeyBuilder to Dispatcher.setup_fsm()."
37
+ )
38
+
39
+ return StorageKey(bot_id=bot_id, chat_id=str(chat_id), user_id=str(user_id))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from trueconf.fsm.context import FSMContext
6
+ from trueconf.fsm.key_builder import DefaultKeyBuilder, KeyBuilder, StorageKey
7
+ from trueconf.fsm.storage.base import BaseStorage
8
+ from trueconf.fsm.storage.memory import MemoryStorage
9
+ from trueconf.fsm.strategy import FSMStrategy, apply_strategy
10
+
11
+
12
+ class FSMManager:
13
+ def __init__(
14
+ self,
15
+ storage: BaseStorage | None = None,
16
+ key_builder: KeyBuilder | None = None,
17
+ strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT,
18
+ ) -> None:
19
+ self.storage: BaseStorage = storage or MemoryStorage()
20
+ self.key_builder: KeyBuilder = key_builder or DefaultKeyBuilder()
21
+ self.strategy = strategy
22
+
23
+ def get_context(self, bot: Any, event: Any) -> FSMContext:
24
+ key = self.key_builder.build(bot, event)
25
+ chat_id, user_id = apply_strategy(
26
+ self.strategy,
27
+ key.chat_id,
28
+ key.user_id,
29
+ )
30
+ adjusted_key = StorageKey(
31
+ bot_id=key.bot_id,
32
+ chat_id=chat_id,
33
+ user_id=user_id,
34
+ destiny=key.destiny,
35
+ )
36
+ return FSMContext(self.storage, adjusted_key)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from trueconf.fsm.manager import FSMManager
6
+ from trueconf.middleware import BaseMiddleware
7
+
8
+
9
+ class FSMMiddleware(BaseMiddleware):
10
+ def __init__(self, fsm_manager: FSMManager) -> None:
11
+ self._manager = fsm_manager
12
+
13
+ async def __call__(
14
+ self,
15
+ handler: Any,
16
+ event: Any,
17
+ data: dict[str, Any],
18
+ ) -> None:
19
+ bot = data.get("bot")
20
+ if bot is not None:
21
+ context = self._manager.get_context(bot, event)
22
+ data["state"] = context
23
+ data["raw_state"] = await context.get_state()
24
+ return await handler(event, data)
trueconf/fsm/state.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any
5
+
6
+
7
+ class State:
8
+ def __init__(self, state: str | None = None) -> None:
9
+ self._custom_state = state
10
+ self._name: str | None = None
11
+ self._group: type[StatesGroup] | None = None
12
+
13
+ def bind(self, group: type[StatesGroup], name: str) -> None:
14
+ if self._group is not None or self._name is not None:
15
+ raise RuntimeError(
16
+ f"State '{self._name}' is already bound to {self._group}. "
17
+ f"Cannot rebind to {group.__name__}:{name}."
18
+ )
19
+ self._group = group
20
+ self._name = name
21
+
22
+ def __str__(self) -> str:
23
+ if self._custom_state is not None:
24
+ return self._custom_state
25
+ if self._group is None or self._name is None:
26
+ raise RuntimeError(
27
+ "State is not bound to a StatesGroup. "
28
+ "Use State inside a StatesGroup class definition, "
29
+ "or pass a custom string: State('my:state')."
30
+ )
31
+ return f"{self._group.__full_group_name__}:{self._name}"
32
+
33
+ def __repr__(self) -> str:
34
+ return f"State({str(self)!r})"
35
+
36
+ def __eq__(self, other: object) -> bool:
37
+ if isinstance(other, State):
38
+ return str(self) == str(other)
39
+ if isinstance(other, str):
40
+ return str(self) == other
41
+ return NotImplemented
42
+
43
+ def __hash__(self) -> int:
44
+ return hash(str(self))
45
+
46
+
47
+ class _StatesGroupMeta(type):
48
+ __parent__: type[StatesGroup] | None
49
+ __childs__: tuple[type[StatesGroup], ...]
50
+ __states__: tuple[State, ...]
51
+ __all_states__: tuple[State, ...]
52
+ __all_childs__: tuple[type[StatesGroup], ...]
53
+
54
+ def __new__(
55
+ mcs,
56
+ name: str,
57
+ bases: tuple[type, ...],
58
+ namespace: dict[str, Any],
59
+ ) -> _StatesGroupMeta:
60
+ cls = super().__new__(mcs, name, bases, namespace)
61
+
62
+ states: list[State] = []
63
+ childs: list[type[StatesGroup]] = []
64
+
65
+ for key, value in namespace.items():
66
+ if isinstance(value, State):
67
+ value.bind(cls, key)
68
+ states.append(value)
69
+ elif (
70
+ inspect.isclass(value)
71
+ and issubclass(value, StatesGroup)
72
+ and value is not StatesGroup
73
+ ):
74
+ child = cls._prepare_child(value)
75
+ childs.append(child)
76
+
77
+ cls.__parent__ = None # type: ignore[attr-defined]
78
+ cls.__childs__ = tuple(childs) # type: ignore[attr-defined]
79
+ cls.__states__ = tuple(states) # type: ignore[attr-defined]
80
+ cls.__all_childs__ = cls._get_all_childs() # type: ignore[attr-defined]
81
+ cls.__all_states__ = cls._get_all_states() # type: ignore[attr-defined]
82
+
83
+ return cls
84
+
85
+ @property
86
+ def __full_group_name__(cls) -> str: # type: ignore[override]
87
+ if cls.__parent__ is not None:
88
+ return f"{cls.__parent__.__full_group_name__}.{cls.__name__}"
89
+ return cls.__name__
90
+
91
+ def _prepare_child(cls, child: type[StatesGroup]) -> type[StatesGroup]:
92
+ child.__parent__ = cls # type: ignore[assignment]
93
+ return child
94
+
95
+ def _get_all_childs(cls) -> tuple[type[StatesGroup], ...]:
96
+ result: list[type[StatesGroup]] = list(cls.__childs__)
97
+ for child in cls.__childs__:
98
+ result.extend(child.__childs__)
99
+ return tuple(result)
100
+
101
+ def _get_all_states(cls) -> tuple[State, ...]:
102
+ result: list[State] = list(cls.__states__)
103
+ for child in cls.__childs__:
104
+ result.extend(child.__all_states__)
105
+ return tuple(result)
106
+
107
+ def __contains__(cls, item: Any) -> bool:
108
+ if isinstance(item, str):
109
+ return item in tuple(str(s) for s in cls.__all_states__)
110
+ if isinstance(item, State):
111
+ return item in cls.__all_states__
112
+ if isinstance(item, _StatesGroupMeta):
113
+ return item in cls.__all_childs__
114
+ return False
115
+
116
+ def __iter__(cls) -> Any:
117
+ return iter(cls.__all_states__)
118
+
119
+
120
+ class StatesGroup(metaclass=_StatesGroupMeta):
121
+ __parent__: type[StatesGroup] | None = None
122
+ __childs__: tuple[type[StatesGroup], ...] = ()
123
+ __states__: tuple[State, ...] = ()
124
+ __all_states__: tuple[State, ...] = ()
125
+ __all_childs__: tuple[type[StatesGroup], ...] = ()
126
+
127
+ @classmethod
128
+ def get_root(cls) -> type[StatesGroup]:
129
+ if cls.__parent__ is None:
130
+ return cls
131
+ return cls.__parent__.get_root()
132
+
133
+
134
+ any_state = State(state="*")
135
+ default_state = State()
@@ -0,0 +1,4 @@
1
+ from trueconf.fsm.storage.base import BaseStorage
2
+ from trueconf.fsm.storage.memory import MemoryStorage
3
+
4
+ __all__ = ("BaseStorage", "MemoryStorage")
@@ -0,0 +1,36 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from trueconf.fsm.key_builder import StorageKey
5
+
6
+
7
+ class BaseStorage(ABC):
8
+ @abstractmethod
9
+ async def get_state(self, key: StorageKey) -> str | None: ...
10
+
11
+ @abstractmethod
12
+ async def set_state(self, key: StorageKey, state: str | None) -> None: ...
13
+
14
+ @abstractmethod
15
+ async def get_data(self, key: StorageKey) -> dict[str, Any]: ...
16
+
17
+ @abstractmethod
18
+ async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None: ...
19
+
20
+ @abstractmethod
21
+ async def update_data(self, key: StorageKey, updates: dict[str, Any]) -> dict[str, Any]: ...
22
+
23
+ @abstractmethod
24
+ async def clear(self, key: StorageKey) -> None: ...
25
+
26
+ async def get_value(
27
+ self,
28
+ key: StorageKey,
29
+ dict_key: str,
30
+ default: Any | None = None,
31
+ ) -> Any | None:
32
+ data = await self.get_data(key)
33
+ return data.get(dict_key, default)
34
+
35
+ async def close(self) -> None:
36
+ pass
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import copy
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from trueconf.fsm.key_builder import StorageKey
8
+ from trueconf.fsm.storage.base import BaseStorage
9
+
10
+
11
+ @dataclass
12
+ class _Record:
13
+ state: str | None = None
14
+ data: dict[str, Any] = field(default_factory=dict)
15
+
16
+
17
+ class MemoryStorage(BaseStorage):
18
+ def __init__(self) -> None:
19
+ self._records: dict[StorageKey, _Record] = {}
20
+
21
+ def _get_or_create(self, key: StorageKey) -> _Record:
22
+ if key not in self._records:
23
+ self._records[key] = _Record()
24
+ return self._records[key]
25
+
26
+ async def get_state(self, key: StorageKey) -> str | None:
27
+ record = self._records.get(key)
28
+ return record.state if record else None
29
+
30
+ async def set_state(self, key: StorageKey, state: str | None) -> None:
31
+ self._get_or_create(key).state = state
32
+
33
+ async def get_data(self, key: StorageKey) -> dict[str, Any]:
34
+ record = self._records.get(key)
35
+ return record.data.copy() if record else {}
36
+
37
+ async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None:
38
+ self._get_or_create(key).data = data.copy()
39
+
40
+ async def update_data(self, key: StorageKey, updates: dict[str, Any]) -> dict[str, Any]:
41
+ record = self._get_or_create(key)
42
+ record.data.update(updates)
43
+ return record.data.copy()
44
+
45
+ async def clear(self, key: StorageKey) -> None:
46
+ self._records.pop(key, None)
47
+
48
+ async def get_value(
49
+ self,
50
+ key: StorageKey,
51
+ dict_key: str,
52
+ default: Any | None = None,
53
+ ) -> Any | None:
54
+ record = self._records.get(key)
55
+ if record is None:
56
+ return default
57
+ return copy(record.data.get(dict_key, default))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum, auto
4
+
5
+
6
+ class FSMStrategy(Enum):
7
+ """FSM strategy for storage key generation.
8
+
9
+ Determines how chat_id and user_id are combined to form the storage key.
10
+ """
11
+
12
+ USER_IN_CHAT = auto()
13
+ """State is stored per user per chat. Default behavior."""
14
+
15
+ CHAT = auto()
16
+ """State is stored per chat (all users in a chat share the same state)."""
17
+
18
+ GLOBAL_USER = auto()
19
+ """State is stored per user globally (across all chats)."""
20
+
21
+
22
+ def apply_strategy(
23
+ strategy: FSMStrategy,
24
+ chat_id: str,
25
+ user_id: str,
26
+ ) -> tuple[str, str]:
27
+ """Apply FSM strategy to chat_id and user_id.
28
+
29
+ Returns (effective_chat_id, effective_user_id) for StorageKey construction.
30
+ """
31
+ if strategy == FSMStrategy.CHAT:
32
+ return chat_id, chat_id
33
+ if strategy == FSMStrategy.GLOBAL_USER:
34
+ return user_id, user_id
35
+ # USER_IN_CHAT (default)
36
+ return chat_id, user_id
trueconf/middleware.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
4
+
5
+ from trueconf.filters.base import Event
6
+
7
+ if TYPE_CHECKING:
8
+ pass
9
+
10
+ logger = logging.getLogger("chat_bot")
11
+
12
+ MiddlewareHandler = Callable[[Event, Dict[str, Any]], Awaitable[None]]
13
+
14
+
15
+ class BaseMiddleware:
16
+ """
17
+ Base middleware class for event processing pipeline.
18
+
19
+ Middleware wraps event handlers and can intercept, modify, or block events
20
+ before they reach the actual handler. To block an event, simply do not call
21
+ ``await handler(event, data)``.
22
+
23
+ Example:
24
+ >>> class LoggingMiddleware(BaseMiddleware):
25
+ ... async def __call__(self, handler, event, data):
26
+ ... logger.info(f"Event: {event}")
27
+ ... await handler(event, data)
28
+ ... logger.info("Done")
29
+
30
+ >>> router.outer_middleware(LoggingMiddleware())
31
+ """
32
+
33
+ async def __call__(
34
+ self,
35
+ handler: MiddlewareHandler,
36
+ event: Event,
37
+ data: Dict[str, Any],
38
+ ) -> None:
39
+ await handler(event, data)
40
+
41
+
42
+ class SkipSelfMessages(BaseMiddleware):
43
+ """
44
+ Middleware that drops messages sent by the bot itself.
45
+
46
+ Prevents the bot from reacting to its own messages when multiple bot sessions
47
+ are running. If the event is a ``Message`` and its ``author.id`` matches
48
+ ``bot.me_id``, the event is silently dropped.
49
+
50
+ Registration:
51
+ Automatically registered when ``Bot(skip_self_messages=True)`` (default).
52
+ Alternatively:
53
+ >>> from trueconf.middleware import SkipSelfMessages
54
+ >>> dp.outer_middleware(SkipSelfMessages())
55
+ """
56
+
57
+ async def __call__(
58
+ self,
59
+ handler: MiddlewareHandler,
60
+ event: Event,
61
+ data: Dict[str, Any],
62
+ ) -> None:
63
+ bot = data.get("bot")
64
+ if bot is None:
65
+ await handler(event, data)
66
+ return
67
+
68
+ from trueconf.types.message import Message
69
+
70
+ if isinstance(event, Message) and event.author.id == bot.me_id:
71
+ logger.debug(
72
+ "[SkipSelfMessages] dropped message %s from bot self (author=%s)",
73
+ event.message_id,
74
+ event.author.id,
75
+ )
76
+ return
77
+
78
+ await handler(event, data)