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 ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .app import FastVK
4
+ from .router import Router
5
+ from . import filters
6
+ from . import fsm
7
+ from . import types
8
+
9
+ __all__ = ["FastVK", "Router", "filters", "fsm", "types"]
fastvk/api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import APIClient
4
+
5
+ __all__ = ["APIClient"]
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."""
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import BaseFilter
4
+ from .builtin import Command, FromUser, IsChat, StateFilter, Text
5
+
6
+ __all__ = ["BaseFilter", "Command", "FromUser", "IsChat", "StateFilter", "Text"]
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
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .context import FSMContext
4
+ from .state import State, StatesGroup
5
+ from .storage import BaseStorage, MemoryStorage
6
+
7
+ __all__ = ["FSMContext", "State", "StatesGroup", "BaseStorage", "MemoryStorage"]
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]