maxapi-sdk 0.12.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.
- maxapi/__init__.py +49 -0
- maxapi/bot.py +674 -0
- maxapi/builders/__init__.py +23 -0
- maxapi/builders/keyboards.py +133 -0
- maxapi/builders/media.py +81 -0
- maxapi/callback_schema.py +140 -0
- maxapi/client/__init__.py +3 -0
- maxapi/client/default.py +89 -0
- maxapi/compat/__init__.py +15 -0
- maxapi/connection/__init__.py +3 -0
- maxapi/connection/base.py +136 -0
- maxapi/dispatcher.py +487 -0
- maxapi/exceptions/__init__.py +3 -0
- maxapi/exceptions/max.py +45 -0
- maxapi/filters/__init__.py +25 -0
- maxapi/filters/base.py +73 -0
- maxapi/filters/command.py +28 -0
- maxapi/filters/common.py +78 -0
- maxapi/filters/text.py +71 -0
- maxapi/fsm/__init__.py +17 -0
- maxapi/fsm/context.py +41 -0
- maxapi/fsm/filters.py +30 -0
- maxapi/fsm/middleware.py +42 -0
- maxapi/fsm/state.py +33 -0
- maxapi/fsm/storage/__init__.py +4 -0
- maxapi/fsm/storage/base.py +30 -0
- maxapi/fsm/storage/memory.py +38 -0
- maxapi/middlewares/__init__.py +3 -0
- maxapi/middlewares/base.py +34 -0
- maxapi/plugins/__init__.py +3 -0
- maxapi/plugins/base.py +19 -0
- maxapi/py.typed +1 -0
- maxapi/runners/__init__.py +4 -0
- maxapi/runners/polling.py +55 -0
- maxapi/runners/webhook.py +72 -0
- maxapi/transport/__init__.py +13 -0
- maxapi/transport/client.py +239 -0
- maxapi/transport/config.py +81 -0
- maxapi/transport/errors.py +45 -0
- maxapi/types/__init__.py +73 -0
- maxapi/types/base.py +9 -0
- maxapi/types/bot_mixin.py +14 -0
- maxapi/types/models.py +308 -0
- maxapi_sdk-0.12.0.dist-info/METADATA +270 -0
- maxapi_sdk-0.12.0.dist-info/RECORD +48 -0
- maxapi_sdk-0.12.0.dist-info/WHEEL +5 -0
- maxapi_sdk-0.12.0.dist-info/licenses/LICENSE +21 -0
- maxapi_sdk-0.12.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from aiohttp import (
|
|
10
|
+
ClientConnectionError,
|
|
11
|
+
ClientPayloadError,
|
|
12
|
+
ClientResponse,
|
|
13
|
+
ClientSession,
|
|
14
|
+
ContentTypeError,
|
|
15
|
+
FormData,
|
|
16
|
+
TCPConnector,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ..exceptions.max import InvalidToken, MaxApiError, MaxConnection
|
|
20
|
+
from .config import TransportConfig
|
|
21
|
+
from .errors import RateLimitExceededError, ResponseDecodeError, ServerResponseError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class TransportResult:
|
|
26
|
+
raw: Any
|
|
27
|
+
parsed: Any
|
|
28
|
+
status: int
|
|
29
|
+
headers: dict[str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MaxApiTransport:
|
|
33
|
+
"""Надёжный HTTP-клиент для MAX API."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str,
|
|
39
|
+
headers: dict[str, str] | None = None,
|
|
40
|
+
config: TransportConfig,
|
|
41
|
+
session: ClientSession | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._base_url = base_url.rstrip("/")
|
|
44
|
+
self._headers = dict(headers or {})
|
|
45
|
+
self._config = config
|
|
46
|
+
self._session = session
|
|
47
|
+
self._owns_session = False
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def session(self) -> ClientSession | None:
|
|
51
|
+
return self._session
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
if self._owns_session and self._session is not None and not self._session.closed:
|
|
55
|
+
await self._session.close()
|
|
56
|
+
|
|
57
|
+
async def request(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
method: str,
|
|
61
|
+
path: str,
|
|
62
|
+
model: Any = None,
|
|
63
|
+
**kwargs: Any,
|
|
64
|
+
) -> TransportResult:
|
|
65
|
+
retry_policy = self._config.retry_policy
|
|
66
|
+
normalized_method = method.upper()
|
|
67
|
+
last_error: Exception | None = None
|
|
68
|
+
|
|
69
|
+
for attempt in range(1, retry_policy.attempts + 1):
|
|
70
|
+
response: ClientResponse | None = None
|
|
71
|
+
try:
|
|
72
|
+
session = await self._ensure_session()
|
|
73
|
+
url = path if path.startswith("http") else f"{self._base_url}{path}"
|
|
74
|
+
response = await session.request(method=normalized_method, url=url, **kwargs)
|
|
75
|
+
return await self._handle_response(response=response, model=model)
|
|
76
|
+
except InvalidToken:
|
|
77
|
+
raise
|
|
78
|
+
except RateLimitExceededError as exc:
|
|
79
|
+
last_error = exc
|
|
80
|
+
if attempt >= retry_policy.attempts:
|
|
81
|
+
raise MaxApiError(
|
|
82
|
+
code=exc.status,
|
|
83
|
+
raw=exc.payload,
|
|
84
|
+
message=exc.message,
|
|
85
|
+
headers=exc.headers,
|
|
86
|
+
) from exc
|
|
87
|
+
await asyncio.sleep(
|
|
88
|
+
retry_policy.delay_for_attempt(attempt, retry_after=exc.retry_after)
|
|
89
|
+
)
|
|
90
|
+
except ServerResponseError as exc:
|
|
91
|
+
last_error = exc
|
|
92
|
+
if attempt >= retry_policy.attempts:
|
|
93
|
+
raise MaxApiError(
|
|
94
|
+
code=exc.status,
|
|
95
|
+
raw=exc.payload,
|
|
96
|
+
message=exc.message,
|
|
97
|
+
headers=exc.headers,
|
|
98
|
+
) from exc
|
|
99
|
+
await asyncio.sleep(retry_policy.delay_for_attempt(attempt))
|
|
100
|
+
except (ClientConnectionError, ClientPayloadError, asyncio.TimeoutError) as exc:
|
|
101
|
+
last_error = exc
|
|
102
|
+
if attempt >= retry_policy.attempts or not retry_policy.allows(normalized_method):
|
|
103
|
+
raise MaxConnection(f"Ошибка при отправке запроса: {exc}") from exc
|
|
104
|
+
await asyncio.sleep(retry_policy.delay_for_attempt(attempt))
|
|
105
|
+
finally:
|
|
106
|
+
if response is not None and not response.closed:
|
|
107
|
+
response.release()
|
|
108
|
+
|
|
109
|
+
if last_error is not None:
|
|
110
|
+
raise MaxConnection(f"Ошибка при отправке запроса: {last_error}") from last_error
|
|
111
|
+
raise MaxConnection("Ошибка при отправке запроса: неизвестная ошибка")
|
|
112
|
+
|
|
113
|
+
async def upload(self, *, url: str, data: FormData) -> str:
|
|
114
|
+
session = await self._ensure_session(upload_mode=True)
|
|
115
|
+
try:
|
|
116
|
+
async with session.post(url=url, data=data) as response:
|
|
117
|
+
text = await response.text()
|
|
118
|
+
if response.status >= 400:
|
|
119
|
+
payload = self._try_parse_json_text(text)
|
|
120
|
+
raise MaxApiError(
|
|
121
|
+
code=response.status,
|
|
122
|
+
raw=payload,
|
|
123
|
+
message="Ошибка загрузки файла в MAX",
|
|
124
|
+
headers=dict(response.headers),
|
|
125
|
+
)
|
|
126
|
+
return text
|
|
127
|
+
except ClientConnectionError as exc:
|
|
128
|
+
raise MaxConnection(f"Ошибка при загрузке файла: {exc}") from exc
|
|
129
|
+
|
|
130
|
+
async def _ensure_session(self, *, upload_mode: bool = False) -> ClientSession:
|
|
131
|
+
if self._session is not None and not self._session.closed:
|
|
132
|
+
return self._session
|
|
133
|
+
|
|
134
|
+
session_kwargs = dict(self._config.session_kwargs)
|
|
135
|
+
session_kwargs.setdefault("timeout", self._config.timeout)
|
|
136
|
+
session_kwargs.setdefault("headers", self._headers)
|
|
137
|
+
session_kwargs.setdefault(
|
|
138
|
+
"connector",
|
|
139
|
+
TCPConnector(
|
|
140
|
+
limit=session_kwargs.pop("connector_limit", 100),
|
|
141
|
+
limit_per_host=session_kwargs.pop("connector_limit_per_host", 30),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if upload_mode:
|
|
146
|
+
session_kwargs.pop("headers", None)
|
|
147
|
+
|
|
148
|
+
self._session = ClientSession(**session_kwargs)
|
|
149
|
+
self._owns_session = True
|
|
150
|
+
return self._session
|
|
151
|
+
|
|
152
|
+
async def _handle_response(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
response: ClientResponse,
|
|
156
|
+
model: Any,
|
|
157
|
+
) -> TransportResult:
|
|
158
|
+
headers = dict(response.headers)
|
|
159
|
+
raw = await self._decode_response(response)
|
|
160
|
+
|
|
161
|
+
if response.status == 401:
|
|
162
|
+
raise InvalidToken("Неверный токен!", raw=raw, headers=headers)
|
|
163
|
+
if response.status == 429:
|
|
164
|
+
retry_after = self._extract_retry_after(headers)
|
|
165
|
+
raise RateLimitExceededError(
|
|
166
|
+
status=response.status,
|
|
167
|
+
payload=raw,
|
|
168
|
+
headers=headers,
|
|
169
|
+
retry_after=retry_after,
|
|
170
|
+
message="API MAX вернул 429 Too Many Requests",
|
|
171
|
+
)
|
|
172
|
+
if 500 <= response.status <= 599:
|
|
173
|
+
raise ServerResponseError(
|
|
174
|
+
status=response.status,
|
|
175
|
+
payload=raw,
|
|
176
|
+
headers=headers,
|
|
177
|
+
message="API MAX вернул серверную ошибку",
|
|
178
|
+
)
|
|
179
|
+
if response.status >= 400:
|
|
180
|
+
raise MaxApiError(code=response.status, raw=raw, headers=headers)
|
|
181
|
+
|
|
182
|
+
parsed = self._parse_model(model=model, raw=raw)
|
|
183
|
+
return TransportResult(raw=raw, parsed=parsed, status=response.status, headers=headers)
|
|
184
|
+
|
|
185
|
+
async def _decode_response(self, response: ClientResponse) -> Any:
|
|
186
|
+
if response.status == 204:
|
|
187
|
+
return {}
|
|
188
|
+
|
|
189
|
+
content_type = response.headers.get("Content-Type", "")
|
|
190
|
+
if "application/json" in content_type.lower():
|
|
191
|
+
try:
|
|
192
|
+
return await response.json(content_type=None)
|
|
193
|
+
except (ContentTypeError, json.JSONDecodeError) as exc:
|
|
194
|
+
text = await response.text()
|
|
195
|
+
raise ResponseDecodeError(text=text, content_type=content_type) from exc
|
|
196
|
+
|
|
197
|
+
text = await response.text()
|
|
198
|
+
return self._try_parse_json_text(text)
|
|
199
|
+
|
|
200
|
+
def _parse_model(self, *, model: Any, raw: Any) -> Any:
|
|
201
|
+
if model is None:
|
|
202
|
+
return raw
|
|
203
|
+
if hasattr(model, "model_validate"):
|
|
204
|
+
return model.model_validate(raw)
|
|
205
|
+
if hasattr(model, "parse_obj"):
|
|
206
|
+
return model.parse_obj(raw)
|
|
207
|
+
if callable(model):
|
|
208
|
+
return model(**raw)
|
|
209
|
+
return raw
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _try_parse_json_text(text: str) -> Any:
|
|
213
|
+
if not text.strip():
|
|
214
|
+
return {}
|
|
215
|
+
try:
|
|
216
|
+
return json.loads(text)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
return {"text": text}
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _extract_retry_after(headers: dict[str, str]) -> float | None:
|
|
222
|
+
retry_after = headers.get("Retry-After")
|
|
223
|
+
if retry_after is None:
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
return float(retry_after)
|
|
227
|
+
except ValueError:
|
|
228
|
+
pass
|
|
229
|
+
try:
|
|
230
|
+
parsed = parsedate_to_datetime(retry_after)
|
|
231
|
+
except (TypeError, ValueError):
|
|
232
|
+
return None
|
|
233
|
+
now = parsed.tzinfo and parsed.now(parsed.tzinfo) or None
|
|
234
|
+
if now is None:
|
|
235
|
+
return None
|
|
236
|
+
delay = (parsed - now).total_seconds()
|
|
237
|
+
if delay <= 0:
|
|
238
|
+
return None
|
|
239
|
+
return delay
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aiohttp import ClientTimeout
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class RetryPolicy:
|
|
11
|
+
"""Политика повторов HTTP-запросов."""
|
|
12
|
+
|
|
13
|
+
attempts: int = 5
|
|
14
|
+
initial_delay: float = 0.5
|
|
15
|
+
backoff: float = 2.0
|
|
16
|
+
max_delay: float = 8.0
|
|
17
|
+
respect_retry_after: bool = True
|
|
18
|
+
retry_statuses: tuple[int, ...] = (408, 425, 429, 500, 502, 503, 504)
|
|
19
|
+
retry_methods: tuple[str, ...] = (
|
|
20
|
+
"DELETE",
|
|
21
|
+
"GET",
|
|
22
|
+
"HEAD",
|
|
23
|
+
"OPTIONS",
|
|
24
|
+
"PATCH",
|
|
25
|
+
"POST",
|
|
26
|
+
"PUT",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def delay_for_attempt(
|
|
30
|
+
self,
|
|
31
|
+
attempt_index: int,
|
|
32
|
+
*,
|
|
33
|
+
retry_after: float | None = None,
|
|
34
|
+
) -> float:
|
|
35
|
+
if self.respect_retry_after and retry_after is not None and retry_after > 0:
|
|
36
|
+
return min(retry_after, self.max_delay)
|
|
37
|
+
delay = self.initial_delay * (self.backoff ** max(attempt_index - 1, 0))
|
|
38
|
+
return min(delay, self.max_delay)
|
|
39
|
+
|
|
40
|
+
def allows(self, method: str, status: int | None = None) -> bool:
|
|
41
|
+
normalized_method = method.upper()
|
|
42
|
+
if normalized_method not in self.retry_methods:
|
|
43
|
+
return False
|
|
44
|
+
if status is None:
|
|
45
|
+
return True
|
|
46
|
+
return status in self.retry_statuses
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class TransportConfig:
|
|
51
|
+
"""Конфигурация транспортного клиента."""
|
|
52
|
+
|
|
53
|
+
timeout: ClientTimeout
|
|
54
|
+
session_kwargs: dict[str, Any]
|
|
55
|
+
retry_policy: RetryPolicy
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_default_connection(cls, connection: Any) -> "TransportConfig":
|
|
59
|
+
retry_policy = RetryPolicy(
|
|
60
|
+
attempts=getattr(connection, "request_retries", 5),
|
|
61
|
+
initial_delay=getattr(connection, "request_retry_delay", 0.5),
|
|
62
|
+
backoff=getattr(connection, "request_retry_backoff", 2.0),
|
|
63
|
+
max_delay=getattr(connection, "request_retry_max_delay", 8.0),
|
|
64
|
+
respect_retry_after=getattr(connection, "respect_retry_after", True),
|
|
65
|
+
retry_statuses=tuple(
|
|
66
|
+
getattr(connection, "retry_statuses", (408, 425, 429, 500, 502, 503, 504))
|
|
67
|
+
),
|
|
68
|
+
retry_methods=tuple(
|
|
69
|
+
getattr(
|
|
70
|
+
connection,
|
|
71
|
+
"retry_methods",
|
|
72
|
+
("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"),
|
|
73
|
+
)
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
session_kwargs = dict(getattr(connection, "kwargs", {}))
|
|
77
|
+
return cls(
|
|
78
|
+
timeout=connection.timeout,
|
|
79
|
+
session_kwargs=session_kwargs,
|
|
80
|
+
retry_policy=retry_policy,
|
|
81
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class TransportResponseError(Exception):
|
|
9
|
+
"""Базовая ошибка transport-слоя."""
|
|
10
|
+
|
|
11
|
+
status: int
|
|
12
|
+
payload: Any
|
|
13
|
+
message: str | None = None
|
|
14
|
+
headers: dict[str, str] | None = None
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
if self.message:
|
|
18
|
+
return self.message
|
|
19
|
+
return f"Transport error status={self.status}: {self.payload!r}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class RateLimitExceededError(TransportResponseError):
|
|
24
|
+
"""Ошибка 429 Too Many Requests."""
|
|
25
|
+
|
|
26
|
+
retry_after: float | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class ServerResponseError(TransportResponseError):
|
|
31
|
+
"""Ошибка 5xx от API MAX."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class ResponseDecodeError(Exception):
|
|
36
|
+
"""Ошибка декодирования ответа сервера."""
|
|
37
|
+
|
|
38
|
+
text: str
|
|
39
|
+
content_type: str | None = None
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
"Не удалось декодировать ответ сервера MAX как JSON. "
|
|
44
|
+
f"content_type={self.content_type!r}"
|
|
45
|
+
)
|
maxapi/types/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from .base import ApiModel
|
|
2
|
+
from .models import (
|
|
3
|
+
AddAdminsRequest,
|
|
4
|
+
AddMembersRequest,
|
|
5
|
+
AnswerCallbackRequest,
|
|
6
|
+
BotCommand,
|
|
7
|
+
CallbackPayload,
|
|
8
|
+
Chat,
|
|
9
|
+
ChatAdmin,
|
|
10
|
+
ChatMember,
|
|
11
|
+
ChatsPage,
|
|
12
|
+
EditMessageRequest,
|
|
13
|
+
EditMessageResponse,
|
|
14
|
+
Image,
|
|
15
|
+
MembersPage,
|
|
16
|
+
Message,
|
|
17
|
+
MessageBody,
|
|
18
|
+
MessageList,
|
|
19
|
+
PinMessageRequest,
|
|
20
|
+
Recipient,
|
|
21
|
+
SendMessageRequest,
|
|
22
|
+
SendMessageResponse,
|
|
23
|
+
SenderAction,
|
|
24
|
+
Subscription,
|
|
25
|
+
SubscriptionsPage,
|
|
26
|
+
SuccessResponse,
|
|
27
|
+
TextFormat,
|
|
28
|
+
Update,
|
|
29
|
+
UpdateType,
|
|
30
|
+
UpdatesPage,
|
|
31
|
+
UploadResponse,
|
|
32
|
+
UploadType,
|
|
33
|
+
User,
|
|
34
|
+
VideoInfo,
|
|
35
|
+
WebhookRequest,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AddAdminsRequest",
|
|
40
|
+
"AddMembersRequest",
|
|
41
|
+
"AnswerCallbackRequest",
|
|
42
|
+
"ApiModel",
|
|
43
|
+
"BotCommand",
|
|
44
|
+
"CallbackPayload",
|
|
45
|
+
"Chat",
|
|
46
|
+
"ChatAdmin",
|
|
47
|
+
"ChatMember",
|
|
48
|
+
"ChatsPage",
|
|
49
|
+
"EditMessageRequest",
|
|
50
|
+
"EditMessageResponse",
|
|
51
|
+
"Image",
|
|
52
|
+
"MembersPage",
|
|
53
|
+
"Message",
|
|
54
|
+
"MessageBody",
|
|
55
|
+
"MessageList",
|
|
56
|
+
"PinMessageRequest",
|
|
57
|
+
"Recipient",
|
|
58
|
+
"SendMessageRequest",
|
|
59
|
+
"SendMessageResponse",
|
|
60
|
+
"SenderAction",
|
|
61
|
+
"Subscription",
|
|
62
|
+
"SubscriptionsPage",
|
|
63
|
+
"SuccessResponse",
|
|
64
|
+
"TextFormat",
|
|
65
|
+
"Update",
|
|
66
|
+
"UpdateType",
|
|
67
|
+
"UpdatesPage",
|
|
68
|
+
"UploadResponse",
|
|
69
|
+
"UploadType",
|
|
70
|
+
"User",
|
|
71
|
+
"VideoInfo",
|
|
72
|
+
"WebhookRequest",
|
|
73
|
+
]
|
maxapi/types/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BotMixin:
|
|
7
|
+
"""Предоставляет безопасный доступ к экземпляру бота."""
|
|
8
|
+
|
|
9
|
+
bot: Any | None = None
|
|
10
|
+
|
|
11
|
+
def _ensure_bot(self) -> Any:
|
|
12
|
+
if self.bot is None:
|
|
13
|
+
raise RuntimeError("Экземпляр Bot не привязан к текущему объекту.")
|
|
14
|
+
return self.bot
|