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,38 @@
1
+ ---
2
+ name: telegram-to-vk
3
+ description: Migrate python-telegram-bot applications to VK community bots with python-vk-bot, preserving business logic while adapting handlers, state, messaging and delivery to supported VK interfaces.
4
+ ---
5
+
6
+ # Migrate a Telegram bot to VK
7
+
8
+ Work in the user's bot project. Read its `AGENTS.md` and approval rules before changing files.
9
+ Do not modify python-vk-bot itself unless explicitly requested.
10
+
11
+ 1. Inspect the source project's installed/locked python-telegram-bot version, initialization,
12
+ configuration, handlers, filters, conversations, context/state, scheduled jobs, media,
13
+ error handling, integrations and business services. Do not assume the source uses current PTB.
14
+ 2. Inspect the installed or checked-out python-vk-bot version and its public interfaces.
15
+ Read [the migration mapping](references/migration-mapping.md), then verify each relevant
16
+ mapping against that version's sources. The reference is a baseline, not permission to
17
+ invent classes, methods, filters or features.
18
+ 3. Build a source-to-target inventory with supported equivalents, behavior differences,
19
+ unsupported functionality and manual configuration. Verify version-dependent VK claims
20
+ against current official documentation; cite sources and label uncertainties. Distinguish
21
+ the library's missing wrapper from a capability absent on the platform.
22
+ 4. Propose a migration that keeps platform-independent business logic and adapts the actual
23
+ Telegram integration points. Address peers/users/communities, message IDs, commands,
24
+ keyboards, button events, media, editing/deletion, permissions, limits, state and delivery.
25
+ Avoid speculative shared-platform abstractions; migration is more than import replacement.
26
+ 5. Obtain required approval for the migration, affected files and intentional behavior changes.
27
+ Follow local approval gates; test changes require approval of the corresponding main code
28
+ and separate test-edit authorization. Never silently remove unsupported functionality.
29
+ 6. Implement the approved scope with supported interfaces, explicit configuration and safe
30
+ token handling. Preserve async behavior, cancellation and owned-resource cleanup. Keep
31
+ Long Poll and Callback HTTP delivery distinct from interactive message-event handling.
32
+ 7. Verify initialization, commands/messages, button events, state, errors and graceful shutdown
33
+ as applicable. Run existing checks; use mocked VK responses for automated verification.
34
+ Do not require live credentials or send messages as part of ordinary verification.
35
+
36
+ Report original components, implemented VK replacements, unsupported functionality,
37
+ behavior differences, required manual configuration and verification results. Explain
38
+ what remains blocked or unverified. Identical behavior across platforms is not guaranteed.
@@ -0,0 +1,159 @@
1
+ # Telegram to VK migration mapping
2
+
3
+ Baseline inspected on **2026-10-31**: python-vk-bot **0.1.0** source tree and
4
+ [PTB 22.8](https://docs.python-telegram-bot.org/en/v22.8/telegram.ext.html).
5
+ Inspect the user's actual installed versions before applying this mapping. This file travels
6
+ with the Python package and does not require repository documentation to be installed.
7
+
8
+ ## Components
9
+
10
+ | PTB component | Current python-vk-bot interface | Adaptation |
11
+ | --- | --- | --- |
12
+ | Application / ApplicationBuilder | `Application(bot, group_id)` | Direct construction; no builder. `run_polling()` must be awaited. |
13
+ | Bot | `Bot(token, api_version=..., timeout=...)` | Community token; async context manager; methods use VK identifiers. |
14
+ | Configuration | `BotSettings()` | Reads VK_TOKEN and VK_GROUP_ID; optional explicit dotenv loading. Pass values to constructors. |
15
+ | Update | `Update.message`, `Update.message_event`, `Update.object` | VK events; unknown objects retained. No effective_message/chat aliases. |
16
+ | CallbackContext | `Context` | user_data, peer_data, bot_data; process-local state, no chat_data or args. |
17
+ | CommandHandler | `CommandHandler("start", callback)` | Exact /start token; no Telegram mention parsing. Parse arguments explicitly. |
18
+ | MessageHandler | `MessageHandler(callback, predicate=...)` | Callback comes first; only message-bearing updates. |
19
+ | filters | `filters.ALL`, `TEXT`, `LOCATION`, `Regex`, `Filter` | Small synchronous subset; supports &, \| and ~. No COMMAND filter. |
20
+ | CallbackQueryHandler | `MessageEventHandler(callback, predicate=...)` | Handles VK message_event; payload shape and answer identifiers differ. |
21
+ | ConversationHandler | No built-in equivalent | Explicit state transitions in the bot project; design persistence if required. |
22
+ | JobQueue | No built-in scheduler | Reuse an existing scheduler or propose one; manage task lifetime explicitly. |
23
+ | Persistence | No built-in backend | In-memory context does not survive restart; adapt the application's storage. |
24
+ | Error callbacks | `Application.add_error_handler(callback)` | Async `(update, context)`; update may be None, exception is context.error; errors propagate. |
25
+ | Updater / webhook delivery | `LongPoll` / `Callback` | Different VK delivery/acknowledgement semantics; choose one mode per Application. |
26
+ | Inline/reply keyboard | `Keyboard(buttons=..., inline=...)` | VK action dictionaries, not Telegram button objects. |
27
+ | send_message / reply_text | `Bot.send_message(peer_id, text, ...)` | Returns an integer ID; Message has no reply_text method. |
28
+ | Edit / forward | `Bot.edit_message` / `Bot.forward_message` | Explicit peer and correct identifier scope; forwarding uses the source peer. |
29
+ | Media / deletion | Attachment references supported; no upload/delete wrappers | Do not promise uploads or delete_message; raw Bot.request needs separately verified VK methods. |
30
+
31
+ For implementation details inspect the installed package's `__init__.py`, `application.py`,
32
+ `handlers.py`, `context.py`, `filters.py`, `bot.py`, `models/` and `callback.py`.
33
+
34
+ ## Working command and message example
35
+
36
+ Run with Python 3.14 and VK_TOKEN/VK_GROUP_ID configured. Enable community messaging, Bots
37
+ Long Poll and message_new first. This example replies only to received messages.
38
+
39
+ ```python
40
+ import asyncio
41
+
42
+ from python_vk_bot import (
43
+ Application,
44
+ Bot,
45
+ BotSettings,
46
+ CommandHandler,
47
+ Context,
48
+ MessageHandler,
49
+ Update,
50
+ filters,
51
+ )
52
+
53
+
54
+ def make_reply(text: str) -> str:
55
+ # Keep platform-independent business logic outside handlers.
56
+ return f"You said: {text}"
57
+
58
+
59
+ async def start(update: Update, context: Context) -> None:
60
+ if update.message is not None:
61
+ await context.bot.send_message(update.message.peer_id, "Send some text.")
62
+
63
+
64
+ async def echo(update: Update, context: Context) -> None:
65
+ if update.message is not None:
66
+ reply = make_reply(update.message.text)
67
+ await context.bot.send_message(update.message.peer_id, reply)
68
+
69
+
70
+ async def main() -> None:
71
+ settings = BotSettings()
72
+ bot = Bot(settings.token, api_version=settings.api_version, timeout=settings.timeout)
73
+ app = Application(bot, settings.group_id)
74
+ app.add_handler(CommandHandler("start", start))
75
+ app.add_handler(MessageHandler(echo, predicate=filters.TEXT))
76
+ await app.run_polling(wait=settings.polling_wait)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ Handlers run serially, taking the first match in each numeric group. Registration order
84
+ above prevents /start from also reaching echo in the same group. Other slash commands
85
+ still reach echo: add explicit command policy if the source bot excluded all commands.
86
+ In an existing event loop, await `main()` instead of calling `asyncio.run()`.
87
+
88
+ ## Button-event answer
89
+
90
+ This registration helper operates on an existing Application. Enable message_event for
91
+ the chosen delivery mode and supply valid VK callback buttons separately.
92
+
93
+ ```python
94
+ from python_vk_bot import Application, Context, MessageEventHandler, Update
95
+
96
+
97
+ async def answer_button(update: Update, context: Context) -> None:
98
+ event = update.message_event
99
+ if event is not None:
100
+ await context.bot.send_message_event_answer(event.event_id, event.user_id, event.peer_id)
101
+
102
+
103
+ def register_button_handler(app: Application) -> None:
104
+ app.add_handler(MessageEventHandler(answer_button))
105
+ ```
106
+
107
+ Use the object's `event.event_id` for the answer, not the envelope `update.event_id`.
108
+ The library preserves `event.payload` as JSON, including strings; validate the application's
109
+ expected shape before routing actions. Do not assume Telegram callback_data semantics.
110
+
111
+ ## Platform and lifecycle pitfalls
112
+
113
+ - A message's `peer_id` identifies the destination conversation; `from_id` identifies its
114
+ author. Keep VK identities separate from Telegram IDs and explicitly map existing users.
115
+ - `id` and `conversation_message_id` have different scopes. `edit_message` takes exactly one
116
+ of `message_id` or `conversation_message_id`; the latter is serialized as VK's `cmid`.
117
+ - Keyboard action dictionaries and media attachment references are VK data. Telegram file
118
+ IDs, entities and parse modes cannot be copied as working VK structures.
119
+ - Community tokens require appropriate permissions and conversation access. Do not assume
120
+ the bot can initiate arbitrary conversations or that Telegram recipients opted into VK.
121
+ - Long Poll consumes batches and can lose remaining events on cancellation/crash. Callback
122
+ acknowledges successful processing and can redeliver after failures. Bounded in-memory
123
+ deduplication does not prevent repeated partial side effects or survive restarts.
124
+ - API mutations are not automatically retried. For a deliberate send retry, preserve the
125
+ operation's nonzero int32 random_id and account for VK's documented deduplication limits.
126
+ Polling backoff settings are not platform rate limits.
127
+ - Application owns Bot's lifecycle; an injected HTTPX client remains caller-owned. Await
128
+ cancellation/cleanup and ensure application-owned scheduler tasks end before resources close.
129
+ - Context state is process-local. Choose per-user, per-peer or compound keys deliberately;
130
+ durable conversations and multi-worker consistency require application-level design.
131
+ - Callback HTTP delivery is distinct from message_event button handling. The optional ASGI
132
+ receiver requires the callback extra, secret, confirmation code and HTTPS deployment.
133
+ - Do not log tokens, context dictionaries, raw exceptions or complete updates. Use harmless
134
+ placeholders and mocked HTTP/event delivery in verification.
135
+
136
+ ## Shared logic and migration report
137
+
138
+ Keep business services independent of platform Update/Context objects. Adapt input and output
139
+ at existing handler boundaries, preserve integrations, and introduce shared adapters only if
140
+ both applications need them. Record explicit decisions for unsupported workflows, scheduling,
141
+ uploads, persistence and Telegram-only features instead of silently dropping them.
142
+
143
+ For each migrated component report its source version/interface, VK replacement, differences,
144
+ manual setup, checks and remaining work. Verify shutdown and error paths as well as happy paths.
145
+
146
+ ## Official references
147
+
148
+ - [PTB Application](https://docs.python-telegram-bot.org/en/v22.8/telegram.ext.application.html)
149
+ - [PTB CallbackContext](https://docs.python-telegram-bot.org/en/v22.8/telegram.ext.callbackcontext.html)
150
+ - [VK API versions](https://dev.vk.com/ru/reference/versions)
151
+ - [VK messages.send](https://dev.vk.com/ru/method/messages.send)
152
+ - [VK messages.edit](https://dev.vk.com/ru/method/messages.edit)
153
+ - [VK message-event answers](https://dev.vk.com/ru/method/messages.sendMessageEventAnswer)
154
+ - [Bots Long Poll](https://dev.vk.com/ru/api/bots-long-poll/getting-started)
155
+ - [Callback API](https://dev.vk.com/ru/api/callback/getting-started)
156
+
157
+ Recheck applicable official VK documentation before version-dependent migration work.
158
+ This mapping describes library interfaces, not a guarantee of live-token permissions or
159
+ complete VK API coverage.
@@ -0,0 +1,30 @@
1
+ """An async framework for VK community bots."""
2
+
3
+ from . import filters
4
+ from .application import Application
5
+ from .bot import Bot
6
+ from .callback import Callback
7
+ from .context import Context
8
+ from .handlers import CommandHandler, Handler, MessageEventHandler, MessageHandler
9
+ from .long_poll import LongPoll
10
+ from .models import Keyboard, Message, MessageEvent, Update, User
11
+ from .settings import BotSettings
12
+
13
+ __all__ = [
14
+ "Application",
15
+ "Bot",
16
+ "BotSettings",
17
+ "Callback",
18
+ "CommandHandler",
19
+ "Context",
20
+ "Handler",
21
+ "Keyboard",
22
+ "LongPoll",
23
+ "Message",
24
+ "MessageEvent",
25
+ "MessageEventHandler",
26
+ "MessageHandler",
27
+ "Update",
28
+ "User",
29
+ "filters",
30
+ ]
@@ -0,0 +1,22 @@
1
+ """Shared constraints for explicit configuration and environment-backed settings."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr
6
+
7
+ DEFAULT_API_VERSION = "5.199"
8
+ DEFAULT_TIMEOUT = 40.0
9
+
10
+ type Token = Annotated[SecretStr, Field(min_length=1)]
11
+ type APIVersion = Annotated[str, Field(pattern=r"^\d+\.\d+$")]
12
+ type RequestTimeout = Annotated[float, Field(gt=0, allow_inf_nan=False)]
13
+
14
+
15
+ class TransportConfig(BaseModel):
16
+ """Explicit transport values; never read environment variables or files."""
17
+
18
+ model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
19
+
20
+ token: Token = Field(repr=False)
21
+ api_version: APIVersion = DEFAULT_API_VERSION
22
+ timeout: RequestTimeout = DEFAULT_TIMEOUT
@@ -0,0 +1,221 @@
1
+ """Serial dispatch with explicit lifecycle and bounded duplicate suppression."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import OrderedDict
6
+ from collections.abc import Awaitable, Callable
7
+ from contextlib import aclosing
8
+ from types import TracebackType
9
+ from typing import Self
10
+
11
+ from .bot import Bot
12
+ from .context import Context
13
+ from .errors import LifecycleError
14
+ from .handlers import Handler
15
+ from .long_poll import LongPoll
16
+ from .models import Update
17
+
18
+ type ErrorCallback = Callable[[Update | None, Context], Awaitable[None]]
19
+ type LifecycleHook = Callable[[Application], Awaitable[None]]
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Application:
25
+ def __init__(
26
+ self,
27
+ bot: Bot,
28
+ group_id: int,
29
+ *,
30
+ deduplication_size: int = 4096,
31
+ post_init: LifecycleHook | None = None,
32
+ post_shutdown: LifecycleHook | None = None,
33
+ ) -> None:
34
+ if group_id <= 0 or deduplication_size < 0:
35
+ raise ValueError("group_id must be positive and deduplication_size nonnegative")
36
+ self.bot = bot
37
+ self.group_id = group_id
38
+ self.bot_data: dict[str, object] = {}
39
+ self.user_data: dict[int, dict[str, object]] = {}
40
+ self.peer_data: dict[int, dict[str, object]] = {}
41
+ self._handlers: dict[int, list[Handler]] = {}
42
+ self._error_handlers: list[ErrorCallback] = []
43
+ self._seen: OrderedDict[str, None] = OrderedDict()
44
+ self._deduplication_size = deduplication_size
45
+ self._post_init = post_init
46
+ self._post_shutdown = post_shutdown
47
+ self._dispatch_lock = asyncio.Lock()
48
+ self._lifecycle_lock = asyncio.Lock()
49
+ self._initialized = False
50
+ self._context_active = False
51
+ self._running = False
52
+ self._polling_task: asyncio.Task[None] | None = None
53
+ self._processing_task: asyncio.Task[object] | None = None
54
+
55
+ @property
56
+ def running(self) -> bool:
57
+ return self._running
58
+
59
+ def add_handler(self, handler: Handler, *, group: int = 0) -> None:
60
+ if self._running:
61
+ raise LifecycleError("Register handlers before starting the application")
62
+ self._handlers.setdefault(group, []).append(handler)
63
+
64
+ def add_error_handler(self, callback: ErrorCallback) -> None:
65
+ if self._running:
66
+ raise LifecycleError("Register error handlers before starting the application")
67
+ self._error_handlers.append(callback)
68
+
69
+ async def initialize(self) -> None:
70
+ async with self._lifecycle_lock:
71
+ if self._initialized:
72
+ return
73
+ try:
74
+ await self.bot.initialize()
75
+ if self._post_init is not None:
76
+ await self._post_init(self)
77
+ except BaseException:
78
+ await self.bot.shutdown()
79
+ raise
80
+ self._initialized = True
81
+
82
+ async def start(self) -> None:
83
+ if not self._initialized:
84
+ raise LifecycleError("Initialize the application before starting it")
85
+ self._running = True
86
+
87
+ async def stop(self) -> None:
88
+ """Stop intake, cancel owned polling, and wait for active dispatch to finish.
89
+
90
+ A handler may call stop: its current dispatch completes before polling exits.
91
+ """
92
+ self._running = False
93
+ task = self._polling_task
94
+ current = asyncio.current_task()
95
+ if task is not None and task is not current:
96
+ task.cancel()
97
+ await asyncio.gather(task, return_exceptions=True)
98
+ if self._processing_task is not current:
99
+ async with self._dispatch_lock:
100
+ pass
101
+
102
+ async def shutdown(self) -> None:
103
+ if self._processing_task is asyncio.current_task():
104
+ raise LifecycleError("Use stop() inside a handler; shutdown belongs to the owner")
105
+ await self.stop()
106
+ async with self._lifecycle_lock:
107
+ if not self._initialized:
108
+ return
109
+ try:
110
+ await self.bot.shutdown()
111
+ finally:
112
+ self._initialized = False
113
+ if self._post_shutdown is not None:
114
+ await self._post_shutdown(self)
115
+
116
+ async def __aenter__(self) -> Self:
117
+ if self._initialized or self._context_active:
118
+ raise LifecycleError("Application context managers cannot be nested")
119
+ self._context_active = True
120
+ try:
121
+ await self.initialize()
122
+ await self.start()
123
+ except BaseException:
124
+ self._context_active = False
125
+ raise
126
+ return self
127
+
128
+ async def __aexit__(
129
+ self,
130
+ exc_type: type[BaseException] | None,
131
+ exc: BaseException | None,
132
+ traceback: TracebackType | None,
133
+ ) -> None:
134
+ try:
135
+ await self.shutdown()
136
+ finally:
137
+ self._context_active = False
138
+
139
+ def _context(self, update: Update | None, error: Exception | None = None) -> Context:
140
+ user_id = update.user_id if update is not None else None
141
+ peer_id = update.peer_id if update is not None else None
142
+ return Context(
143
+ application=self,
144
+ bot=self.bot,
145
+ user_data=self.user_data.setdefault(user_id, {}) if user_id is not None else {},
146
+ peer_data=self.peer_data.setdefault(peer_id, {}) if peer_id is not None else {},
147
+ bot_data=self.bot_data,
148
+ error=error,
149
+ )
150
+
151
+ async def _report_error(self, update: Update | None, error: Exception) -> None:
152
+ context = self._context(update, error)
153
+ logger.error("Update processing failed (%s)", type(error).__name__)
154
+ for callback in self._error_handlers:
155
+ try:
156
+ await callback(update, context)
157
+ except Exception as callback_error:
158
+ logger.error("Error handler failed (%s)", type(callback_error).__name__)
159
+
160
+ async def process_update(self, update: Update) -> bool:
161
+ """Return False for a duplicate, True after dispatch; propagate handler errors."""
162
+ if self._processing_task is asyncio.current_task():
163
+ raise LifecycleError("Recursive update dispatch is not supported")
164
+ async with self._dispatch_lock:
165
+ if not self._running:
166
+ raise LifecycleError("Application is not running")
167
+ if update.group_id != self.group_id:
168
+ raise ValueError("Update belongs to another community")
169
+ if update.event_id in self._seen:
170
+ return False
171
+ self._processing_task = asyncio.current_task()
172
+ try:
173
+ context = self._context(update)
174
+ for group in sorted(self._handlers):
175
+ for handler in self._handlers[group]:
176
+ if handler.check_update(update):
177
+ await handler.handle_update(update, context)
178
+ break
179
+ except Exception as error:
180
+ await self._report_error(update, error)
181
+ raise
182
+ finally:
183
+ self._processing_task = None
184
+ if self._deduplication_size:
185
+ self._seen[update.event_id] = None
186
+ if len(self._seen) > self._deduplication_size:
187
+ self._seen.popitem(last=False)
188
+ return True
189
+
190
+ async def _poll(self, long_poll: LongPoll) -> None:
191
+ async with aclosing(long_poll.listen()) as updates:
192
+ async for update in updates:
193
+ if not self._running:
194
+ break
195
+ try:
196
+ await self.process_update(update)
197
+ except Exception:
198
+ # Dispatch already reported the error; never replay partial side effects.
199
+ logger.warning("Skipping failed update in polling mode")
200
+ if not self._running:
201
+ break
202
+
203
+ async def run_polling(self, *, wait: int = 25) -> None:
204
+ """Run until stop or cancellation; own startup and shutdown, not the event loop."""
205
+ if self._polling_task is not None:
206
+ raise LifecycleError("Polling is already running")
207
+ long_poll = LongPoll(self.bot, self.group_id, wait=wait)
208
+ async with self:
209
+ task = asyncio.create_task(self._poll(long_poll), name="vk-long-poll")
210
+ self._polling_task = task
211
+ try:
212
+ await task
213
+ except asyncio.CancelledError:
214
+ current = asyncio.current_task()
215
+ if current is not None and current.cancelling():
216
+ raise
217
+ except Exception as error:
218
+ await self._report_error(None, error)
219
+ raise
220
+ finally:
221
+ self._polling_task = None
python_vk_bot/bot.py ADDED
@@ -0,0 +1,184 @@
1
+ """Typed wrappers for supported VK community bot methods."""
2
+
3
+ import json
4
+ import secrets
5
+ from collections.abc import Mapping
6
+ from types import TracebackType
7
+ from typing import Self
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, JsonValue, SecretStr, TypeAdapter, ValidationError
11
+
12
+ from ._config import DEFAULT_API_VERSION, DEFAULT_TIMEOUT, TransportConfig
13
+ from .errors import InvalidResponseError
14
+ from .models.api import LongPollServer
15
+ from .models.messages import Keyboard, Message, MessagePage, User
16
+ from .transport import HTTPTransport, Parameter
17
+
18
+
19
+ def _parse[T: BaseModel](model: type[T], payload: JsonValue) -> T:
20
+ try:
21
+ return model.model_validate(payload)
22
+ except ValidationError:
23
+ raise InvalidResponseError(f"Invalid {model.__name__} response") from None
24
+
25
+
26
+ def _integer(payload: JsonValue) -> int:
27
+ if type(payload) is not int:
28
+ raise InvalidResponseError("Expected an integer VK response")
29
+ return payload
30
+
31
+
32
+ def _random_id(value: int | None) -> int:
33
+ if value is None:
34
+ return secrets.randbelow(2**31 - 1) + 1
35
+ if type(value) is not int or not -(2**31) <= value < 2**31 or value == 0:
36
+ raise ValueError("random_id must be a nonzero signed 32-bit integer")
37
+ return value
38
+
39
+
40
+ class Bot:
41
+ def __init__(
42
+ self,
43
+ token: str | SecretStr,
44
+ *,
45
+ api_version: str = DEFAULT_API_VERSION,
46
+ timeout: float = DEFAULT_TIMEOUT,
47
+ client: httpx.AsyncClient | None = None,
48
+ ) -> None:
49
+ config = TransportConfig(
50
+ token=token if isinstance(token, SecretStr) else SecretStr(token),
51
+ api_version=api_version,
52
+ timeout=timeout,
53
+ )
54
+ self.transport = HTTPTransport(config, client=client)
55
+
56
+ async def initialize(self) -> None:
57
+ await self.transport.initialize()
58
+
59
+ async def shutdown(self) -> None:
60
+ await self.transport.shutdown()
61
+
62
+ async def __aenter__(self) -> Self:
63
+ await self.initialize()
64
+ return self
65
+
66
+ async def __aexit__(
67
+ self,
68
+ exc_type: type[BaseException] | None,
69
+ exc: BaseException | None,
70
+ traceback: TracebackType | None,
71
+ ) -> None:
72
+ await self.shutdown()
73
+
74
+ async def request(self, method: str, parameters: Mapping[str, Parameter]) -> JsonValue:
75
+ """Access a raw VK result. Nested parameters must be explicitly JSON encoded."""
76
+ return await self.transport.call(method, parameters)
77
+
78
+ async def get_long_poll_server(self, group_id: int) -> LongPollServer:
79
+ if group_id <= 0:
80
+ raise ValueError("group_id must be positive")
81
+ result = await self.request("groups.getLongPollServer", {"group_id": group_id})
82
+ return _parse(LongPollServer, result)
83
+
84
+ async def get_user_info(self, user_id: int) -> User | None:
85
+ result = await self.request("users.get", {"user_ids": user_id, "fields": "screen_name"})
86
+ try:
87
+ users = TypeAdapter(list[User]).validate_python(result)
88
+ except ValidationError:
89
+ raise InvalidResponseError("Invalid users.get response") from None
90
+ return users[0] if users else None
91
+
92
+ async def get_message(self, peer_id: int, conversation_message_id: int) -> Message | None:
93
+ result = await self.request(
94
+ "messages.getByConversationMessageId",
95
+ {"peer_id": peer_id, "conversation_message_ids": conversation_message_id},
96
+ )
97
+ page = _parse(MessagePage, result)
98
+ return page.items[0] if page.items else None
99
+
100
+ async def send_message(
101
+ self,
102
+ peer_id: int,
103
+ text: str = "",
104
+ *,
105
+ random_id: int | None = None,
106
+ keyboard: Keyboard | None = None,
107
+ attachment: str | None = None,
108
+ dont_parse_links: bool = False,
109
+ ) -> int:
110
+ if not text and not attachment:
111
+ raise ValueError("Provide text or an attachment")
112
+ result = await self.request(
113
+ "messages.send",
114
+ {
115
+ "peer_id": peer_id,
116
+ "message": text,
117
+ "random_id": _random_id(random_id),
118
+ "keyboard": keyboard.model_dump_json(exclude_none=True) if keyboard else None,
119
+ "attachment": attachment,
120
+ "dont_parse_links": dont_parse_links,
121
+ },
122
+ )
123
+ return _integer(result)
124
+
125
+ async def edit_message(
126
+ self,
127
+ peer_id: int,
128
+ text: str,
129
+ *,
130
+ message_id: int | None = None,
131
+ conversation_message_id: int | None = None,
132
+ keyboard: Keyboard | None = None,
133
+ dont_parse_links: bool = False,
134
+ ) -> bool:
135
+ if (message_id is None) == (conversation_message_id is None):
136
+ raise ValueError("Provide exactly one message identifier")
137
+ result = await self.request(
138
+ "messages.edit",
139
+ {
140
+ "peer_id": peer_id,
141
+ "message": text,
142
+ "message_id": message_id,
143
+ "cmid": conversation_message_id,
144
+ "keyboard": keyboard.model_dump_json(exclude_none=True) if keyboard else None,
145
+ "dont_parse_links": dont_parse_links,
146
+ },
147
+ )
148
+ return _integer(result) == 1
149
+
150
+ async def forward_message(
151
+ self, peer_id: int, message: Message, *, random_id: int | None = None
152
+ ) -> int:
153
+ # The source is the conversation, which need not be the author's user ID.
154
+ forward = {
155
+ "peer_id": message.peer_id,
156
+ "conversation_message_ids": [message.conversation_message_id],
157
+ }
158
+ result = await self.request(
159
+ "messages.send",
160
+ {
161
+ "peer_id": peer_id,
162
+ "random_id": _random_id(random_id),
163
+ "forward": json.dumps(forward),
164
+ },
165
+ )
166
+ return _integer(result)
167
+
168
+ async def send_message_event_answer(
169
+ self,
170
+ event_id: str,
171
+ user_id: int,
172
+ peer_id: int,
173
+ event_data: Mapping[str, JsonValue] | None = None,
174
+ ) -> bool:
175
+ result = await self.request(
176
+ "messages.sendMessageEventAnswer",
177
+ {
178
+ "event_id": event_id,
179
+ "user_id": user_id,
180
+ "peer_id": peer_id,
181
+ "event_data": json.dumps(dict(event_data)) if event_data is not None else None,
182
+ },
183
+ )
184
+ return _integer(result) == 1