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.
- python_vk_bot/.agents/skills/telegram-to-vk/SKILL.md +38 -0
- python_vk_bot/.agents/skills/telegram-to-vk/references/migration-mapping.md +159 -0
- python_vk_bot/__init__.py +30 -0
- python_vk_bot/_config.py +22 -0
- python_vk_bot/application.py +221 -0
- python_vk_bot/bot.py +184 -0
- python_vk_bot/callback.py +115 -0
- python_vk_bot/context.py +19 -0
- python_vk_bot/errors.py +42 -0
- python_vk_bot/filters.py +40 -0
- python_vk_bot/handlers.py +60 -0
- python_vk_bot/long_poll.py +91 -0
- python_vk_bot/models/__init__.py +4 -0
- python_vk_bot/models/api.py +37 -0
- python_vk_bot/models/base.py +9 -0
- python_vk_bot/models/events.py +67 -0
- python_vk_bot/models/messages.py +57 -0
- python_vk_bot/py.typed +0 -0
- python_vk_bot/settings.py +26 -0
- python_vk_bot/transport.py +154 -0
- python_vk_bot-0.1.0.dist-info/METADATA +237 -0
- python_vk_bot-0.1.0.dist-info/RECORD +24 -0
- python_vk_bot-0.1.0.dist-info/WHEEL +4 -0
- python_vk_bot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
python_vk_bot/context.py
ADDED
|
@@ -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)
|
python_vk_bot/errors.py
ADDED
|
@@ -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."""
|
python_vk_bot/filters.py
ADDED
|
@@ -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,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,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)
|