maxapi-python 1.1.15__py3-none-any.whl → 1.1.16__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_python-1.1.15.dist-info → maxapi_python-1.1.16.dist-info}/METADATA +7 -3
- maxapi_python-1.1.16.dist-info/RECORD +31 -0
- pymax/__init__.py +48 -0
- pymax/core.py +86 -9
- pymax/exceptions.py +51 -9
- pymax/files.py +12 -4
- pymax/formatter.py +30 -0
- pymax/interfaces.py +13 -5
- pymax/mixins/auth.py +12 -7
- pymax/mixins/channel.py +31 -13
- pymax/mixins/group.py +159 -209
- pymax/mixins/handler.py +12 -8
- pymax/mixins/message.py +293 -310
- pymax/mixins/self.py +7 -6
- pymax/mixins/socket.py +59 -82
- pymax/mixins/telemetry.py +7 -12
- pymax/mixins/user.py +109 -64
- pymax/mixins/utils.py +27 -0
- pymax/mixins/websocket.py +49 -27
- pymax/payloads.py +7 -2
- pymax/static/enum.py +17 -12
- pymax/types.py +42 -35
- maxapi_python-1.1.15.dist-info/RECORD +0 -30
- pymax/utils.py +0 -46
- {maxapi_python-1.1.15.dist-info → maxapi_python-1.1.16.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.15.dist-info → maxapi_python-1.1.16.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.16
|
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
|
5
5
|
Project-URL: Homepage, https://github.com/ink-developer/PyMax
|
|
6
6
|
Project-URL: Repository, https://github.com/ink-developer/PyMax
|
|
@@ -155,10 +155,14 @@ if __name__ == "__main__":
|
|
|
155
155
|
|
|
156
156
|
[Telegram](https://t.me/pymax_news)
|
|
157
157
|
|
|
158
|
-
##
|
|
158
|
+
## Star History
|
|
159
|
+
|
|
160
|
+
[](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left)
|
|
159
161
|
|
|
160
|
-
|
|
162
|
+
## Авторы
|
|
161
163
|
- **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
|
|
164
|
+
- **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
|
|
165
|
+
|
|
162
166
|
|
|
163
167
|
## Контрибьюторы
|
|
164
168
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
pymax/__init__.py,sha256=vsdE56xHZnvDCXqLwSJL3v9MxsXcmmxE9p0QSHG66HA,1846
|
|
2
|
+
pymax/core.py,sha256=EKgMlOnqvsPJwOQeCgQE1tnKk4E8kMnmxCAfo4jrP_k,11083
|
|
3
|
+
pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
|
|
4
|
+
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
|
+
pymax/files.py,sha256=dRuOpvoJZWiH4xa_HVGyqQ-_Zzj-sVikElHmrPjwgs0,3166
|
|
6
|
+
pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
|
|
7
|
+
pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
|
|
8
|
+
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
9
|
+
pymax/interfaces.py,sha256=NspCm8asUhG_LHo-CTseGMg-zKAHU5H4bl6BMEwqAK0,3416
|
|
10
|
+
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
|
+
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
+
pymax/payloads.py,sha256=qaafULDGBXsQ7gNFC374wZVUwN5tzJLHwkxtAmglOzU,6292
|
|
13
|
+
pymax/types.py,sha256=tiSU_YpvOlx710SYQbYLNw_SjwefgqOu9Xk5ev_PbFU,30931
|
|
14
|
+
pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
|
|
15
|
+
pymax/mixins/auth.py,sha256=H4Zp3n8cwpv4Q3Mn1_Kb7Oh9DbTL7T9GcWJ6R1JN7ls,6672
|
|
16
|
+
pymax/mixins/channel.py,sha256=dMuJRnbqZisN8kcPFCCe1sIOOBQl2uT4P49PpZXcoKE,5206
|
|
17
|
+
pymax/mixins/group.py,sha256=7oa7RpiqnlcnAsnIHOfSiujNYAzUZ9lkTy9NGW5KVUE,8654
|
|
18
|
+
pymax/mixins/handler.py,sha256=AhxIRvwftkuWN435_CXede2ZVWrDde4zkMPZtwIm5IU,3892
|
|
19
|
+
pymax/mixins/message.py,sha256=ezU9d6r4MkYjH67gZ9SFLYPKqo4Nb6lswqDsEW5p-Bg,22329
|
|
20
|
+
pymax/mixins/self.py,sha256=tDQrUdUpsCu7qGkWLtKxTfTHPHU5_r3qsn-eptHG2KY,1198
|
|
21
|
+
pymax/mixins/socket.py,sha256=j6XTo_M3rNw-az2PfSW6oJ_YHg9M7cWARY4cXpMllDY,22256
|
|
22
|
+
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
23
|
+
pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
|
|
24
|
+
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
25
|
+
pymax/mixins/websocket.py,sha256=gqGP-3XPrbo4DPqUL4H8tuOAjZQ4QKbBvJGxOFqwk9E,17254
|
|
26
|
+
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
27
|
+
pymax/static/enum.py,sha256=ofqxOsRzi6XvZN_UOPinxug1uPEulJsQ95MWifAfCqA,4562
|
|
28
|
+
maxapi_python-1.1.16.dist-info/METADATA,sha256=OLNhs5zuW9ux6C_57MVhTscaZknfVXh90OYf5P2_X8A,6245
|
|
29
|
+
maxapi_python-1.1.16.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
30
|
+
maxapi_python-1.1.16.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
31
|
+
maxapi_python-1.1.16.dist-info/RECORD,,
|
pymax/__init__.py
CHANGED
|
@@ -9,14 +9,22 @@ from .core import (
|
|
|
9
9
|
from .exceptions import (
|
|
10
10
|
InvalidPhoneError,
|
|
11
11
|
LoginError,
|
|
12
|
+
ResponseError,
|
|
13
|
+
ResponseStructureError,
|
|
14
|
+
SocketNotConnectedError,
|
|
15
|
+
SocketSendError,
|
|
12
16
|
WebSocketNotConnectedError,
|
|
13
17
|
)
|
|
14
18
|
from .static.enum import (
|
|
15
19
|
AccessType,
|
|
20
|
+
AttachType,
|
|
16
21
|
AuthType,
|
|
17
22
|
ChatType,
|
|
23
|
+
ContactAction,
|
|
18
24
|
DeviceType,
|
|
19
25
|
ElementType,
|
|
26
|
+
FormattingType,
|
|
27
|
+
MarkupType,
|
|
20
28
|
MessageStatus,
|
|
21
29
|
MessageType,
|
|
22
30
|
Opcode,
|
|
@@ -24,10 +32,26 @@ from .static.enum import (
|
|
|
24
32
|
from .types import (
|
|
25
33
|
Channel,
|
|
26
34
|
Chat,
|
|
35
|
+
Contact,
|
|
36
|
+
ControlAttach,
|
|
27
37
|
Dialog,
|
|
28
38
|
Element,
|
|
39
|
+
FileAttach,
|
|
40
|
+
FileRequest,
|
|
41
|
+
Me,
|
|
42
|
+
Member,
|
|
29
43
|
Message,
|
|
44
|
+
MessageLink,
|
|
45
|
+
Name,
|
|
46
|
+
Names,
|
|
47
|
+
PhotoAttach,
|
|
48
|
+
Presence,
|
|
49
|
+
ReactionCounter,
|
|
50
|
+
ReactionInfo,
|
|
51
|
+
Session,
|
|
30
52
|
User,
|
|
53
|
+
VideoAttach,
|
|
54
|
+
VideoRequest,
|
|
31
55
|
)
|
|
32
56
|
|
|
33
57
|
__author__ = "ink-developer"
|
|
@@ -35,19 +59,43 @@ __author__ = "ink-developer"
|
|
|
35
59
|
__all__ = [
|
|
36
60
|
# Перечисления и константы
|
|
37
61
|
"AccessType",
|
|
62
|
+
"AttachType",
|
|
38
63
|
"AuthType",
|
|
64
|
+
"ContactAction",
|
|
65
|
+
"FormattingType",
|
|
66
|
+
"MarkupType",
|
|
39
67
|
# Типы данных
|
|
40
68
|
"Channel",
|
|
41
69
|
"Chat",
|
|
42
70
|
"ChatType",
|
|
71
|
+
"Contact",
|
|
72
|
+
"ControlAttach",
|
|
43
73
|
"DeviceType",
|
|
44
74
|
"Dialog",
|
|
45
75
|
"Element",
|
|
46
76
|
"ElementType",
|
|
77
|
+
"FileAttach",
|
|
78
|
+
"FileRequest",
|
|
79
|
+
"Me",
|
|
80
|
+
"Member",
|
|
81
|
+
"MessageLink",
|
|
82
|
+
"Name",
|
|
83
|
+
"Names",
|
|
84
|
+
"PhotoAttach",
|
|
85
|
+
"Presence",
|
|
86
|
+
"ReactionCounter",
|
|
87
|
+
"ReactionInfo",
|
|
88
|
+
"Session",
|
|
89
|
+
"VideoAttach",
|
|
90
|
+
"VideoRequest",
|
|
47
91
|
# Исключения
|
|
48
92
|
"InvalidPhoneError",
|
|
49
93
|
"LoginError",
|
|
50
94
|
"WebSocketNotConnectedError",
|
|
95
|
+
"ResponseError",
|
|
96
|
+
"ResponseStructureError",
|
|
97
|
+
"SocketNotConnectedError",
|
|
98
|
+
"SocketSendError",
|
|
51
99
|
# Клиент
|
|
52
100
|
"MaxClient",
|
|
53
101
|
"Message",
|
pymax/core.py
CHANGED
|
@@ -3,13 +3,16 @@ import logging
|
|
|
3
3
|
import socket
|
|
4
4
|
import ssl
|
|
5
5
|
import time
|
|
6
|
+
import traceback
|
|
7
|
+
from collections.abc import Awaitable
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Literal
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
10
|
|
|
9
11
|
from typing_extensions import override
|
|
10
12
|
|
|
11
13
|
from .crud import Database
|
|
12
14
|
from .exceptions import InvalidPhoneError
|
|
15
|
+
from .formatter import ColoredFormatter
|
|
13
16
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
14
17
|
from .payloads import UserAgentPayload
|
|
15
18
|
from .static.constant import (
|
|
@@ -18,6 +21,16 @@ from .static.constant import (
|
|
|
18
21
|
WEBSOCKET_URI,
|
|
19
22
|
)
|
|
20
23
|
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import websockets
|
|
29
|
+
|
|
30
|
+
from .filters import Filter
|
|
31
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
32
|
+
|
|
33
|
+
|
|
21
34
|
logger = logging.getLogger(__name__)
|
|
22
35
|
|
|
23
36
|
|
|
@@ -64,9 +77,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
64
77
|
last_name: str | None = None,
|
|
65
78
|
logger: logging.Logger | None = None,
|
|
66
79
|
) -> None:
|
|
67
|
-
logger = logger or logging.getLogger(f"{__name__}
|
|
68
|
-
ApiMixin.__init__(self, token=token, logger=logger)
|
|
69
|
-
WebSocketMixin.__init__(self, token=token, logger=logger)
|
|
80
|
+
self.logger = logger or logging.getLogger(f"{__name__}")
|
|
70
81
|
self.uri: str = uri
|
|
71
82
|
self.phone: str = phone
|
|
72
83
|
if not self._check_phone():
|
|
@@ -77,23 +88,55 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
77
88
|
self.first_name: str = first_name
|
|
78
89
|
self.last_name: str | None = last_name
|
|
79
90
|
self.proxy: str | Literal[True] | None = proxy
|
|
91
|
+
|
|
92
|
+
self.is_connected: bool = False
|
|
93
|
+
|
|
94
|
+
self.chats: list[Chat] = []
|
|
95
|
+
self.dialogs: list[Dialog] = []
|
|
96
|
+
self.channels: list[Channel] = []
|
|
97
|
+
self.me: Me | None = None
|
|
98
|
+
self._users: dict[int, User] = {}
|
|
99
|
+
|
|
80
100
|
self._work_dir: str = work_dir
|
|
81
101
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
82
102
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
103
|
self._database_path.touch(exist_ok=True)
|
|
84
104
|
self._database = Database(self._work_dir)
|
|
105
|
+
|
|
106
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
85
107
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
108
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
86
109
|
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
110
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
111
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
112
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
113
|
+
|
|
114
|
+
self._seq: int = 0
|
|
87
115
|
self._error_count: int = 0
|
|
88
116
|
self._circuit_breaker: bool = False
|
|
89
117
|
self._last_error_time: float = 0.0
|
|
118
|
+
|
|
90
119
|
self._device_id = self._database.get_device_id()
|
|
91
120
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
121
|
+
|
|
92
122
|
self._token = self._database.get_auth_token() or token
|
|
93
123
|
self.user_agent = headers
|
|
94
124
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
95
125
|
self._session_id: int = int(time.time() * 1000)
|
|
96
126
|
self._action_id: int = 1
|
|
127
|
+
self._current_screen: str = "chats_list_tab"
|
|
128
|
+
|
|
129
|
+
self._on_message_handlers: list[
|
|
130
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
131
|
+
] = []
|
|
132
|
+
self._on_message_edit_handlers: list[
|
|
133
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
134
|
+
] = []
|
|
135
|
+
self._on_message_delete_handlers: list[
|
|
136
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
137
|
+
] = []
|
|
138
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
139
|
+
|
|
97
140
|
self._ssl_context = ssl.create_default_context()
|
|
98
141
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
99
142
|
self._ssl_context.check_hostname = True
|
|
@@ -101,6 +144,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
101
144
|
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
102
145
|
self._ssl_context.load_default_certs()
|
|
103
146
|
self._socket: socket.socket | None = None
|
|
147
|
+
self._ws: websockets.ClientConnection | None = None
|
|
148
|
+
|
|
104
149
|
self._setup_logger()
|
|
105
150
|
self.logger.debug(
|
|
106
151
|
"Initialized MaxClient uri=%s work_dir=%s",
|
|
@@ -109,13 +154,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
109
154
|
)
|
|
110
155
|
|
|
111
156
|
def _setup_logger(self) -> None:
|
|
112
|
-
if not logger.handlers:
|
|
157
|
+
if not self.logger.handlers:
|
|
158
|
+
if not self.logger.level:
|
|
159
|
+
self.logger.setLevel(logging.INFO)
|
|
113
160
|
handler = logging.StreamHandler()
|
|
114
|
-
formatter =
|
|
115
|
-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
161
|
+
formatter = ColoredFormatter(
|
|
162
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
163
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
116
164
|
)
|
|
117
165
|
handler.setFormatter(formatter)
|
|
118
|
-
logger.addHandler(handler)
|
|
166
|
+
self.logger.addHandler(handler)
|
|
119
167
|
|
|
120
168
|
async def _wait_forever(self):
|
|
121
169
|
try:
|
|
@@ -123,6 +171,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
123
171
|
except asyncio.CancelledError:
|
|
124
172
|
self.logger.debug("wait_closed cancelled")
|
|
125
173
|
|
|
174
|
+
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
175
|
+
"""
|
|
176
|
+
Безопасно выполняет пользовательскую корутину.
|
|
177
|
+
Логирует traceback, но не роняет event loop.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
return await coro
|
|
181
|
+
except Exception as e:
|
|
182
|
+
self.logger.error(
|
|
183
|
+
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
184
|
+
)
|
|
185
|
+
|
|
126
186
|
async def close(self) -> None:
|
|
127
187
|
try:
|
|
128
188
|
self.logger.info("Closing client")
|
|
@@ -145,6 +205,23 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
145
205
|
except Exception:
|
|
146
206
|
self.logger.exception("Error closing client")
|
|
147
207
|
|
|
208
|
+
def _create_safe_task(self, coro: Awaitable[Any], *, name: str | None = None):
|
|
209
|
+
async def runner():
|
|
210
|
+
try:
|
|
211
|
+
return await coro
|
|
212
|
+
except asyncio.CancelledError:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
self.logger.error(
|
|
216
|
+
f"Unhandled exception in task {name or coro}: {e}",
|
|
217
|
+
exc_info=e,
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
task = asyncio.create_task(runner(), name=name)
|
|
222
|
+
self._background_tasks.add(task)
|
|
223
|
+
return task
|
|
224
|
+
|
|
148
225
|
async def start(self) -> None:
|
|
149
226
|
"""
|
|
150
227
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
@@ -171,7 +248,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
171
248
|
self.logger.debug("Calling on_start handler")
|
|
172
249
|
result = self._on_start_handler()
|
|
173
250
|
if asyncio.iscoroutine(result):
|
|
174
|
-
await result
|
|
251
|
+
await self._safe_execute(result, context="on_start handler")
|
|
175
252
|
|
|
176
253
|
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
177
254
|
ping_task.add_done_callback(self._log_task_exception)
|
pymax/exceptions.py
CHANGED
|
@@ -39,15 +39,6 @@ class SocketSendError(Exception):
|
|
|
39
39
|
super().__init__("Send and wait failed (socket)")
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
class LoginError(Exception):
|
|
43
|
-
"""
|
|
44
|
-
Исключение, вызываемое при ошибке авторизации.
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
def __init__(self, message: str) -> None:
|
|
48
|
-
super().__init__(f"Login error: {message}")
|
|
49
|
-
|
|
50
|
-
|
|
51
42
|
class ResponseError(Exception):
|
|
52
43
|
"""
|
|
53
44
|
Исключение, вызываемое при ошибке в ответе от сервера.
|
|
@@ -64,3 +55,54 @@ class ResponseStructureError(Exception):
|
|
|
64
55
|
|
|
65
56
|
def __init__(self, message: str) -> None:
|
|
66
57
|
super().__init__(f"Response structure error: {message}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Error(Exception):
|
|
61
|
+
"""
|
|
62
|
+
Базовое исключение для ошибок PyMax.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
error: str,
|
|
68
|
+
message: str,
|
|
69
|
+
title: str,
|
|
70
|
+
localized_message: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.error = error
|
|
73
|
+
self.message = message
|
|
74
|
+
self.title = title
|
|
75
|
+
self.localized_message = localized_message
|
|
76
|
+
|
|
77
|
+
parts = []
|
|
78
|
+
if localized_message:
|
|
79
|
+
parts.append(localized_message)
|
|
80
|
+
if message:
|
|
81
|
+
parts.append(message)
|
|
82
|
+
if title:
|
|
83
|
+
parts.append(f"({title})")
|
|
84
|
+
parts.append(f"[{error}]")
|
|
85
|
+
|
|
86
|
+
super().__init__("PyMax Error: " + " ".join(parts))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RateLimitError(Error):
|
|
90
|
+
"""
|
|
91
|
+
Исключение, вызываемое при превышении лимита запросов.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self, error: str, message: str, title: str, localized_message: str | None = None
|
|
96
|
+
) -> None:
|
|
97
|
+
super().__init__(error, message, title, localized_message)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LoginError(Error):
|
|
101
|
+
"""
|
|
102
|
+
Исключение, вызываемое при ошибке авторизации.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self, error: str, message: str, title: str, localized_message: str | None = None
|
|
107
|
+
) -> None:
|
|
108
|
+
super().__init__(error, message, title, localized_message)
|
pymax/files.py
CHANGED
|
@@ -9,7 +9,9 @@ from typing_extensions import override
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class BaseFile(ABC):
|
|
12
|
-
def __init__(
|
|
12
|
+
def __init__(
|
|
13
|
+
self, url: str | None = None, path: str | None = None
|
|
14
|
+
) -> None:
|
|
13
15
|
self.url = url
|
|
14
16
|
self.path = path
|
|
15
17
|
|
|
@@ -45,7 +47,9 @@ class Photo(BaseFile):
|
|
|
45
47
|
".bmp",
|
|
46
48
|
} # FIXME: костыль ✅
|
|
47
49
|
|
|
48
|
-
def __init__(
|
|
50
|
+
def __init__(
|
|
51
|
+
self, url: str | None = None, path: str | None = None
|
|
52
|
+
) -> None:
|
|
49
53
|
super().__init__(url, path)
|
|
50
54
|
|
|
51
55
|
def validate_photo(self) -> tuple[str, str] | None:
|
|
@@ -67,7 +71,9 @@ class Photo(BaseFile):
|
|
|
67
71
|
mime_type = mimetypes.guess_type(self.url)[0]
|
|
68
72
|
|
|
69
73
|
if not mime_type or not mime_type.startswith("image/"):
|
|
70
|
-
raise ValueError(
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"URL does not appear to be an image: {self.url}"
|
|
76
|
+
)
|
|
71
77
|
|
|
72
78
|
return (extension[1:], mime_type)
|
|
73
79
|
return None
|
|
@@ -84,7 +90,9 @@ class Video(BaseFile):
|
|
|
84
90
|
|
|
85
91
|
|
|
86
92
|
class File(BaseFile):
|
|
87
|
-
def __init__(
|
|
93
|
+
def __init__(
|
|
94
|
+
self, url: str | None = None, path: str | None = None
|
|
95
|
+
) -> None:
|
|
88
96
|
self.file_name: str = ""
|
|
89
97
|
if path:
|
|
90
98
|
self.file_name = Path(path).name
|
pymax/formatter.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ColoredFormatter(logging.Formatter):
|
|
6
|
+
COLORS: ClassVar = {
|
|
7
|
+
"DEBUG": "\033[37m",
|
|
8
|
+
"INFO": "\033[36m",
|
|
9
|
+
"WARNING": "\033[33m",
|
|
10
|
+
"ERROR": "\033[31m",
|
|
11
|
+
"CRITICAL": "\033[41m",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
RESET = "\033[0m"
|
|
15
|
+
DIM = "\033[2m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
|
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
19
|
+
level_color = self.COLORS.get(record.levelname, self.RESET)
|
|
20
|
+
time_color = self.DIM
|
|
21
|
+
name_color = "\033[35m"
|
|
22
|
+
message_color = self.RESET
|
|
23
|
+
|
|
24
|
+
log = (
|
|
25
|
+
f"{time_color}{self.formatTime(record, '%H:%M:%S')}{self.RESET} "
|
|
26
|
+
f"[{level_color}{record.levelname}{self.RESET}] "
|
|
27
|
+
f"{name_color}{record.name}{self.RESET}: "
|
|
28
|
+
f"{message_color}{record.getMessage()}{self.RESET}"
|
|
29
|
+
)
|
|
30
|
+
return log
|
pymax/interfaces.py
CHANGED
|
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
9
9
|
|
|
10
10
|
import websockets
|
|
11
11
|
|
|
12
|
-
from pymax.static.constant import DEFAULT_USER_AGENT
|
|
13
|
-
|
|
14
12
|
from .filters import Filter
|
|
15
13
|
from .payloads import UserAgentPayload
|
|
16
14
|
from .static.constant import DEFAULT_TIMEOUT
|
|
@@ -43,6 +41,7 @@ class ClientProtocol(ABC):
|
|
|
43
41
|
self.registration: bool
|
|
44
42
|
self.first_name: str
|
|
45
43
|
self.last_name: str | None
|
|
44
|
+
self._token: str | None
|
|
46
45
|
self._work_dir: str
|
|
47
46
|
self._database_path: Path
|
|
48
47
|
self._ws: websockets.ClientConnection | None = None
|
|
@@ -50,14 +49,15 @@ class ClientProtocol(ABC):
|
|
|
50
49
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
51
50
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
52
51
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
53
|
-
self._file_upload_waiters: dict[
|
|
52
|
+
self._file_upload_waiters: dict[
|
|
53
|
+
int, asyncio.Future[dict[str, Any]]
|
|
54
|
+
] = {}
|
|
54
55
|
self.user_agent = UserAgentPayload()
|
|
55
56
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
56
57
|
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
57
58
|
self._error_count: int = 0
|
|
58
59
|
self._circuit_breaker: bool = False
|
|
59
60
|
self._last_error_time: float = 0.0
|
|
60
|
-
self.user_agent = DEFAULT_USER_AGENT
|
|
61
61
|
self._session_id: int
|
|
62
62
|
self._action_id: int = 0
|
|
63
63
|
self._current_screen: str = "chats_list_tab"
|
|
@@ -70,7 +70,9 @@ class ClientProtocol(ABC):
|
|
|
70
70
|
self._on_message_delete_handlers: list[
|
|
71
71
|
tuple[Callable[[Message], Any], Filter | None]
|
|
72
72
|
] = []
|
|
73
|
-
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None =
|
|
73
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = (
|
|
74
|
+
None
|
|
75
|
+
)
|
|
74
76
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
75
77
|
self._ssl_context: ssl.SSLContext
|
|
76
78
|
self._socket: socket.socket | None = None
|
|
@@ -99,3 +101,9 @@ class ClientProtocol(ABC):
|
|
|
99
101
|
max_retries: int = 3,
|
|
100
102
|
) -> Message | None:
|
|
101
103
|
pass
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def _create_safe_task(
|
|
107
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
108
|
+
) -> asyncio.Task[Any]:
|
|
109
|
+
pass
|
pymax/mixins/auth.py
CHANGED
|
@@ -3,17 +3,15 @@ import re
|
|
|
3
3
|
import sys
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
+
from pymax.exceptions import Error
|
|
6
7
|
from pymax.interfaces import ClientProtocol
|
|
8
|
+
from pymax.mixins.utils import MixinsUtils
|
|
7
9
|
from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
|
|
8
10
|
from pymax.static.constant import PHONE_REGEX
|
|
9
11
|
from pymax.static.enum import AuthType, Opcode
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class AuthMixin(ClientProtocol):
|
|
13
|
-
def __init__(self, token: str | None = None, *args, **kwargs) -> None:
|
|
14
|
-
super().__init__(*args, **kwargs)
|
|
15
|
-
self._token = token
|
|
16
|
-
|
|
17
15
|
def _check_phone(self) -> bool:
|
|
18
16
|
return bool(re.match(PHONE_REGEX, self.phone))
|
|
19
17
|
|
|
@@ -30,6 +28,9 @@ class AuthMixin(ClientProtocol):
|
|
|
30
28
|
data = await self._send_and_wait(
|
|
31
29
|
opcode=Opcode.AUTH_REQUEST, payload=payload
|
|
32
30
|
)
|
|
31
|
+
if data.get("payload", {}).get("error"):
|
|
32
|
+
MixinsUtils.handle_error(data)
|
|
33
|
+
|
|
33
34
|
self.logger.debug(
|
|
34
35
|
"Code request response opcode=%s seq=%s",
|
|
35
36
|
data.get("opcode"),
|
|
@@ -56,6 +57,9 @@ class AuthMixin(ClientProtocol):
|
|
|
56
57
|
).model_dump(by_alias=True)
|
|
57
58
|
|
|
58
59
|
data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
|
|
60
|
+
if data.get("payload", {}).get("error"):
|
|
61
|
+
MixinsUtils.handle_error(data)
|
|
62
|
+
|
|
59
63
|
self.logger.debug(
|
|
60
64
|
"Send code response opcode=%s seq=%s",
|
|
61
65
|
data.get("opcode"),
|
|
@@ -113,6 +117,9 @@ class AuthMixin(ClientProtocol):
|
|
|
113
117
|
data = await self._send_and_wait(
|
|
114
118
|
opcode=Opcode.AUTH_CONFIRM, payload=payload
|
|
115
119
|
)
|
|
120
|
+
if data.get("payload", {}).get("error"):
|
|
121
|
+
MixinsUtils.handle_error(data)
|
|
122
|
+
|
|
116
123
|
self.logger.debug(
|
|
117
124
|
"Registration info response opcode=%s seq=%s",
|
|
118
125
|
data.get("opcode"),
|
|
@@ -121,9 +128,7 @@ class AuthMixin(ClientProtocol):
|
|
|
121
128
|
payload_data = data.get("payload")
|
|
122
129
|
if isinstance(payload_data, dict):
|
|
123
130
|
return payload_data
|
|
124
|
-
|
|
125
|
-
self.logger.error("Invalid payload data received")
|
|
126
|
-
raise ValueError("Invalid payload data received")
|
|
131
|
+
raise ValueError("Invalid payload data received")
|
|
127
132
|
except Exception:
|
|
128
133
|
self.logger.error("Submit registration info failed", exc_info=True)
|
|
129
134
|
raise RuntimeError("Submit registration info failed")
|
pymax/mixins/channel.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
from pymax.exceptions import ResponseError, ResponseStructureError
|
|
1
|
+
from pymax.exceptions import Error, ResponseError, ResponseStructureError
|
|
2
2
|
from pymax.interfaces import ClientProtocol
|
|
3
|
+
from pymax.mixins.utils import MixinsUtils
|
|
3
4
|
from pymax.payloads import (
|
|
4
5
|
GetGroupMembersPayload,
|
|
6
|
+
JoinChatPayload,
|
|
5
7
|
ResolveLinkPayload,
|
|
6
8
|
SearchGroupMembersPayload,
|
|
7
9
|
)
|
|
@@ -32,12 +34,32 @@ class ChannelMixin(ClientProtocol):
|
|
|
32
34
|
link=f"https://max.ru/{name}",
|
|
33
35
|
).model_dump(by_alias=True)
|
|
34
36
|
|
|
35
|
-
data = await self._send_and_wait(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
data = await self._send_and_wait(opcode=Opcode.LINK_INFO, payload=payload)
|
|
38
|
+
if data.get("payload", {}).get("error"):
|
|
39
|
+
MixinsUtils.handle_error(data)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
async def join_channel(self, link: str) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Присоединяется к каналу по ссылке
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
link (str): Ссылка на канал
|
|
48
|
+
|
|
49
|
+
Exceptions:
|
|
50
|
+
ResponseError: Ошибка в ответе сервера
|
|
51
|
+
ResponseStructureError: Ошибка структуры ответа сервера
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
bool: True, если присоединение прошло успешно
|
|
55
|
+
"""
|
|
56
|
+
payload = JoinChatPayload(
|
|
57
|
+
link=link,
|
|
58
|
+
).model_dump(by_alias=True)
|
|
59
|
+
|
|
60
|
+
data = await self._send_and_wait(opcode=Opcode.CHAT_JOIN, payload=payload)
|
|
61
|
+
if data.get("payload", {}).get("error"):
|
|
62
|
+
MixinsUtils.handle_error(data)
|
|
41
63
|
return True
|
|
42
64
|
|
|
43
65
|
async def _query_members(
|
|
@@ -65,9 +87,7 @@ class ChannelMixin(ClientProtocol):
|
|
|
65
87
|
if isinstance(members, list):
|
|
66
88
|
for item in members:
|
|
67
89
|
if not isinstance(item, dict):
|
|
68
|
-
raise ResponseStructureError(
|
|
69
|
-
"Invalid member structure in response"
|
|
70
|
-
)
|
|
90
|
+
raise ResponseStructureError("Invalid member structure in response")
|
|
71
91
|
member_list.append(Member.from_dict(item))
|
|
72
92
|
else:
|
|
73
93
|
raise ResponseStructureError("Invalid members type in response")
|
|
@@ -91,9 +111,7 @@ class ChannelMixin(ClientProtocol):
|
|
|
91
111
|
Returns:
|
|
92
112
|
list[Member]: Список участников канала
|
|
93
113
|
"""
|
|
94
|
-
payload = GetGroupMembersPayload(
|
|
95
|
-
chat_id=chat_id, marker=marker, count=count
|
|
96
|
-
)
|
|
114
|
+
payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count)
|
|
97
115
|
return await self._query_members(payload)
|
|
98
116
|
|
|
99
117
|
async def find_members(
|