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,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
|
+
]
|
python_vk_bot/_config.py
ADDED
|
@@ -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
|