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,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
SPECIAL_ROW_LIMIT_TYPES = {
|
|
6
|
+
"link",
|
|
7
|
+
"open_app",
|
|
8
|
+
"request_contact",
|
|
9
|
+
"request_geo_location",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InlineKeyboardBuilder:
|
|
14
|
+
"""Builder для MAX inline keyboard."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._rows: list[list[dict[str, Any]]] = []
|
|
18
|
+
self._pending: list[dict[str, Any]] = []
|
|
19
|
+
|
|
20
|
+
def add(self, *buttons: dict[str, Any]) -> "InlineKeyboardBuilder":
|
|
21
|
+
for button in buttons:
|
|
22
|
+
self._pending.append(_normalize_button(button))
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def button(self, *, button_type: str, text: str, **extra: Any) -> "InlineKeyboardBuilder":
|
|
26
|
+
button = {"type": button_type, "text": text}
|
|
27
|
+
button.update({key: value for key, value in extra.items() if value is not None})
|
|
28
|
+
return self.add(button)
|
|
29
|
+
|
|
30
|
+
def callback(self, text: str, payload: str | dict[str, Any]) -> "InlineKeyboardBuilder":
|
|
31
|
+
return self.button(button_type="callback", text=text, payload=payload)
|
|
32
|
+
|
|
33
|
+
def link(self, text: str, url: str) -> "InlineKeyboardBuilder":
|
|
34
|
+
return self.button(button_type="link", text=text, url=url)
|
|
35
|
+
|
|
36
|
+
def request_contact(self, text: str) -> "InlineKeyboardBuilder":
|
|
37
|
+
return self.button(button_type="request_contact", text=text)
|
|
38
|
+
|
|
39
|
+
def request_geo_location(self, text: str) -> "InlineKeyboardBuilder":
|
|
40
|
+
return self.button(button_type="request_geo_location", text=text)
|
|
41
|
+
|
|
42
|
+
def open_app(
|
|
43
|
+
self,
|
|
44
|
+
text: str,
|
|
45
|
+
*,
|
|
46
|
+
url: str | None = None,
|
|
47
|
+
app_id: str | None = None,
|
|
48
|
+
payload: str | dict[str, Any] | None = None,
|
|
49
|
+
) -> "InlineKeyboardBuilder":
|
|
50
|
+
return self.button(
|
|
51
|
+
button_type="open_app",
|
|
52
|
+
text=text,
|
|
53
|
+
url=url,
|
|
54
|
+
app_id=app_id,
|
|
55
|
+
payload=payload,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def message(self, text: str, message_text: str) -> "InlineKeyboardBuilder":
|
|
59
|
+
return self.button(button_type="message", text=text, message_text=message_text)
|
|
60
|
+
|
|
61
|
+
def row(self, *buttons: dict[str, Any]) -> "InlineKeyboardBuilder":
|
|
62
|
+
normalized = [_normalize_button(button) for button in buttons]
|
|
63
|
+
if normalized:
|
|
64
|
+
self._append_row(normalized)
|
|
65
|
+
elif self._pending:
|
|
66
|
+
self._append_row(self._consume_pending())
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def adjust(self, *sizes: int, repeat: bool = False) -> "InlineKeyboardBuilder":
|
|
70
|
+
if not self._pending:
|
|
71
|
+
return self
|
|
72
|
+
if not sizes:
|
|
73
|
+
sizes = (7,)
|
|
74
|
+
index = 0
|
|
75
|
+
pattern_index = 0
|
|
76
|
+
while index < len(self._pending):
|
|
77
|
+
row_size = sizes[pattern_index]
|
|
78
|
+
row = self._pending[index:index + row_size]
|
|
79
|
+
self._append_row(row)
|
|
80
|
+
index += row_size
|
|
81
|
+
if repeat:
|
|
82
|
+
pattern_index = (pattern_index + 1) % len(sizes)
|
|
83
|
+
else:
|
|
84
|
+
pattern_index = min(pattern_index + 1, len(sizes) - 1)
|
|
85
|
+
self._pending.clear()
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def as_markup(self) -> dict[str, Any]:
|
|
89
|
+
self._flush_pending()
|
|
90
|
+
return {"type": "inline_keyboard", "payload": {"buttons": self._rows}}
|
|
91
|
+
|
|
92
|
+
def as_attachment(self) -> dict[str, Any]:
|
|
93
|
+
return self.as_markup()
|
|
94
|
+
|
|
95
|
+
def export(self) -> list[list[dict[str, Any]]]:
|
|
96
|
+
self._flush_pending()
|
|
97
|
+
return [[dict(button) for button in row] for row in self._rows]
|
|
98
|
+
|
|
99
|
+
def _consume_pending(self) -> list[dict[str, Any]]:
|
|
100
|
+
items = list(self._pending)
|
|
101
|
+
self._pending.clear()
|
|
102
|
+
return items
|
|
103
|
+
|
|
104
|
+
def _flush_pending(self) -> None:
|
|
105
|
+
if not self._pending:
|
|
106
|
+
return
|
|
107
|
+
self.adjust(7, repeat=True)
|
|
108
|
+
|
|
109
|
+
def _append_row(self, row: Iterable[dict[str, Any]]) -> None:
|
|
110
|
+
normalized = [dict(item) for item in row]
|
|
111
|
+
if not normalized:
|
|
112
|
+
return
|
|
113
|
+
if len(normalized) > 7:
|
|
114
|
+
raise ValueError("В одном ряду MAX inline keyboard может быть не более 7 кнопок.")
|
|
115
|
+
if any(item.get("type") in SPECIAL_ROW_LIMIT_TYPES for item in normalized) and len(normalized) > 3:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"Ряд с кнопками link/open_app/request_contact/request_geo_location "
|
|
118
|
+
"может содержать не более 3 кнопок."
|
|
119
|
+
)
|
|
120
|
+
if len(self._rows) >= 30:
|
|
121
|
+
raise ValueError("MAX inline keyboard поддерживает не более 30 рядов.")
|
|
122
|
+
total_buttons = sum(len(existing_row) for existing_row in self._rows) + len(normalized)
|
|
123
|
+
if total_buttons > 210:
|
|
124
|
+
raise ValueError("MAX inline keyboard поддерживает не более 210 кнопок.")
|
|
125
|
+
self._rows.append(normalized)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalize_button(button: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
if not isinstance(button, dict):
|
|
130
|
+
raise TypeError(f"Кнопка должна быть dict, получено: {button!r}")
|
|
131
|
+
if "type" not in button or "text" not in button:
|
|
132
|
+
raise ValueError("Кнопка должна содержать поля 'type' и 'text'.")
|
|
133
|
+
return {key: value for key, value in button.items() if value is not None}
|
maxapi/builders/media.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_attachment(attachment_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
7
|
+
return {"type": attachment_type, "payload": payload}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def image_attachment(*, token: str | None = None, url: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
11
|
+
payload = _build_payload(token=token, url=url, extra=extra)
|
|
12
|
+
return make_attachment("image", payload)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def video_attachment(*, token: str | None = None, url: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
16
|
+
payload = _build_payload(token=token, url=url, extra=extra)
|
|
17
|
+
return make_attachment("video", payload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def audio_attachment(*, token: str | None = None, url: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
21
|
+
payload = _build_payload(token=token, url=url, extra=extra)
|
|
22
|
+
return make_attachment("audio", payload)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_attachment(*, token: str | None = None, url: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
26
|
+
payload = _build_payload(token=token, url=url, extra=extra)
|
|
27
|
+
return make_attachment("file", payload)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_attachment(item: Any) -> dict[str, Any]:
|
|
31
|
+
if item is None:
|
|
32
|
+
raise TypeError("Attachment не может быть None.")
|
|
33
|
+
if isinstance(item, dict):
|
|
34
|
+
return item
|
|
35
|
+
if hasattr(item, "as_attachment"):
|
|
36
|
+
return item.as_attachment()
|
|
37
|
+
if hasattr(item, "model_dump"):
|
|
38
|
+
return item.model_dump(by_alias=True, exclude_none=True)
|
|
39
|
+
raise TypeError(f"Неподдерживаемый attachment: {item!r}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_attachments(
|
|
43
|
+
attachments: Iterable[Any] | None = None,
|
|
44
|
+
*,
|
|
45
|
+
keyboard: Any | None = None,
|
|
46
|
+
) -> list[dict[str, Any]] | None:
|
|
47
|
+
normalized: list[dict[str, Any]] = []
|
|
48
|
+
if attachments is not None:
|
|
49
|
+
for item in attachments:
|
|
50
|
+
normalized.append(normalize_attachment(item))
|
|
51
|
+
if keyboard is not None:
|
|
52
|
+
normalized.append(normalize_attachment(keyboard))
|
|
53
|
+
if not normalized:
|
|
54
|
+
return None
|
|
55
|
+
return normalized
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_uploaded_attachment(
|
|
59
|
+
*,
|
|
60
|
+
upload_type: str,
|
|
61
|
+
upload_response_token: str | None,
|
|
62
|
+
uploaded_payload: dict[str, Any],
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
payload = dict(uploaded_payload)
|
|
65
|
+
if payload.get("token") is None and upload_response_token is not None:
|
|
66
|
+
payload["token"] = upload_response_token
|
|
67
|
+
return make_attachment(upload_type, payload)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_payload(
|
|
71
|
+
*,
|
|
72
|
+
token: str | None,
|
|
73
|
+
url: str | None,
|
|
74
|
+
extra: dict[str, Any],
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
payload = dict(extra)
|
|
77
|
+
if token is not None:
|
|
78
|
+
payload["token"] = token
|
|
79
|
+
if url is not None:
|
|
80
|
+
payload["url"] = url
|
|
81
|
+
return payload
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, ClassVar, TypeVar, get_origin
|
|
5
|
+
from urllib.parse import quote, unquote
|
|
6
|
+
|
|
7
|
+
from pydantic import TypeAdapter
|
|
8
|
+
|
|
9
|
+
from .types import ApiModel
|
|
10
|
+
|
|
11
|
+
PayloadSchemaType = TypeVar("PayloadSchemaType", bound="CallbackPayloadSchema")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CallbackPayloadError(ValueError):
|
|
15
|
+
"""Ошибка сериализации или десериализации callback payload."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CallbackPayloadSchema(ApiModel):
|
|
19
|
+
"""Структурированная схема callback payload.
|
|
20
|
+
|
|
21
|
+
Пример:
|
|
22
|
+
|
|
23
|
+
class AdminAction(CallbackPayloadSchema):
|
|
24
|
+
prefix = "admin"
|
|
25
|
+
action: str
|
|
26
|
+
user_id: int
|
|
27
|
+
|
|
28
|
+
payload = AdminAction(action="ban", user_id=42).pack()
|
|
29
|
+
parsed = AdminAction.unpack(payload)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
prefix: ClassVar[str] = ""
|
|
33
|
+
sep: ClassVar[str] = ":"
|
|
34
|
+
|
|
35
|
+
def pack(self) -> str:
|
|
36
|
+
field_names = list(type(self).model_fields)
|
|
37
|
+
values = [self.prefix or type(self).__name__.lower()]
|
|
38
|
+
for field_name in field_names:
|
|
39
|
+
values.append(_encode_component(getattr(self, field_name)))
|
|
40
|
+
return self.sep.join(values)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def unpack(cls, payload: Any) -> PayloadSchemaType:
|
|
44
|
+
payload_text = extract_callback_value(payload)
|
|
45
|
+
if payload_text is None:
|
|
46
|
+
raise CallbackPayloadError("Не удалось извлечь текстовый callback payload.")
|
|
47
|
+
parts = payload_text.split(cls.sep)
|
|
48
|
+
expected_prefix = cls.prefix or cls.__name__.lower()
|
|
49
|
+
if not parts or parts[0] != expected_prefix:
|
|
50
|
+
raise CallbackPayloadError(
|
|
51
|
+
f"Некорректный prefix callback payload: ожидался {expected_prefix!r}."
|
|
52
|
+
)
|
|
53
|
+
field_names = list(cls.model_fields)
|
|
54
|
+
raw_values = parts[1:]
|
|
55
|
+
if len(raw_values) != len(field_names):
|
|
56
|
+
raise CallbackPayloadError(
|
|
57
|
+
"Количество значений callback payload не совпадает с количеством полей схемы."
|
|
58
|
+
)
|
|
59
|
+
converted: dict[str, Any] = {}
|
|
60
|
+
for index, field_name in enumerate(field_names):
|
|
61
|
+
field_info = cls.model_fields[field_name]
|
|
62
|
+
converted[field_name] = _convert_component(
|
|
63
|
+
unquote(raw_values[index]),
|
|
64
|
+
field_info.annotation,
|
|
65
|
+
)
|
|
66
|
+
return cls(**converted)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def filter(cls, **conditions: Any):
|
|
70
|
+
return CallbackSchemaFilter(cls, **conditions)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CallbackSchemaFilter:
|
|
74
|
+
def __init__(self, schema: type[CallbackPayloadSchema], **conditions: Any) -> None:
|
|
75
|
+
self.schema = schema
|
|
76
|
+
self.conditions = conditions
|
|
77
|
+
|
|
78
|
+
async def __call__(self, event: Any) -> bool:
|
|
79
|
+
update = getattr(event, "update", None)
|
|
80
|
+
callback = getattr(update, "callback", None)
|
|
81
|
+
payload = getattr(callback, "payload", None)
|
|
82
|
+
try:
|
|
83
|
+
parsed = self.schema.unpack(payload)
|
|
84
|
+
except CallbackPayloadError:
|
|
85
|
+
return False
|
|
86
|
+
for key, expected_value in self.conditions.items():
|
|
87
|
+
if getattr(parsed, key, None) != expected_value:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_callback_value(payload: Any) -> str | None:
|
|
93
|
+
if payload is None:
|
|
94
|
+
return None
|
|
95
|
+
if isinstance(payload, str):
|
|
96
|
+
return payload
|
|
97
|
+
if isinstance(payload, dict):
|
|
98
|
+
for key in ("data", "payload", "value"):
|
|
99
|
+
item = payload.get(key)
|
|
100
|
+
if isinstance(item, str):
|
|
101
|
+
return item
|
|
102
|
+
return None
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_callback_mapping(payload: Any) -> dict[str, Any] | None:
|
|
107
|
+
if payload is None:
|
|
108
|
+
return None
|
|
109
|
+
if isinstance(payload, dict):
|
|
110
|
+
return dict(payload)
|
|
111
|
+
if isinstance(payload, str):
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(payload)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return None
|
|
116
|
+
if isinstance(parsed, dict):
|
|
117
|
+
return parsed
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _encode_component(value: Any) -> str:
|
|
122
|
+
if isinstance(value, (dict, list)):
|
|
123
|
+
serialized = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
124
|
+
else:
|
|
125
|
+
serialized = str(value)
|
|
126
|
+
return quote(serialized, safe="")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _convert_component(raw_value: str, annotation: Any) -> Any:
|
|
130
|
+
origin = get_origin(annotation)
|
|
131
|
+
candidate: Any = raw_value
|
|
132
|
+
if annotation is Any:
|
|
133
|
+
return raw_value
|
|
134
|
+
if annotation in {dict, list} or origin in {dict, list, tuple, set}:
|
|
135
|
+
try:
|
|
136
|
+
candidate = json.loads(raw_value)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
candidate = raw_value
|
|
139
|
+
adapter = TypeAdapter(annotation)
|
|
140
|
+
return adapter.validate_python(candidate)
|
maxapi/client/default.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aiohttp import ClientTimeout
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class DefaultConnectionProperties:
|
|
11
|
+
"""Расширенная конфигурация HTTP-соединения."""
|
|
12
|
+
|
|
13
|
+
timeout_seconds: float = 150.0
|
|
14
|
+
sock_connect: float = 30.0
|
|
15
|
+
sock_read: float = 60.0
|
|
16
|
+
connector_limit: int = 100
|
|
17
|
+
connector_limit_per_host: int = 30
|
|
18
|
+
trust_env: bool = False
|
|
19
|
+
auto_decompress: bool = True
|
|
20
|
+
raise_for_status: bool = False
|
|
21
|
+
request_retries: int = 5
|
|
22
|
+
request_retry_delay: float = 0.5
|
|
23
|
+
request_retry_backoff: float = 2.0
|
|
24
|
+
request_retry_max_delay: float = 8.0
|
|
25
|
+
respect_retry_after: bool = True
|
|
26
|
+
retry_statuses: tuple[int, ...] = (408, 425, 429, 500, 502, 503, 504)
|
|
27
|
+
retry_methods: tuple[str, ...] = (
|
|
28
|
+
"DELETE",
|
|
29
|
+
"GET",
|
|
30
|
+
"HEAD",
|
|
31
|
+
"OPTIONS",
|
|
32
|
+
"PATCH",
|
|
33
|
+
"POST",
|
|
34
|
+
"PUT",
|
|
35
|
+
)
|
|
36
|
+
timeout: ClientTimeout = field(init=False)
|
|
37
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
timeout: float = 150.0,
|
|
42
|
+
sock_connect: float = 30.0,
|
|
43
|
+
sock_read: float = 60.0,
|
|
44
|
+
connector_limit: int = 100,
|
|
45
|
+
connector_limit_per_host: int = 30,
|
|
46
|
+
trust_env: bool = False,
|
|
47
|
+
auto_decompress: bool = True,
|
|
48
|
+
raise_for_status: bool = False,
|
|
49
|
+
request_retries: int = 5,
|
|
50
|
+
request_retry_delay: float = 0.5,
|
|
51
|
+
request_retry_backoff: float = 2.0,
|
|
52
|
+
request_retry_max_delay: float = 8.0,
|
|
53
|
+
respect_retry_after: bool = True,
|
|
54
|
+
retry_statuses: tuple[int, ...] | None = None,
|
|
55
|
+
retry_methods: tuple[str, ...] | None = None,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.timeout_seconds = timeout
|
|
59
|
+
self.sock_connect = sock_connect
|
|
60
|
+
self.sock_read = sock_read
|
|
61
|
+
self.connector_limit = connector_limit
|
|
62
|
+
self.connector_limit_per_host = connector_limit_per_host
|
|
63
|
+
self.trust_env = trust_env
|
|
64
|
+
self.auto_decompress = auto_decompress
|
|
65
|
+
self.raise_for_status = raise_for_status
|
|
66
|
+
self.request_retries = request_retries
|
|
67
|
+
self.request_retry_delay = request_retry_delay
|
|
68
|
+
self.request_retry_backoff = request_retry_backoff
|
|
69
|
+
self.request_retry_max_delay = request_retry_max_delay
|
|
70
|
+
self.respect_retry_after = respect_retry_after
|
|
71
|
+
self.retry_statuses = retry_statuses or (408, 425, 429, 500, 502, 503, 504)
|
|
72
|
+
self.retry_methods = retry_methods or (
|
|
73
|
+
"DELETE",
|
|
74
|
+
"GET",
|
|
75
|
+
"HEAD",
|
|
76
|
+
"OPTIONS",
|
|
77
|
+
"PATCH",
|
|
78
|
+
"POST",
|
|
79
|
+
"PUT",
|
|
80
|
+
)
|
|
81
|
+
self.timeout = ClientTimeout(
|
|
82
|
+
total=timeout,
|
|
83
|
+
sock_connect=sock_connect,
|
|
84
|
+
sock_read=sock_read,
|
|
85
|
+
)
|
|
86
|
+
self.kwargs = dict(kwargs)
|
|
87
|
+
self.kwargs.setdefault("trust_env", trust_env)
|
|
88
|
+
self.kwargs.setdefault("auto_decompress", auto_decompress)
|
|
89
|
+
self.kwargs.setdefault("raise_for_status", raise_for_status)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..bot import Bot as LegacyBot
|
|
4
|
+
from ..builders import InlineKeyboardBuilder as Keyboard
|
|
5
|
+
from ..dispatcher import Dispatcher as LegacyDispatcher
|
|
6
|
+
from ..dispatcher import Router as LegacyRouter
|
|
7
|
+
from ..types import MessageBody as NewMessageBody
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Keyboard",
|
|
11
|
+
"LegacyBot",
|
|
12
|
+
"LegacyDispatcher",
|
|
13
|
+
"LegacyRouter",
|
|
14
|
+
"NewMessageBody",
|
|
15
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import aiofiles
|
|
9
|
+
from aiohttp import FormData
|
|
10
|
+
|
|
11
|
+
from ..transport import MaxApiTransport, TransportConfig
|
|
12
|
+
from ..types.bot_mixin import BotMixin
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from aiohttp import ClientSession
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseConnection(BotMixin):
|
|
19
|
+
"""Базовый класс transport-aware соединения."""
|
|
20
|
+
|
|
21
|
+
API_URL = "https://platform-api.max.ru"
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self.bot = None
|
|
25
|
+
self.session: ClientSession | None = None
|
|
26
|
+
self.api_url = self.API_URL
|
|
27
|
+
self._transport: MaxApiTransport | None = None
|
|
28
|
+
|
|
29
|
+
def set_api_url(self, url: str) -> None:
|
|
30
|
+
self.api_url = url.rstrip("/")
|
|
31
|
+
if self.bot is not None:
|
|
32
|
+
self.bot.api_url = self.api_url
|
|
33
|
+
self._transport = None
|
|
34
|
+
|
|
35
|
+
async def request(
|
|
36
|
+
self,
|
|
37
|
+
method: str,
|
|
38
|
+
path: str,
|
|
39
|
+
model: Any = None,
|
|
40
|
+
*,
|
|
41
|
+
is_return_raw: bool = False,
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> Any:
|
|
44
|
+
bot = self._ensure_bot()
|
|
45
|
+
transport = self._ensure_transport(bot)
|
|
46
|
+
response = await transport.request(method=method, path=path, model=model, **kwargs)
|
|
47
|
+
if is_return_raw:
|
|
48
|
+
return response.raw
|
|
49
|
+
parsed = response.parsed
|
|
50
|
+
self._bind_bot(bot=bot, payload=parsed)
|
|
51
|
+
return parsed
|
|
52
|
+
|
|
53
|
+
async def upload_file(self, url: str, path: str, upload_type: str) -> Any:
|
|
54
|
+
async with aiofiles.open(path, "rb") as file_object:
|
|
55
|
+
file_data = await file_object.read()
|
|
56
|
+
filename = Path(path).name
|
|
57
|
+
return await self.upload_file_buffer(
|
|
58
|
+
filename=filename,
|
|
59
|
+
url=url,
|
|
60
|
+
buffer=file_data,
|
|
61
|
+
upload_type=upload_type,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def upload_file_buffer(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
filename: str,
|
|
68
|
+
url: str,
|
|
69
|
+
buffer: bytes,
|
|
70
|
+
upload_type: str,
|
|
71
|
+
) -> Any:
|
|
72
|
+
ext = Path(filename).suffix
|
|
73
|
+
if not ext:
|
|
74
|
+
guessed_ext = mimetypes.guess_extension(f"{upload_type}/*") or ""
|
|
75
|
+
ext = guessed_ext
|
|
76
|
+
content_type = mimetypes.guess_type(filename)[0] or f"{upload_type}/*"
|
|
77
|
+
form = FormData(quote_fields=False)
|
|
78
|
+
form.add_field(
|
|
79
|
+
name="data",
|
|
80
|
+
value=buffer,
|
|
81
|
+
filename=f"{Path(filename).stem}{ext}",
|
|
82
|
+
content_type=content_type,
|
|
83
|
+
)
|
|
84
|
+
transport = self._ensure_transport(self._ensure_bot())
|
|
85
|
+
payload_text = await transport.upload(url=url, data=form)
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(payload_text)
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return {"text": payload_text}
|
|
90
|
+
|
|
91
|
+
def _ensure_transport(self, bot: Any) -> MaxApiTransport:
|
|
92
|
+
if self._transport is not None:
|
|
93
|
+
return self._transport
|
|
94
|
+
config = TransportConfig.from_default_connection(bot.default_connection)
|
|
95
|
+
config.session_kwargs.setdefault(
|
|
96
|
+
"connector_limit",
|
|
97
|
+
getattr(bot.default_connection, "connector_limit", 100),
|
|
98
|
+
)
|
|
99
|
+
config.session_kwargs.setdefault(
|
|
100
|
+
"connector_limit_per_host",
|
|
101
|
+
getattr(bot.default_connection, "connector_limit_per_host", 30),
|
|
102
|
+
)
|
|
103
|
+
self._transport = MaxApiTransport(
|
|
104
|
+
base_url=bot.api_url,
|
|
105
|
+
headers=dict(getattr(bot, "headers", {})),
|
|
106
|
+
config=config,
|
|
107
|
+
session=getattr(bot, "session", None),
|
|
108
|
+
)
|
|
109
|
+
if self._transport.session is not None:
|
|
110
|
+
bot.session = self._transport.session
|
|
111
|
+
self.session = self._transport.session
|
|
112
|
+
return self._transport
|
|
113
|
+
|
|
114
|
+
def _bind_bot(self, *, bot: Any, payload: Any) -> None:
|
|
115
|
+
if payload is None:
|
|
116
|
+
return
|
|
117
|
+
if hasattr(payload, "bind_bot"):
|
|
118
|
+
payload.bind_bot(bot)
|
|
119
|
+
if hasattr(payload, "message") and getattr(payload, "message") is not None:
|
|
120
|
+
message = getattr(payload, "message")
|
|
121
|
+
if hasattr(message, "bind_bot"):
|
|
122
|
+
message.bind_bot(bot)
|
|
123
|
+
if hasattr(payload, "messages"):
|
|
124
|
+
for message in getattr(payload, "messages") or []:
|
|
125
|
+
if hasattr(message, "bind_bot"):
|
|
126
|
+
message.bind_bot(bot)
|
|
127
|
+
if hasattr(payload, "updates"):
|
|
128
|
+
for update in getattr(payload, "updates") or []:
|
|
129
|
+
message = getattr(update, "message", None)
|
|
130
|
+
if message is not None and hasattr(message, "bind_bot"):
|
|
131
|
+
message.bind_bot(bot)
|
|
132
|
+
callback = getattr(update, "callback", None)
|
|
133
|
+
if callback is not None and getattr(callback, "message", None) is not None:
|
|
134
|
+
callback_message = callback.message
|
|
135
|
+
if hasattr(callback_message, "bind_bot"):
|
|
136
|
+
callback_message.bind_bot(bot)
|