python-vk-bot 0.1.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,115 @@
1
+ """Framework-neutral Callback authentication and an optional Starlette HTTP adapter."""
2
+
3
+ import asyncio
4
+ import hmac
5
+ import json
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import JsonValue, SecretStr
11
+
12
+ from .application import Application
13
+ from .errors import CallbackAuthenticationError, InvalidUpdateError, LifecycleError
14
+ from .models import Update
15
+
16
+ if TYPE_CHECKING:
17
+ from starlette.applications import Starlette
18
+ from starlette.requests import Request
19
+ from starlette.responses import Response
20
+
21
+
22
+ class Callback:
23
+ def __init__(
24
+ self,
25
+ application: Application,
26
+ *,
27
+ secret: str | SecretStr,
28
+ confirmation_code: str | SecretStr,
29
+ ) -> None:
30
+ self.application = application
31
+ self._secret = secret if isinstance(secret, SecretStr) else SecretStr(secret)
32
+ self._confirmation_code = (
33
+ confirmation_code
34
+ if isinstance(confirmation_code, SecretStr)
35
+ else SecretStr(confirmation_code)
36
+ )
37
+ if not self._secret.get_secret_value() or not self._confirmation_code.get_secret_value():
38
+ raise ValueError("Callback secret and confirmation code must be nonempty")
39
+
40
+ async def handle(self, payload: dict[str, JsonValue]) -> str:
41
+ secret = payload.get("secret")
42
+ group_id = payload.get("group_id")
43
+ if (
44
+ type(group_id) is not int
45
+ or group_id != self.application.group_id
46
+ or not isinstance(secret, str)
47
+ or not hmac.compare_digest(secret.encode(), self._secret.get_secret_value().encode())
48
+ ):
49
+ raise CallbackAuthenticationError("Callback authentication failed")
50
+ if payload.get("type") == "confirmation":
51
+ return self._confirmation_code.get_secret_value()
52
+ update = Update.from_dict(payload)
53
+ await self.application.process_update(update)
54
+ return "ok"
55
+
56
+ def create_app(
57
+ self,
58
+ *,
59
+ path: str = "/callback",
60
+ max_body_bytes: int = 1_048_576,
61
+ max_pending: int = 32,
62
+ request_timeout: float = 10.0,
63
+ ) -> Starlette:
64
+ """Create an ASGI app. Limits are local policy, not VK API limits.
65
+
66
+ The host must run ASGI lifespan; it owns the listener and TLS configuration.
67
+ """
68
+ from starlette.applications import Starlette
69
+ from starlette.requests import ClientDisconnect
70
+ from starlette.responses import PlainTextResponse
71
+ from starlette.routing import Route
72
+
73
+ if max_body_bytes <= 0 or max_pending <= 0 or request_timeout <= 0:
74
+ raise ValueError("Callback resource limits must be positive")
75
+ pending = 0
76
+
77
+ @asynccontextmanager
78
+ async def lifespan(app: Starlette) -> AsyncGenerator[None]:
79
+ async with self.application:
80
+ yield
81
+
82
+ async def endpoint(request: Request) -> Response:
83
+ nonlocal pending
84
+ if pending >= max_pending:
85
+ return PlainTextResponse("busy", status_code=503)
86
+ pending += 1
87
+ try:
88
+ async with asyncio.timeout(request_timeout):
89
+ body = bytearray()
90
+ async for chunk in request.stream():
91
+ if len(body) + len(chunk) > max_body_bytes:
92
+ return PlainTextResponse("too large", status_code=413)
93
+ body.extend(chunk)
94
+ try:
95
+ payload = json.loads(body)
96
+ except ValueError, UnicodeDecodeError:
97
+ return PlainTextResponse("invalid JSON", status_code=400)
98
+ if not isinstance(payload, dict):
99
+ return PlainTextResponse("invalid event", status_code=400)
100
+ return PlainTextResponse(await self.handle(payload))
101
+ except CallbackAuthenticationError:
102
+ return PlainTextResponse("forbidden", status_code=403)
103
+ except InvalidUpdateError:
104
+ return PlainTextResponse("invalid event", status_code=400)
105
+ except TimeoutError, LifecycleError:
106
+ return PlainTextResponse("unavailable", status_code=503)
107
+ except ClientDisconnect:
108
+ return PlainTextResponse("disconnected", status_code=400)
109
+ except Exception:
110
+ # Do not expose user code exceptions or echo private request data.
111
+ return PlainTextResponse("processing failed", status_code=500)
112
+ finally:
113
+ pending -= 1
114
+
115
+ return Starlette(routes=[Route(path, endpoint, methods=["POST"])], lifespan=lifespan)
@@ -0,0 +1,19 @@
1
+ """State scopes for one dispatched update."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .bot import Bot
7
+
8
+ if TYPE_CHECKING:
9
+ from .application import Application
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class Context:
14
+ application: Application
15
+ bot: Bot
16
+ user_data: dict[str, object]
17
+ peer_data: dict[str, object]
18
+ bot_data: dict[str, object]
19
+ error: Exception | None = field(default=None, repr=False)
@@ -0,0 +1,42 @@
1
+ """Safe exceptions: messages never include credentials or server payloads."""
2
+
3
+
4
+ class VKError(Exception):
5
+ """Base class for library errors."""
6
+
7
+
8
+ class NetworkError(VKError):
9
+ """A request could not complete; a mutation may already have reached VK."""
10
+
11
+
12
+ class HTTPError(VKError):
13
+ def __init__(self, status_code: int) -> None:
14
+ self.status_code = status_code
15
+ super().__init__(f"VK HTTP request failed with status {status_code}")
16
+
17
+
18
+ class APIError(VKError):
19
+ def __init__(self, code: int, method: str) -> None:
20
+ self.code = code
21
+ self.method = method
22
+ super().__init__(f"VK API error {code} in {method}")
23
+
24
+
25
+ class RateLimitError(APIError):
26
+ """VK error 6; no retry or numeric rate assumption is made."""
27
+
28
+
29
+ class InvalidResponseError(VKError):
30
+ """Malformed JSON or an unexpected response shape."""
31
+
32
+
33
+ class InvalidUpdateError(VKError):
34
+ """An event does not match the supported data boundary."""
35
+
36
+
37
+ class CallbackAuthenticationError(VKError):
38
+ """The Callback group or secret did not match."""
39
+
40
+
41
+ class LifecycleError(VKError):
42
+ """An operation is invalid in the current lifecycle state."""
@@ -0,0 +1,40 @@
1
+ """Small synchronous predicates; filter execution must not perform blocking I/O."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+
6
+ from .models import Update
7
+
8
+
9
+ class Filter:
10
+ def __init__(self, predicate: Callable[[Update], bool]) -> None:
11
+ self.predicate = predicate
12
+
13
+ def __call__(self, update: Update) -> bool:
14
+ return self.predicate(update)
15
+
16
+ def __and__(self, other: Filter) -> Filter:
17
+ return Filter(lambda update: self(update) and other(update))
18
+
19
+ def __or__(self, other: Filter) -> Filter:
20
+ return Filter(lambda update: self(update) or other(update))
21
+
22
+ def __invert__(self) -> Filter:
23
+ return Filter(lambda update: not self(update))
24
+
25
+
26
+ class Regex(Filter):
27
+ """Search the message text using a compiled regular expression."""
28
+
29
+ def __init__(self, pattern: str | re.Pattern[str]) -> None:
30
+ compiled = re.compile(pattern)
31
+ super().__init__(
32
+ lambda update: (
33
+ update.message is not None and compiled.search(update.message.text) is not None
34
+ )
35
+ )
36
+
37
+
38
+ ALL = Filter(lambda update: True)
39
+ TEXT = Filter(lambda update: update.message is not None and bool(update.message.text))
40
+ LOCATION = Filter(lambda update: update.message is not None and update.message.geo is not None)
@@ -0,0 +1,60 @@
1
+ """Typed handler registration without a conversation-state framework."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ from . import filters
6
+ from .context import Context
7
+ from .models import Update
8
+
9
+ type HandlerCallback = Callable[[Update, Context], Awaitable[None]]
10
+
11
+
12
+ class Handler:
13
+ def __init__(
14
+ self, callback: HandlerCallback, *, predicate: filters.Filter = filters.ALL
15
+ ) -> None:
16
+ self.callback = callback
17
+ self.predicate = predicate
18
+
19
+ def check_update(self, update: Update) -> bool:
20
+ return self.predicate(update)
21
+
22
+ async def handle_update(self, update: Update, context: Context) -> None:
23
+ await self.callback(update, context)
24
+
25
+
26
+ class MessageHandler(Handler):
27
+ def __init__(
28
+ self, callback: HandlerCallback, *, predicate: filters.Filter = filters.ALL
29
+ ) -> None:
30
+ super().__init__(
31
+ callback,
32
+ predicate=filters.Filter(lambda update: update.message is not None) & predicate,
33
+ )
34
+
35
+
36
+ class MessageEventHandler(Handler):
37
+ def __init__(
38
+ self, callback: HandlerCallback, *, predicate: filters.Filter = filters.ALL
39
+ ) -> None:
40
+ super().__init__(
41
+ callback,
42
+ predicate=filters.Filter(lambda update: update.message_event is not None) & predicate,
43
+ )
44
+
45
+
46
+ class CommandHandler(MessageHandler):
47
+ """Match an explicit /command token; no Telegram mention syntax is inferred."""
48
+
49
+ def __init__(self, command: str, callback: HandlerCallback) -> None:
50
+ if not command or command.startswith("/") or any(char.isspace() for char in command):
51
+ raise ValueError("command must be a nonempty token without a leading slash")
52
+ super().__init__(
53
+ callback,
54
+ predicate=filters.Filter(
55
+ lambda update: (
56
+ update.message is not None
57
+ and update.message.text.split(maxsplit=1)[:1] == [f"/{command}"]
58
+ )
59
+ ),
60
+ )
@@ -0,0 +1,91 @@
1
+ """VK Bots Long Poll recovery without recursion or detached tasks."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncGenerator
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from .bot import Bot
10
+ from .errors import HTTPError, InvalidResponseError, NetworkError, RateLimitError
11
+ from .models.api import LongPollResponse, LongPollServer
12
+ from .models.events import Update
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LongPoll:
18
+ def __init__(self, bot: Bot, group_id: int, *, wait: int = 25) -> None:
19
+ if group_id <= 0:
20
+ raise ValueError("group_id must be positive")
21
+ if not 1 <= wait <= 90:
22
+ raise ValueError("wait must be between 1 and 90 seconds")
23
+ self.bot = bot
24
+ self.group_id = group_id
25
+ self.wait = wait
26
+ self._server: LongPollServer | None = None
27
+
28
+ async def _connect(self, *, preserve_ts: bool = False) -> None:
29
+ old_ts = self._server.ts if self._server is not None else None
30
+ server = await self.bot.get_long_poll_server(self.group_id)
31
+ if preserve_ts and old_ts is not None:
32
+ server.ts = old_ts
33
+ self._server = server
34
+
35
+ async def get_updates(self) -> list[Update]:
36
+ """Fetch a batch; the caller owns dispatch and its failure policy."""
37
+ if self._server is None:
38
+ await self._connect()
39
+ server = self._server
40
+ if server is None:
41
+ raise InvalidResponseError("Long Poll connection is unavailable")
42
+ payload = await self.bot.transport.poll(
43
+ server.server, key=server.key, ts=server.ts, wait=self.wait
44
+ )
45
+ try:
46
+ response = LongPollResponse.model_validate(payload)
47
+ except ValidationError:
48
+ raise InvalidResponseError("Invalid Long Poll response") from None
49
+ if response.failed == 1:
50
+ if response.ts is None:
51
+ raise InvalidResponseError("Long Poll recovery has no cursor")
52
+ server.ts = response.ts
53
+ logger.warning("Long Poll event history was partially lost")
54
+ return []
55
+ if response.failed in (2, 3):
56
+ await self._connect(preserve_ts=response.failed == 2)
57
+ return []
58
+ if response.failed is not None:
59
+ raise InvalidResponseError("Unknown Long Poll failure code")
60
+ if response.ts is None or response.updates is None:
61
+ raise InvalidResponseError("Long Poll response has no cursor or updates")
62
+ # Validate the whole batch before advancing the cursor; malformed data is fatal.
63
+ updates = [Update.from_dict(item) for item in response.updates]
64
+ if any(update.group_id != self.group_id for update in updates):
65
+ raise InvalidResponseError("Long Poll event belongs to another community")
66
+ server.ts = response.ts
67
+ return updates
68
+
69
+ async def listen(self) -> AsyncGenerator[Update]:
70
+ """Retry only polling/network acquisition; authentication errors propagate."""
71
+ delay = 1.0
72
+ while True:
73
+ try:
74
+ updates = await self.get_updates()
75
+ except HTTPError as error:
76
+ if error.status_code != 429 and error.status_code < 500:
77
+ raise
78
+ updates = None
79
+ except NetworkError, RateLimitError:
80
+ updates = None
81
+ if updates is None:
82
+ logger.warning("Long Poll temporarily unavailable; reconnecting")
83
+ await asyncio.sleep(delay)
84
+ delay = min(delay * 2, 30.0)
85
+ continue
86
+ delay = 1.0
87
+ for update in updates:
88
+ yield update
89
+ if not updates:
90
+ # Local anti-spin policy, not a VK rate limit.
91
+ await asyncio.sleep(0.1)
@@ -0,0 +1,4 @@
1
+ from .events import Update
2
+ from .messages import Coordinates, Geo, Keyboard, Message, MessageEvent, User
3
+
4
+ __all__ = ["Coordinates", "Geo", "Keyboard", "Message", "MessageEvent", "Update", "User"]
@@ -0,0 +1,37 @@
1
+ """API envelope and Long Poll response boundaries."""
2
+
3
+ from pydantic import Field, JsonValue, SecretStr, field_validator
4
+
5
+ from .base import VKModel
6
+
7
+
8
+ class APIErrorData(VKModel):
9
+ error_code: int
10
+ error_msg: str = Field(repr=False)
11
+
12
+
13
+ class APIEnvelope(VKModel):
14
+ response: JsonValue = None
15
+ error: APIErrorData | None = Field(default=None, repr=False)
16
+
17
+
18
+ class LongPollServer(VKModel):
19
+ key: SecretStr
20
+ server: str
21
+ ts: str
22
+
23
+ @field_validator("ts", mode="before")
24
+ @classmethod
25
+ def normalize_ts(cls, value: object) -> object:
26
+ return str(value) if type(value) is int else value
27
+
28
+
29
+ class LongPollResponse(VKModel):
30
+ failed: int | None = None
31
+ ts: str | None = None
32
+ updates: list[dict[str, JsonValue]] | None = None
33
+
34
+ @field_validator("ts", mode="before")
35
+ @classmethod
36
+ def normalize_ts(cls, value: object) -> object:
37
+ return str(value) if type(value) is int else value
@@ -0,0 +1,9 @@
1
+ """Shared inbound-model policy."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class VKModel(BaseModel):
7
+ """Retain unmodeled VK fields without logging their contents."""
8
+
9
+ model_config = ConfigDict(extra="allow", hide_input_in_errors=True)
@@ -0,0 +1,67 @@
1
+ """Validate known event objects once and retain unknown event types."""
2
+
3
+ from typing import Self
4
+
5
+ from pydantic import JsonValue, PrivateAttr, ValidationError, field_validator, model_validator
6
+
7
+ from python_vk_bot.errors import InvalidUpdateError
8
+
9
+ from .base import VKModel
10
+ from .messages import Message, MessageEvent, MessageObject
11
+
12
+
13
+ class Update(VKModel):
14
+ type: str
15
+ group_id: int
16
+ event_id: str
17
+ v: str
18
+ object: JsonValue
19
+ _message: Message | None = PrivateAttr(default=None)
20
+ _message_event: MessageEvent | None = PrivateAttr(default=None)
21
+
22
+ @field_validator("event_id", mode="before")
23
+ @classmethod
24
+ def normalize_event_id(cls, value: object) -> object:
25
+ # Callback documentation also contains a numeric event_id example.
26
+ return str(value) if type(value) is int else value
27
+
28
+ @model_validator(mode="after")
29
+ def parse_object(self) -> Self:
30
+ if self.type == "message_new":
31
+ self._message = MessageObject.model_validate(self.object).message
32
+ elif self.type == "message_event":
33
+ self._message_event = MessageEvent.model_validate(self.object)
34
+ return self
35
+
36
+ @classmethod
37
+ def from_dict(cls, payload: dict[str, JsonValue]) -> Self:
38
+ # Authentication belongs to the Callback boundary, never to an Update.
39
+ clean = {key: value for key, value in payload.items() if key != "secret"}
40
+ try:
41
+ return cls.model_validate(clean)
42
+ except ValidationError:
43
+ raise InvalidUpdateError("Invalid VK event structure") from None
44
+
45
+ @property
46
+ def message(self) -> Message | None:
47
+ return self._message
48
+
49
+ @property
50
+ def message_event(self) -> MessageEvent | None:
51
+ return self._message_event
52
+
53
+ @property
54
+ def user_id(self) -> int | None:
55
+ if self.message is not None:
56
+ return self.message.from_id
57
+ if self.message_event is not None:
58
+ return self.message_event.user_id
59
+ return None
60
+
61
+ @property
62
+ def peer_id(self) -> int | None:
63
+ if self.message is not None:
64
+ return self.message.peer_id
65
+ if self.message_event is not None:
66
+ return self.message_event.peer_id
67
+ return None
@@ -0,0 +1,57 @@
1
+ """The message fields used by this framework, not the entire VK schema."""
2
+
3
+ from pydantic import Field, JsonValue
4
+
5
+ from .base import VKModel
6
+
7
+
8
+ class Coordinates(VKModel):
9
+ latitude: float
10
+ longitude: float
11
+
12
+
13
+ class Geo(VKModel):
14
+ coordinates: Coordinates
15
+
16
+
17
+ class Message(VKModel):
18
+ id: int
19
+ from_id: int
20
+ peer_id: int
21
+ conversation_message_id: int
22
+ text: str
23
+ geo: Geo | None = None
24
+ payload: str | None = None
25
+ attachments: list[dict[str, JsonValue]] = Field(default_factory=list)
26
+
27
+
28
+ class MessageObject(VKModel):
29
+ message: Message
30
+
31
+
32
+ class MessageEvent(VKModel):
33
+ user_id: int
34
+ peer_id: int
35
+ event_id: str
36
+ conversation_message_id: int | None = None
37
+ # Official schema says string; deployed button events may contain JSON objects.
38
+ # Preserve both without silently decoding or discarding the original value.
39
+ payload: JsonValue = None
40
+
41
+
42
+ class User(VKModel):
43
+ id: int
44
+ first_name: str | None = None
45
+ last_name: str | None = None
46
+ screen_name: str | None = None
47
+
48
+
49
+ class Keyboard(VKModel):
50
+ buttons: list[list[dict[str, JsonValue]]]
51
+ one_time: bool = False
52
+ inline: bool = False
53
+
54
+
55
+ class MessagePage(VKModel):
56
+ count: int
57
+ items: list[Message]
python_vk_bot/py.typed ADDED
File without changes
@@ -0,0 +1,26 @@
1
+ """Reusable settings loaded only when the caller creates an instance."""
2
+
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+ from ._config import DEFAULT_API_VERSION, DEFAULT_TIMEOUT, APIVersion, RequestTimeout, Token
7
+
8
+
9
+ class BotSettings(BaseSettings):
10
+ """Read VK_* variables, with optional explicit dotenv loading and subclass fields.
11
+
12
+ No instance is created at import time. Pass ``_env_file=Path(".env")`` to opt into
13
+ dotenv loading. Pass values explicitly to override their environment counterparts.
14
+ """
15
+
16
+ model_config = SettingsConfigDict(
17
+ env_prefix="VK_",
18
+ extra="ignore",
19
+ hide_input_in_errors=True,
20
+ )
21
+
22
+ token: Token = Field(repr=False)
23
+ group_id: int = Field(gt=0)
24
+ api_version: APIVersion = DEFAULT_API_VERSION
25
+ timeout: RequestTimeout = DEFAULT_TIMEOUT
26
+ polling_wait: int = Field(default=25, ge=1, le=90)