maxapi-python 1.1.15__py3-none-any.whl → 1.1.17__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.17.dist-info}/METADATA +7 -3
- maxapi_python-1.1.17.dist-info/RECORD +31 -0
- pymax/__init__.py +55 -1
- pymax/core.py +187 -42
- pymax/exceptions.py +51 -9
- pymax/files.py +12 -4
- pymax/formatter.py +30 -0
- pymax/interfaces.py +8 -3
- 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 +19 -14
- pymax/payloads.py +7 -2
- pymax/static/enum.py +18 -12
- pymax/types.py +56 -22
- 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.17.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.15.dist-info → maxapi_python-1.1.17.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.17
|
|
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=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
+
pymax/core.py,sha256=uSmIYVBqiO-3v0wtehObnvOEJ3n_dpG62TXTKwmdFf4,13838
|
|
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=WWKNGT725GXuYneS9gCOAC6RNtySRs-BTU0fQLyh2OQ,3399
|
|
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=RaLn9bUpkxO0SKbDMIHnoFeqV6gqOl2pKDNCa2LxTRI,32102
|
|
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=LaL-okzhJCyS3uWV7xsCCKnuff_rooKjAoZ8vkaintY,16817
|
|
26
|
+
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
27
|
+
pymax/static/enum.py,sha256=c_QaLU0Ephe4SuKFIpwpmrf_HCutc34JJ6o4Ik1E6_g,4582
|
|
28
|
+
maxapi_python-1.1.17.dist-info/METADATA,sha256=0gplAk5x9ujWD7fkCZu_yvwataKPPq3fMETJbwHb3HQ,6245
|
|
29
|
+
maxapi_python-1.1.17.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
30
|
+
maxapi_python-1.1.17.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
31
|
+
maxapi_python-1.1.17.dist-info/RECORD,,
|
pymax/__init__.py
CHANGED
|
@@ -9,14 +9,26 @@ 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
|
)
|
|
18
|
+
from .files import (
|
|
19
|
+
File,
|
|
20
|
+
Photo,
|
|
21
|
+
)
|
|
14
22
|
from .static.enum import (
|
|
15
23
|
AccessType,
|
|
24
|
+
AttachType,
|
|
16
25
|
AuthType,
|
|
17
26
|
ChatType,
|
|
27
|
+
ContactAction,
|
|
18
28
|
DeviceType,
|
|
19
29
|
ElementType,
|
|
30
|
+
FormattingType,
|
|
31
|
+
MarkupType,
|
|
20
32
|
MessageStatus,
|
|
21
33
|
MessageType,
|
|
22
34
|
Opcode,
|
|
@@ -24,10 +36,26 @@ from .static.enum import (
|
|
|
24
36
|
from .types import (
|
|
25
37
|
Channel,
|
|
26
38
|
Chat,
|
|
39
|
+
Contact,
|
|
40
|
+
ControlAttach,
|
|
27
41
|
Dialog,
|
|
28
42
|
Element,
|
|
43
|
+
FileAttach,
|
|
44
|
+
FileRequest,
|
|
45
|
+
Me,
|
|
46
|
+
Member,
|
|
29
47
|
Message,
|
|
48
|
+
MessageLink,
|
|
49
|
+
Name,
|
|
50
|
+
Names,
|
|
51
|
+
PhotoAttach,
|
|
52
|
+
Presence,
|
|
53
|
+
ReactionCounter,
|
|
54
|
+
ReactionInfo,
|
|
55
|
+
Session,
|
|
30
56
|
User,
|
|
57
|
+
VideoAttach,
|
|
58
|
+
VideoRequest,
|
|
31
59
|
)
|
|
32
60
|
|
|
33
61
|
__author__ = "ink-developer"
|
|
@@ -35,25 +63,51 @@ __author__ = "ink-developer"
|
|
|
35
63
|
__all__ = [
|
|
36
64
|
# Перечисления и константы
|
|
37
65
|
"AccessType",
|
|
66
|
+
"AttachType",
|
|
38
67
|
"AuthType",
|
|
39
68
|
# Типы данных
|
|
40
69
|
"Channel",
|
|
41
70
|
"Chat",
|
|
42
71
|
"ChatType",
|
|
72
|
+
"Contact",
|
|
73
|
+
"ContactAction",
|
|
74
|
+
"ControlAttach",
|
|
43
75
|
"DeviceType",
|
|
44
76
|
"Dialog",
|
|
45
77
|
"Element",
|
|
46
78
|
"ElementType",
|
|
79
|
+
"File",
|
|
80
|
+
"FileAttach",
|
|
81
|
+
"FileRequest",
|
|
82
|
+
"FormattingType",
|
|
47
83
|
# Исключения
|
|
48
84
|
"InvalidPhoneError",
|
|
49
85
|
"LoginError",
|
|
50
|
-
"
|
|
86
|
+
"MarkupType",
|
|
51
87
|
# Клиент
|
|
52
88
|
"MaxClient",
|
|
89
|
+
"Me",
|
|
90
|
+
"Member",
|
|
53
91
|
"Message",
|
|
92
|
+
"MessageLink",
|
|
54
93
|
"MessageStatus",
|
|
55
94
|
"MessageType",
|
|
95
|
+
"Name",
|
|
96
|
+
"Names",
|
|
56
97
|
"Opcode",
|
|
98
|
+
"Photo",
|
|
99
|
+
"PhotoAttach",
|
|
100
|
+
"Presence",
|
|
101
|
+
"ReactionCounter",
|
|
102
|
+
"ReactionInfo",
|
|
103
|
+
"ResponseError",
|
|
104
|
+
"ResponseStructureError",
|
|
105
|
+
"Session",
|
|
57
106
|
"SocketMaxClient",
|
|
107
|
+
"SocketNotConnectedError",
|
|
108
|
+
"SocketSendError",
|
|
58
109
|
"User",
|
|
110
|
+
"VideoAttach",
|
|
111
|
+
"VideoRequest",
|
|
112
|
+
"WebSocketNotConnectedError",
|
|
59
113
|
]
|
pymax/core.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
2
3
|
import logging
|
|
3
4
|
import socket
|
|
4
5
|
import ssl
|
|
5
6
|
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Awaitable
|
|
6
9
|
from pathlib import Path
|
|
7
|
-
from typing import Literal
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
11
|
|
|
9
12
|
from typing_extensions import override
|
|
10
13
|
|
|
11
14
|
from .crud import Database
|
|
12
|
-
from .exceptions import InvalidPhoneError
|
|
15
|
+
from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
|
|
16
|
+
from .formatter import ColoredFormatter
|
|
13
17
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
14
18
|
from .payloads import UserAgentPayload
|
|
15
19
|
from .static.constant import (
|
|
@@ -18,6 +22,16 @@ from .static.constant import (
|
|
|
18
22
|
WEBSOCKET_URI,
|
|
19
23
|
)
|
|
20
24
|
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import websockets
|
|
30
|
+
|
|
31
|
+
from .filters import Filter
|
|
32
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
logger = logging.getLogger(__name__)
|
|
22
36
|
|
|
23
37
|
|
|
@@ -43,6 +57,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
43
57
|
send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
|
|
44
58
|
proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
|
|
45
59
|
(См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
|
|
60
|
+
reconnect (bool, optional): Флаг автоматического переподключения при потере соединения. По умолчанию True.
|
|
61
|
+
|
|
46
62
|
|
|
47
63
|
Raises:
|
|
48
64
|
InvalidPhoneError: Если формат номера телефона неверный.
|
|
@@ -63,10 +79,10 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
63
79
|
first_name: str = "",
|
|
64
80
|
last_name: str | None = None,
|
|
65
81
|
logger: logging.Logger | None = None,
|
|
82
|
+
reconnect: bool = True,
|
|
83
|
+
reconnect_delay: float = 1.0,
|
|
66
84
|
) -> 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)
|
|
85
|
+
self.logger = logger or logging.getLogger(f"{__name__}")
|
|
70
86
|
self.uri: str = uri
|
|
71
87
|
self.phone: str = phone
|
|
72
88
|
if not self._check_phone():
|
|
@@ -77,23 +93,57 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
77
93
|
self.first_name: str = first_name
|
|
78
94
|
self.last_name: str | None = last_name
|
|
79
95
|
self.proxy: str | Literal[True] | None = proxy
|
|
96
|
+
self.reconnect: bool = reconnect
|
|
97
|
+
self.reconnect_delay: float = reconnect_delay
|
|
98
|
+
|
|
99
|
+
self.is_connected: bool = False
|
|
100
|
+
|
|
101
|
+
self.chats: list[Chat] = []
|
|
102
|
+
self.dialogs: list[Dialog] = []
|
|
103
|
+
self.channels: list[Channel] = []
|
|
104
|
+
self.me: Me | None = None
|
|
105
|
+
self._users: dict[int, User] = {}
|
|
106
|
+
|
|
80
107
|
self._work_dir: str = work_dir
|
|
81
108
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
82
109
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
110
|
self._database_path.touch(exist_ok=True)
|
|
84
111
|
self._database = Database(self._work_dir)
|
|
112
|
+
|
|
113
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
85
114
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
115
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
86
116
|
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
117
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
118
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
119
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
120
|
+
|
|
121
|
+
self._seq: int = 0
|
|
87
122
|
self._error_count: int = 0
|
|
88
123
|
self._circuit_breaker: bool = False
|
|
89
124
|
self._last_error_time: float = 0.0
|
|
125
|
+
|
|
90
126
|
self._device_id = self._database.get_device_id()
|
|
91
127
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
128
|
+
|
|
92
129
|
self._token = self._database.get_auth_token() or token
|
|
93
130
|
self.user_agent = headers
|
|
94
131
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
95
132
|
self._session_id: int = int(time.time() * 1000)
|
|
96
133
|
self._action_id: int = 1
|
|
134
|
+
self._current_screen: str = "chats_list_tab"
|
|
135
|
+
|
|
136
|
+
self._on_message_handlers: list[
|
|
137
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
138
|
+
] = []
|
|
139
|
+
self._on_message_edit_handlers: list[
|
|
140
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
141
|
+
] = []
|
|
142
|
+
self._on_message_delete_handlers: list[
|
|
143
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
144
|
+
] = []
|
|
145
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
146
|
+
|
|
97
147
|
self._ssl_context = ssl.create_default_context()
|
|
98
148
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
99
149
|
self._ssl_context.check_hostname = True
|
|
@@ -101,6 +151,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
101
151
|
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
102
152
|
self._ssl_context.load_default_certs()
|
|
103
153
|
self._socket: socket.socket | None = None
|
|
154
|
+
self._ws: websockets.ClientConnection | None = None
|
|
155
|
+
|
|
104
156
|
self._setup_logger()
|
|
105
157
|
self.logger.debug(
|
|
106
158
|
"Initialized MaxClient uri=%s work_dir=%s",
|
|
@@ -109,13 +161,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
109
161
|
)
|
|
110
162
|
|
|
111
163
|
def _setup_logger(self) -> None:
|
|
112
|
-
if not logger.handlers:
|
|
164
|
+
if not self.logger.handlers:
|
|
165
|
+
if not self.logger.level:
|
|
166
|
+
self.logger.setLevel(logging.INFO)
|
|
113
167
|
handler = logging.StreamHandler()
|
|
114
|
-
formatter =
|
|
115
|
-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
168
|
+
formatter = ColoredFormatter(
|
|
169
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
170
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
116
171
|
)
|
|
117
172
|
handler.setFormatter(formatter)
|
|
118
|
-
logger.addHandler(handler)
|
|
173
|
+
self.logger.addHandler(handler)
|
|
119
174
|
|
|
120
175
|
async def _wait_forever(self):
|
|
121
176
|
try:
|
|
@@ -123,6 +178,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
123
178
|
except asyncio.CancelledError:
|
|
124
179
|
self.logger.debug("wait_closed cancelled")
|
|
125
180
|
|
|
181
|
+
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
182
|
+
"""
|
|
183
|
+
Безопасно выполняет пользовательскую корутину.
|
|
184
|
+
Логирует traceback, но не роняет event loop.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
return await coro
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self.logger.error(
|
|
190
|
+
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
191
|
+
)
|
|
192
|
+
|
|
126
193
|
async def close(self) -> None:
|
|
127
194
|
try:
|
|
128
195
|
self.logger.info("Closing client")
|
|
@@ -145,44 +212,122 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
145
212
|
except Exception:
|
|
146
213
|
self.logger.exception("Error closing client")
|
|
147
214
|
|
|
215
|
+
@override
|
|
216
|
+
def _create_safe_task(
|
|
217
|
+
self, coro: Awaitable[Any], *, name: str | None = None
|
|
218
|
+
) -> asyncio.Task[Any | None]:
|
|
219
|
+
async def runner():
|
|
220
|
+
try:
|
|
221
|
+
return await coro
|
|
222
|
+
except asyncio.CancelledError:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
self.logger.exception(
|
|
226
|
+
f"Unhandled exception in task {name or coro}: {e}",
|
|
227
|
+
exc_info=e,
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
task = asyncio.create_task(runner(), name=name)
|
|
232
|
+
self._background_tasks.add(task)
|
|
233
|
+
return task
|
|
234
|
+
|
|
148
235
|
async def start(self) -> None:
|
|
149
236
|
"""
|
|
150
237
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
151
238
|
пользователя (если нужно) и запускает фоновый цикл.
|
|
239
|
+
Теперь включает безопасный reconnect-loop, если self.reconnect=True.
|
|
152
240
|
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
self.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
241
|
+
|
|
242
|
+
while True:
|
|
243
|
+
try:
|
|
244
|
+
self.logger.info("Client starting")
|
|
245
|
+
await self._connect(self.user_agent)
|
|
246
|
+
|
|
247
|
+
if self.registration:
|
|
248
|
+
if not self.first_name:
|
|
249
|
+
raise ValueError("First name is required for registration")
|
|
250
|
+
await self._register(self.first_name, self.last_name)
|
|
251
|
+
|
|
252
|
+
if self._token and self._database.get_auth_token() is None:
|
|
253
|
+
self._database.update_auth_token(self._device_id, self._token)
|
|
254
|
+
|
|
255
|
+
if self._token is None:
|
|
256
|
+
await self._login()
|
|
257
|
+
else:
|
|
258
|
+
await self._sync()
|
|
259
|
+
|
|
260
|
+
if self._on_start_handler:
|
|
261
|
+
self.logger.debug("Calling on_start handler")
|
|
262
|
+
result = self._on_start_handler()
|
|
263
|
+
if asyncio.iscoroutine(result):
|
|
264
|
+
await self._safe_execute(result, context="on_start handler")
|
|
265
|
+
|
|
266
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
267
|
+
ping_task.add_done_callback(self._log_task_exception)
|
|
268
|
+
self._background_tasks.add(ping_task)
|
|
269
|
+
|
|
270
|
+
if self._send_fake_telemetry:
|
|
271
|
+
telemetry_task = asyncio.create_task(self._start())
|
|
272
|
+
telemetry_task.add_done_callback(self._log_task_exception)
|
|
273
|
+
self._background_tasks.add(telemetry_task)
|
|
274
|
+
|
|
275
|
+
await self._wait_forever()
|
|
276
|
+
self.logger.info("WebSocket closed (wait_forever exited)")
|
|
277
|
+
|
|
278
|
+
except Exception:
|
|
279
|
+
self.logger.exception("Client start iteration failed")
|
|
280
|
+
|
|
281
|
+
finally:
|
|
282
|
+
self.logger.debug("Cleaning up background tasks and pending futures")
|
|
283
|
+
|
|
284
|
+
for task in list(self._background_tasks):
|
|
285
|
+
task.cancel()
|
|
286
|
+
try:
|
|
287
|
+
await task
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
pass
|
|
290
|
+
except Exception:
|
|
291
|
+
self.logger.debug(
|
|
292
|
+
"Background task raised during cancellation", exc_info=True
|
|
293
|
+
)
|
|
294
|
+
self._background_tasks.discard(task)
|
|
295
|
+
|
|
296
|
+
if self._recv_task:
|
|
297
|
+
self._recv_task.cancel()
|
|
298
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
299
|
+
await self._recv_task
|
|
300
|
+
self._recv_task = None
|
|
301
|
+
|
|
302
|
+
if self._outgoing_task:
|
|
303
|
+
self._outgoing_task.cancel()
|
|
304
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
305
|
+
await self._outgoing_task
|
|
306
|
+
self._outgoing_task = None
|
|
307
|
+
|
|
308
|
+
for fut in self._pending.values():
|
|
309
|
+
if not fut.done():
|
|
310
|
+
fut.set_exception(WebSocketNotConnectedError)
|
|
311
|
+
self._pending.clear()
|
|
312
|
+
|
|
313
|
+
if self._ws:
|
|
314
|
+
try:
|
|
315
|
+
await self._ws.close()
|
|
316
|
+
except Exception:
|
|
317
|
+
self.logger.debug(
|
|
318
|
+
"Error closing ws during cleanup", exc_info=True
|
|
319
|
+
)
|
|
320
|
+
self._ws = None
|
|
321
|
+
|
|
322
|
+
self.is_connected = False
|
|
323
|
+
self.logger.info("Client start() cleaned up")
|
|
324
|
+
|
|
325
|
+
if not self.reconnect:
|
|
326
|
+
self.logger.info("Reconnect disabled — exiting start()")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
self.logger.info("Reconnect enabled — restarting client")
|
|
330
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
186
331
|
|
|
187
332
|
|
|
188
333
|
class SocketMaxClient(SocketMixin, MaxClient):
|
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,7 +41,9 @@ 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
|
|
46
|
+
self.reconnect: bool
|
|
47
47
|
self._database_path: Path
|
|
48
48
|
self._ws: websockets.ClientConnection | None = None
|
|
49
49
|
self._seq: int = 0
|
|
@@ -57,7 +57,6 @@ class ClientProtocol(ABC):
|
|
|
57
57
|
self._error_count: int = 0
|
|
58
58
|
self._circuit_breaker: bool = False
|
|
59
59
|
self._last_error_time: float = 0.0
|
|
60
|
-
self.user_agent = DEFAULT_USER_AGENT
|
|
61
60
|
self._session_id: int
|
|
62
61
|
self._action_id: int = 0
|
|
63
62
|
self._current_screen: str = "chats_list_tab"
|
|
@@ -99,3 +98,9 @@ class ClientProtocol(ABC):
|
|
|
99
98
|
max_retries: int = 3,
|
|
100
99
|
) -> Message | None:
|
|
101
100
|
pass
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def _create_safe_task(
|
|
104
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
105
|
+
) -> asyncio.Task[Any]:
|
|
106
|
+
pass
|