fastvk 0.0.1__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.
- fastvk/__init__.py +9 -0
- fastvk/api/__init__.py +5 -0
- fastvk/api/client.py +79 -0
- fastvk/app.py +164 -0
- fastvk/exceptions.py +31 -0
- fastvk/filters/__init__.py +6 -0
- fastvk/filters/base.py +24 -0
- fastvk/filters/builtin.py +182 -0
- fastvk/fsm/__init__.py +7 -0
- fastvk/fsm/context.py +79 -0
- fastvk/fsm/state.py +74 -0
- fastvk/fsm/storage.py +67 -0
- fastvk/middleware/__init__.py +5 -0
- fastvk/middleware/base.py +74 -0
- fastvk/polling/__init__.py +5 -0
- fastvk/polling/longpoll.py +88 -0
- fastvk/router.py +194 -0
- fastvk/types/__init__.py +7 -0
- fastvk/types/message.py +136 -0
- fastvk/types/update.py +25 -0
- fastvk/types/user.py +46 -0
- fastvk-0.0.1.dist-info/METADATA +403 -0
- fastvk-0.0.1.dist-info/RECORD +26 -0
- fastvk-0.0.1.dist-info/WHEEL +5 -0
- fastvk-0.0.1.dist-info/licenses/LICENSE +21 -0
- fastvk-0.0.1.dist-info/top_level.txt +1 -0
fastvk/__init__.py
ADDED
fastvk/api/__init__.py
ADDED
fastvk/api/client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
|
|
7
|
+
from ..exceptions import VKAPIError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
VK_API_BASE = "https://api.vk.com/method/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _APIMethod:
|
|
16
|
+
"""Lazy proxy for a VK API namespace (e.g. ``messages``, ``groups``)."""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("_client", "_prefix")
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: APIClient, prefix: str) -> None:
|
|
21
|
+
self._client = client
|
|
22
|
+
self._prefix = prefix
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, name: str) -> _APICallable:
|
|
25
|
+
return _APICallable(self._client, f"{self._prefix}.{name}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _APICallable:
|
|
29
|
+
"""Callable proxy for a single VK API method (e.g. ``messages.send``)."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_client", "_method")
|
|
32
|
+
|
|
33
|
+
def __init__(self, client: APIClient, method: str) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
self._method = method
|
|
36
|
+
|
|
37
|
+
async def __call__(self, **kwargs: Any) -> Any:
|
|
38
|
+
return await self._client._call(self._method, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class APIClient:
|
|
42
|
+
"""
|
|
43
|
+
Async VK API client with dynamic method dispatch.
|
|
44
|
+
|
|
45
|
+
Supports any VK API method via attribute access:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
api = APIClient(token="...")
|
|
49
|
+
await api.messages.send(peer_id=123, message="Hello", random_id=0)
|
|
50
|
+
await api.users.get(user_ids=1)
|
|
51
|
+
```
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, token: str, version: str = "5.199") -> None:
|
|
55
|
+
self.token = token
|
|
56
|
+
self.version = version
|
|
57
|
+
self._session: aiohttp.ClientSession | None = None
|
|
58
|
+
|
|
59
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
60
|
+
if self._session is None or self._session.closed:
|
|
61
|
+
self._session = aiohttp.ClientSession()
|
|
62
|
+
return self._session
|
|
63
|
+
|
|
64
|
+
async def _call(self, method: str, **kwargs: Any) -> Any:
|
|
65
|
+
session = await self._get_session()
|
|
66
|
+
params = {"access_token": self.token, "v": self.version, **kwargs}
|
|
67
|
+
async with session.post(f"{VK_API_BASE}{method}", data=params) as resp:
|
|
68
|
+
data: dict = await resp.json(content_type=None)
|
|
69
|
+
if "error" in data:
|
|
70
|
+
raise VKAPIError(data["error"])
|
|
71
|
+
return data.get("response")
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, name: str) -> _APIMethod:
|
|
74
|
+
return _APIMethod(self, name)
|
|
75
|
+
|
|
76
|
+
async def close(self) -> None:
|
|
77
|
+
"""Close the underlying HTTP session."""
|
|
78
|
+
if self._session and not self._session.closed:
|
|
79
|
+
await self._session.close()
|
fastvk/app.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Annotated, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from annotated_doc import Doc
|
|
8
|
+
|
|
9
|
+
from .api.client import APIClient
|
|
10
|
+
from .fsm.storage import BaseStorage, MemoryStorage
|
|
11
|
+
from .middleware.base import BaseMiddleware, MiddlewareManager
|
|
12
|
+
from .polling.longpoll import LongPoller
|
|
13
|
+
from .router import Router
|
|
14
|
+
from .types.update import Update
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("fastvk")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FastVK(Router):
|
|
23
|
+
"""
|
|
24
|
+
FastVK application — the root dispatcher and entry point.
|
|
25
|
+
|
|
26
|
+
Extends :class:`~fastvk.Router`, so you can register handlers
|
|
27
|
+
directly on the bot instance or via ``include_router()``.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from fastvk import FastVK, Router
|
|
31
|
+
from fastvk.filters import Command
|
|
32
|
+
from fastvk.types import Message
|
|
33
|
+
|
|
34
|
+
bot = FastVK(token="vk1.a...", group_id=123456789)
|
|
35
|
+
|
|
36
|
+
@bot.message(Command("start"))
|
|
37
|
+
async def start(message: Message) -> None:
|
|
38
|
+
await message.answer("Привет!")
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
bot.run_polling()
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
token: Annotated[
|
|
48
|
+
str,
|
|
49
|
+
Doc(
|
|
50
|
+
"""
|
|
51
|
+
VK community token with the required permissions.
|
|
52
|
+
|
|
53
|
+
Obtain in community settings → Manage → API usage → Access tokens.
|
|
54
|
+
The token must have at least the ``messages`` permission.
|
|
55
|
+
"""
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
group_id: Annotated[
|
|
59
|
+
int,
|
|
60
|
+
Doc(
|
|
61
|
+
"""
|
|
62
|
+
Numeric ID of the VK community.
|
|
63
|
+
|
|
64
|
+
Shown in the community URL: ``vk.com/public{group_id}``
|
|
65
|
+
or retrievable via ``groups.getById``.
|
|
66
|
+
"""
|
|
67
|
+
),
|
|
68
|
+
],
|
|
69
|
+
*,
|
|
70
|
+
api_version: Annotated[
|
|
71
|
+
str,
|
|
72
|
+
Doc(
|
|
73
|
+
"""
|
|
74
|
+
VK API version used for all requests.
|
|
75
|
+
|
|
76
|
+
FastVK is tested against 5.199. Avoid downgrading below 5.131.
|
|
77
|
+
"""
|
|
78
|
+
),
|
|
79
|
+
] = "5.199",
|
|
80
|
+
storage: Annotated[
|
|
81
|
+
BaseStorage | None,
|
|
82
|
+
Doc(
|
|
83
|
+
"""
|
|
84
|
+
FSM storage backend.
|
|
85
|
+
|
|
86
|
+
Defaults to :class:`~fastvk.fsm.MemoryStorage` (in-process, lost on restart).
|
|
87
|
+
Pass a persistent backend (e.g. Redis) for production bots.
|
|
88
|
+
"""
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
middleware: Annotated[
|
|
92
|
+
list[BaseMiddleware] | BaseMiddleware | None,
|
|
93
|
+
Doc(
|
|
94
|
+
"""
|
|
95
|
+
Middleware applied to every incoming update.
|
|
96
|
+
|
|
97
|
+
Can be a single instance or a list.
|
|
98
|
+
Middleware is executed in the order provided.
|
|
99
|
+
"""
|
|
100
|
+
),
|
|
101
|
+
] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.api = APIClient(token=token, version=api_version)
|
|
105
|
+
self.group_id = group_id
|
|
106
|
+
self.storage: BaseStorage = storage or MemoryStorage()
|
|
107
|
+
|
|
108
|
+
if middleware is None:
|
|
109
|
+
_mw: list[BaseMiddleware] = []
|
|
110
|
+
elif isinstance(middleware, list):
|
|
111
|
+
_mw = middleware
|
|
112
|
+
else:
|
|
113
|
+
_mw = [middleware]
|
|
114
|
+
self.middleware_manager = MiddlewareManager(_mw)
|
|
115
|
+
|
|
116
|
+
def middleware(self, mw: BaseMiddleware) -> BaseMiddleware:
|
|
117
|
+
"""
|
|
118
|
+
Register *mw* as global middleware.
|
|
119
|
+
|
|
120
|
+
Can be used as a decorator:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
@bot.middleware
|
|
124
|
+
class Log(BaseMiddleware):
|
|
125
|
+
async def __call__(self, handler, event, data):
|
|
126
|
+
print(event)
|
|
127
|
+
return await handler(event, data)
|
|
128
|
+
```
|
|
129
|
+
"""
|
|
130
|
+
self.middleware_manager.register(mw)
|
|
131
|
+
return mw
|
|
132
|
+
|
|
133
|
+
async def _process_update(self, update: Update) -> None:
|
|
134
|
+
await self.middleware_manager.trigger(
|
|
135
|
+
lambda evt, data: self.feed_update(update, self.api, self.storage),
|
|
136
|
+
update,
|
|
137
|
+
{},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _run_polling(self) -> None:
|
|
141
|
+
logger.info("FastVK started (group_id=%d)", self.group_id)
|
|
142
|
+
poller = LongPoller(api=self.api, group_id=self.group_id)
|
|
143
|
+
try:
|
|
144
|
+
async for update in poller.listen():
|
|
145
|
+
asyncio.create_task(self._process_update(update))
|
|
146
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
147
|
+
logger.info("Polling stopped")
|
|
148
|
+
finally:
|
|
149
|
+
await self.api.close()
|
|
150
|
+
await self.storage.close()
|
|
151
|
+
|
|
152
|
+
def run_polling(self) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Start Long Poll and block until interrupted.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
bot.run_polling()
|
|
159
|
+
```
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
asyncio.run(self._run_polling())
|
|
163
|
+
except KeyboardInterrupt:
|
|
164
|
+
logger.info("FastVK stopped by user")
|
fastvk/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FastVKError(Exception):
|
|
5
|
+
"""Base exception for all FastVK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VKAPIError(FastVKError):
|
|
9
|
+
"""Raised when VK API returns an error response."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, error_data: dict) -> None:
|
|
12
|
+
self.code: int = error_data.get("error_code", 0)
|
|
13
|
+
self.message: str = error_data.get("error_msg", "Unknown error")
|
|
14
|
+
self.request_params: list = error_data.get("request_params", [])
|
|
15
|
+
super().__init__(f"[{self.code}] {self.message}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HandlerNotFoundError(FastVKError):
|
|
19
|
+
"""Raised when no handler matched the incoming update."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FilterError(FastVKError):
|
|
23
|
+
"""Raised when a filter fails unexpectedly."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StorageError(FastVKError):
|
|
27
|
+
"""Raised on FSM storage failures."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PollingError(FastVKError):
|
|
31
|
+
"""Raised on Long Poll failures that cannot be recovered."""
|
fastvk/filters/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseFilter(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for all FastVK filters.
|
|
10
|
+
|
|
11
|
+
A filter is a callable that receives an event object and a handler
|
|
12
|
+
data dict, and returns a bool indicating whether the handler should run.
|
|
13
|
+
|
|
14
|
+
Sync and async filters are both supported:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
class MyFilter(BaseFilter):
|
|
18
|
+
async def __call__(self, message: Message, data: dict) -> bool:
|
|
19
|
+
return message.from_id == 123456
|
|
20
|
+
```
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def __call__(self, event: Any, data: dict) -> bool: ...
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ..fsm.context import FSMContext
|
|
7
|
+
from ..fsm.state import State
|
|
8
|
+
from ..types.message import Message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command:
|
|
12
|
+
"""
|
|
13
|
+
Filter that matches messages starting with a bot command.
|
|
14
|
+
|
|
15
|
+
Handles ``/cmd``, ``/cmd@botname``, and ``/cmd argument`` forms.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
@router.message(Command("start", "help"))
|
|
19
|
+
async def on_start(message: Message) -> None:
|
|
20
|
+
await message.answer("Привет!")
|
|
21
|
+
```
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *commands: str) -> None:
|
|
25
|
+
self.commands = {cmd.lstrip("/") for cmd in commands}
|
|
26
|
+
|
|
27
|
+
def __call__(self, message: Message, data: dict) -> bool:
|
|
28
|
+
if not message.text:
|
|
29
|
+
return False
|
|
30
|
+
text = message.text.strip()
|
|
31
|
+
for cmd in self.commands:
|
|
32
|
+
if (
|
|
33
|
+
text == f"/{cmd}"
|
|
34
|
+
or text.startswith(f"/{cmd} ")
|
|
35
|
+
or text.startswith(f"/{cmd}@")
|
|
36
|
+
):
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"Command({', '.join(self.commands)!r})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Text:
|
|
45
|
+
"""
|
|
46
|
+
Filter that matches message text by exact value or substring.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@router.message(Text("привет"))
|
|
50
|
+
async def on_hello(message: Message) -> None:
|
|
51
|
+
await message.answer("И тебе привет!")
|
|
52
|
+
|
|
53
|
+
@router.message(Text("help", contains=True, ignore_case=True))
|
|
54
|
+
async def on_help_mention(message: Message) -> None:
|
|
55
|
+
await message.answer("Нужна помощь?")
|
|
56
|
+
```
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*texts: str,
|
|
62
|
+
contains: bool = False,
|
|
63
|
+
ignore_case: bool = True,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.texts = texts
|
|
66
|
+
self.contains = contains
|
|
67
|
+
self.ignore_case = ignore_case
|
|
68
|
+
|
|
69
|
+
def __call__(self, message: Message, data: dict) -> bool:
|
|
70
|
+
if not message.text:
|
|
71
|
+
return False
|
|
72
|
+
msg = message.text.lower() if self.ignore_case else message.text
|
|
73
|
+
for t in self.texts:
|
|
74
|
+
cmp = t.lower() if self.ignore_case else t
|
|
75
|
+
if self.contains and cmp in msg:
|
|
76
|
+
return True
|
|
77
|
+
if not self.contains and msg == cmp:
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"Text({self.texts!r}, contains={self.contains})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StateFilter:
|
|
86
|
+
"""
|
|
87
|
+
Filter that matches the user's current FSM state.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
@router.message(StateFilter(Form.waiting_name))
|
|
91
|
+
async def got_name(message: Message, state: FSMContext) -> None:
|
|
92
|
+
await state.update_data(name=message.text)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Pass ``None`` to match users with no active state:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@router.message(StateFilter(None))
|
|
99
|
+
async def no_state(message: Message) -> None: ...
|
|
100
|
+
```
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, *states: State | str | None) -> None:
|
|
104
|
+
from ..fsm.state import State as _State
|
|
105
|
+
|
|
106
|
+
self._states: list[str | None] = []
|
|
107
|
+
for s in states:
|
|
108
|
+
if isinstance(s, _State):
|
|
109
|
+
self._states.append(s.state)
|
|
110
|
+
else:
|
|
111
|
+
self._states.append(s)
|
|
112
|
+
|
|
113
|
+
async def __call__(self, message: Message, data: dict) -> bool:
|
|
114
|
+
ctx: FSMContext | None = data.get("state")
|
|
115
|
+
current = await ctx.get_state() if ctx is not None else None
|
|
116
|
+
return current in self._states
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return f"StateFilter({self._states!r})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FromUser:
|
|
123
|
+
"""
|
|
124
|
+
Filter that only allows messages from specific user IDs.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
ADMIN_ID = 123456789
|
|
128
|
+
|
|
129
|
+
@router.message(FromUser(ADMIN_ID), Command("ban"))
|
|
130
|
+
async def admin_ban(message: Message) -> None: ...
|
|
131
|
+
```
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, *user_ids: int) -> None:
|
|
135
|
+
self.user_ids = frozenset(user_ids)
|
|
136
|
+
|
|
137
|
+
def __call__(self, message: Message, data: dict) -> bool:
|
|
138
|
+
return message.from_id in self.user_ids
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
return f"FromUser({set(self.user_ids)!r})"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IsChat:
|
|
145
|
+
"""
|
|
146
|
+
Filter that restricts handlers to a specific chat type.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
@router.message(IsChat("private"))
|
|
150
|
+
async def private_only(message: Message) -> None: ...
|
|
151
|
+
|
|
152
|
+
@router.message(IsChat("chat"))
|
|
153
|
+
async def chat_only(message: Message) -> None: ...
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Accepted values: ``"private"``, ``"chat"``.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
_PRIVATE = "private"
|
|
160
|
+
_CHAT = "chat"
|
|
161
|
+
|
|
162
|
+
def __init__(self, *types: str) -> None:
|
|
163
|
+
self.types = frozenset(types)
|
|
164
|
+
|
|
165
|
+
def __call__(self, message: Message, data: dict) -> bool:
|
|
166
|
+
if self._PRIVATE in self.types and message.is_private:
|
|
167
|
+
return True
|
|
168
|
+
if self._CHAT in self.types and message.is_chat:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
return f"IsChat({set(self.types)!r})"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize_filter(f: Any) -> Any:
|
|
177
|
+
"""Wrap a bare :class:`~fastvk.fsm.State` in a :class:`StateFilter`."""
|
|
178
|
+
from ..fsm.state import State as _State
|
|
179
|
+
|
|
180
|
+
if isinstance(f, _State):
|
|
181
|
+
return StateFilter(f)
|
|
182
|
+
return f
|
fastvk/fsm/__init__.py
ADDED
fastvk/fsm/context.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .state import State
|
|
7
|
+
from .storage import BaseStorage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FSMContext:
|
|
11
|
+
"""
|
|
12
|
+
Per-user FSM state accessor injected into every handler.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
@router.message(Command("start"))
|
|
16
|
+
async def start(message: Message, state: FSMContext) -> None:
|
|
17
|
+
await state.set_state(Form.waiting_name)
|
|
18
|
+
await message.answer("Как тебя зовут?")
|
|
19
|
+
|
|
20
|
+
@router.message(StateFilter(Form.waiting_name))
|
|
21
|
+
async def got_name(message: Message, state: FSMContext) -> None:
|
|
22
|
+
await state.update_data(name=message.text)
|
|
23
|
+
await state.set_state(Form.waiting_age)
|
|
24
|
+
await message.answer("Сколько тебе лет?")
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
storage: BaseStorage,
|
|
31
|
+
peer_id: int,
|
|
32
|
+
user_id: int,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._storage = storage
|
|
35
|
+
self._key = (peer_id, user_id)
|
|
36
|
+
|
|
37
|
+
async def get_state(self) -> str | None:
|
|
38
|
+
"""Return the current state string, or ``None`` if no state is set."""
|
|
39
|
+
return await self._storage.get_state(self._key)
|
|
40
|
+
|
|
41
|
+
async def set_state(self, state: State | str | None) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Set the current state.
|
|
44
|
+
|
|
45
|
+
Accepts a :class:`~fastvk.fsm.State` instance, a raw string,
|
|
46
|
+
or ``None`` to clear the state without wiping data.
|
|
47
|
+
"""
|
|
48
|
+
from .state import State as _State
|
|
49
|
+
|
|
50
|
+
if isinstance(state, _State):
|
|
51
|
+
state = state.state
|
|
52
|
+
await self._storage.set_state(self._key, state)
|
|
53
|
+
|
|
54
|
+
async def clear(self) -> None:
|
|
55
|
+
"""Reset both the state and the stored data for this user."""
|
|
56
|
+
await self._storage.set_state(self._key, None)
|
|
57
|
+
await self._storage.set_data(self._key, {})
|
|
58
|
+
|
|
59
|
+
async def get_data(self) -> dict:
|
|
60
|
+
"""Return the stored data dict for this user."""
|
|
61
|
+
return await self._storage.get_data(self._key)
|
|
62
|
+
|
|
63
|
+
async def set_data(self, data: dict) -> None:
|
|
64
|
+
"""Replace the stored data dict for this user."""
|
|
65
|
+
await self._storage.set_data(self._key, data)
|
|
66
|
+
|
|
67
|
+
async def update_data(self, **kwargs: object) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
Merge *kwargs* into the stored data and return the updated dict.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
await state.update_data(name="Alice", age=30)
|
|
73
|
+
data = await state.get_data() # {"name": "Alice", "age": 30}
|
|
74
|
+
```
|
|
75
|
+
"""
|
|
76
|
+
data = await self.get_data()
|
|
77
|
+
data.update(kwargs)
|
|
78
|
+
await self.set_data(data)
|
|
79
|
+
return data
|
fastvk/fsm/state.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class State:
|
|
5
|
+
"""
|
|
6
|
+
A single FSM state.
|
|
7
|
+
|
|
8
|
+
Declare as a class attribute of a :class:`StatesGroup` subclass —
|
|
9
|
+
the state name is set automatically from the class and attribute names.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
class Form(StatesGroup):
|
|
13
|
+
waiting_name = State() # state == "Form:waiting_name"
|
|
14
|
+
waiting_age = State() # state == "Form:waiting_age"
|
|
15
|
+
```
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, state: str | None = None) -> None:
|
|
19
|
+
self._state = state
|
|
20
|
+
|
|
21
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
22
|
+
if self._state is None:
|
|
23
|
+
self._state = f"{owner.__name__}:{name}"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def state(self) -> str | None:
|
|
27
|
+
"""Fully qualified state name, e.g. ``"Form:waiting_name"``."""
|
|
28
|
+
return self._state
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"<State {self._state!r}>"
|
|
32
|
+
|
|
33
|
+
def __eq__(self, other: object) -> bool:
|
|
34
|
+
if isinstance(other, State):
|
|
35
|
+
return self._state == other._state
|
|
36
|
+
if isinstance(other, str):
|
|
37
|
+
return self._state == other
|
|
38
|
+
return NotImplemented
|
|
39
|
+
|
|
40
|
+
def __hash__(self) -> int:
|
|
41
|
+
return hash(self._state)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _StatesGroupMeta(type):
|
|
45
|
+
"""Metaclass that auto-assigns state names to all :class:`State` attributes."""
|
|
46
|
+
|
|
47
|
+
def __new__(
|
|
48
|
+
mcs,
|
|
49
|
+
name: str,
|
|
50
|
+
bases: tuple[type, ...],
|
|
51
|
+
namespace: dict,
|
|
52
|
+
) -> _StatesGroupMeta:
|
|
53
|
+
states: dict[str, State] = {}
|
|
54
|
+
for attr_name, attr_val in namespace.items():
|
|
55
|
+
if isinstance(attr_val, State) and attr_val._state is None:
|
|
56
|
+
attr_val._state = f"{name}:{attr_name}"
|
|
57
|
+
states[attr_name] = attr_val
|
|
58
|
+
namespace["_states"] = states
|
|
59
|
+
return super().__new__(mcs, name, bases, namespace)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StatesGroup(metaclass=_StatesGroupMeta):
|
|
63
|
+
"""
|
|
64
|
+
Base class for grouping related FSM states.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
class RegistrationForm(StatesGroup):
|
|
68
|
+
waiting_name = State()
|
|
69
|
+
waiting_age = State()
|
|
70
|
+
waiting_email = State()
|
|
71
|
+
```
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_states: dict[str, State]
|