maxapi-python 1.1.21__py3-none-any.whl → 1.2.2__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.21.dist-info → maxapi_python-1.2.2.dist-info}/METADATA +45 -1
- {maxapi_python-1.1.21.dist-info → maxapi_python-1.2.2.dist-info}/RECORD +14 -14
- pymax/core.py +14 -152
- pymax/crud.py +1 -1
- pymax/formatting.py +4 -6
- pymax/interfaces.py +143 -9
- pymax/mixins/auth.py +162 -20
- pymax/mixins/socket.py +30 -43
- pymax/mixins/websocket.py +7 -16
- pymax/payloads.py +29 -14
- pymax/static/constant.py +2 -2
- pymax/static/enum.py +4 -0
- {maxapi_python-1.1.21.dist-info → maxapi_python-1.2.2.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.21.dist-info → maxapi_python-1.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.2
|
|
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
|
|
@@ -16,8 +16,16 @@ Requires-Dist: aiofiles>=24.1.0
|
|
|
16
16
|
Requires-Dist: aiohttp>=3.12.15
|
|
17
17
|
Requires-Dist: lz4>=4.4.4
|
|
18
18
|
Requires-Dist: msgpack>=1.1.1
|
|
19
|
+
Requires-Dist: qrcode>=8.2
|
|
19
20
|
Requires-Dist: sqlmodel>=0.0.24
|
|
20
21
|
Requires-Dist: websockets>=15.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: flake8; extra == 'test'
|
|
24
|
+
Requires-Dist: mypy; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest-timeout>=2.1.0; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
21
29
|
Description-Content-Type: text/markdown
|
|
22
30
|
|
|
23
31
|
<p align="center">
|
|
@@ -35,6 +43,7 @@ Description-Content-Type: text/markdown
|
|
|
35
43
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
|
36
44
|
</p>
|
|
37
45
|
|
|
46
|
+
|
|
38
47
|
---
|
|
39
48
|
> ⚠️ **Дисклеймер**
|
|
40
49
|
>
|
|
@@ -75,6 +84,41 @@ uv add -U maxapi-python
|
|
|
75
84
|
|
|
76
85
|
## Быстрый старт
|
|
77
86
|
|
|
87
|
+
### Аутентификация (`device_type`)
|
|
88
|
+
|
|
89
|
+
> [!IMPORTANT]
|
|
90
|
+
> Параметр `device_type` в `UserAgentPayload` **критически важен** для выбора способа авторизации:
|
|
91
|
+
|
|
92
|
+
**Вход по номеру телефона (DESKTOP):**
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pymax import MaxClient
|
|
96
|
+
from pymax.payloads import UserAgentPayload
|
|
97
|
+
|
|
98
|
+
ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13")
|
|
99
|
+
|
|
100
|
+
client = MaxClient(
|
|
101
|
+
phone="+79111111111",
|
|
102
|
+
work_dir="cache",
|
|
103
|
+
headers=ua,
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Вход через QR-код (WEB)** — токен совместим с веб-версией Max:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from pymax import MaxClient
|
|
111
|
+
from pymax.payloads import UserAgentPayload
|
|
112
|
+
|
|
113
|
+
ua = UserAgentPayload(device_type="WEB", app_version="25.12.13")
|
|
114
|
+
|
|
115
|
+
client = MaxClient(
|
|
116
|
+
phone="+7911111111",
|
|
117
|
+
work_dir="cache",
|
|
118
|
+
headers=ua,
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
78
122
|
### Базовый пример использования
|
|
79
123
|
|
|
80
124
|
```python
|
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
-
pymax/core.py,sha256=
|
|
3
|
-
pymax/crud.py,sha256=
|
|
2
|
+
pymax/core.py,sha256=OXGNaQ0pDaf6Ofr1Fb9m7vh5ffpbiMyvUMM0EfwlnIQ,14907
|
|
3
|
+
pymax/crud.py,sha256=YC92TyhA2mr1tJCcfd-tvh8umtXKgqJfgiLo7nXUl3Q,3076
|
|
4
4
|
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
5
|
pymax/files.py,sha256=AvFIr34Desq2p4CNWXIngRqeyTBKMT98VmcnI-zvUU0,3462
|
|
6
6
|
pymax/filters.py,sha256=gSHPJ1Vi37HKPxf0jRRv9Q3iGwhiQjw1MGrCaouqHzs,4325
|
|
7
7
|
pymax/formatter.py,sha256=RJ_5VbY7Li8UM3xL1AvcXo8v1iYnY8GvDDkreaFqtnY,860
|
|
8
|
-
pymax/formatting.py,sha256=
|
|
9
|
-
pymax/interfaces.py,sha256=
|
|
8
|
+
pymax/formatting.py,sha256=XRtuXJGweuNZevJFdPxksDftIrfuMGEA-AOUc_v6IhQ,2484
|
|
9
|
+
pymax/interfaces.py,sha256=wKF1z1QRw8LcjvM9rzSHWXTK6gPb6sDt2UGiQLvyMf8,8790
|
|
10
10
|
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
11
|
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
-
pymax/payloads.py,sha256
|
|
12
|
+
pymax/payloads.py,sha256=GuTLK6HYe_bLW3ardgpKeZ98f79j349tD_6B6EwkGww,7879
|
|
13
13
|
pymax/types.py,sha256=_ARcVXLGHyiGAJKYPd6EU9QDKzz4VwS6kjTu3YEH_u4,35523
|
|
14
14
|
pymax/mixins/__init__.py,sha256=5sXJME34S1EssuDETaN4DLRH7vhMw_Q3Jmay9myAIZM,775
|
|
15
|
-
pymax/mixins/auth.py,sha256=
|
|
15
|
+
pymax/mixins/auth.py,sha256=e90vIpEOwAjUxgYMYaG7R6jR_5t9rKsei_mTBQUirL4,14716
|
|
16
16
|
pymax/mixins/channel.py,sha256=W52YnBay1sUYXxF9oAWsz44ZUh_s45jSvKmAyjTbULM,5357
|
|
17
17
|
pymax/mixins/group.py,sha256=LqI1QHmZlmtuQ0-4H1MrNeBV-O9SMDMfHT9f4B_2poE,15189
|
|
18
18
|
pymax/mixins/handler.py,sha256=ETnI8fA386LYJGjWtUhhWzQHREUA78di1aO1oWwtscA,12523
|
|
19
19
|
pymax/mixins/message.py,sha256=AznKKmTMxdzsYl8IecT43RjWpGvlQM85GzSNGFbI8BA,33279
|
|
20
20
|
pymax/mixins/scheduler.py,sha256=rcMfgfZnzu5V6MkcCg6uRgbi-jkc7UyqOjemulydWbc,964
|
|
21
21
|
pymax/mixins/self.py,sha256=Be5L64eNYylGM-NmoxFpQZv1ohsC1Dx_Cs3Om__V96s,6976
|
|
22
|
-
pymax/mixins/socket.py,sha256=
|
|
22
|
+
pymax/mixins/socket.py,sha256=tdHgd1NwWoEZhHCDd74XLOHFKUq-rladxhXV8Z_-APU,22860
|
|
23
23
|
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
24
24
|
pymax/mixins/user.py,sha256=RSZd4t-aq8P2k3cVzNVWBkUf-_xTWILrBzwxLRgk1pw,9450
|
|
25
25
|
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
26
|
-
pymax/mixins/websocket.py,sha256=
|
|
27
|
-
pymax/static/constant.py,sha256=
|
|
28
|
-
pymax/static/enum.py,sha256=
|
|
29
|
-
maxapi_python-1.
|
|
30
|
-
maxapi_python-1.
|
|
31
|
-
maxapi_python-1.
|
|
32
|
-
maxapi_python-1.
|
|
26
|
+
pymax/mixins/websocket.py,sha256=GpdboEVWzyN1qLTcsgKZym6TlPnklcQuNeXJ5YKwg8c,17724
|
|
27
|
+
pymax/static/constant.py,sha256=nM0svv3VpsVxK-RqoADn9qsTdQvB-IYv0Sgv-bQcWs4,1116
|
|
28
|
+
pymax/static/enum.py,sha256=Hk0e6zSbGOJC_9Aw7gNXX3hcavnjzQfDyr8vjW22cFo,4648
|
|
29
|
+
maxapi_python-1.2.2.dist-info/METADATA,sha256=rgiQKdSqYAO743n6jWOy0F76jZyjaGMY7A6qUlHlk64,6753
|
|
30
|
+
maxapi_python-1.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
31
|
+
maxapi_python-1.2.2.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
32
|
+
maxapi_python-1.2.2.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -6,21 +6,19 @@ import logging
|
|
|
6
6
|
import socket
|
|
7
7
|
import ssl
|
|
8
8
|
import time
|
|
9
|
-
import traceback
|
|
10
9
|
from collections.abc import Awaitable
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from typing import TYPE_CHECKING, Any, Literal
|
|
13
12
|
from uuid import UUID
|
|
14
13
|
|
|
15
|
-
from typing_extensions import
|
|
14
|
+
from typing_extensions import override
|
|
16
15
|
|
|
17
16
|
from .crud import Database
|
|
18
17
|
from .exceptions import (
|
|
19
18
|
InvalidPhoneError,
|
|
20
19
|
SocketNotConnectedError,
|
|
21
|
-
WebSocketNotConnectedError,
|
|
22
20
|
)
|
|
23
|
-
from .
|
|
21
|
+
from .interfaces import BaseClient
|
|
24
22
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
25
23
|
from .payloads import UserAgentPayload
|
|
26
24
|
from .static.constant import (
|
|
@@ -43,7 +41,7 @@ if TYPE_CHECKING:
|
|
|
43
41
|
logger = logging.getLogger(__name__)
|
|
44
42
|
|
|
45
43
|
|
|
46
|
-
class MaxClient(ApiMixin, WebSocketMixin):
|
|
44
|
+
class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
|
|
47
45
|
"""
|
|
48
46
|
Основной клиент для работы с WebSocket API сервиса Max.
|
|
49
47
|
|
|
@@ -139,9 +137,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
139
137
|
self._circuit_breaker: bool = False
|
|
140
138
|
self._last_error_time: float = 0.0
|
|
141
139
|
|
|
142
|
-
self._device_id = (
|
|
143
|
-
device_id if device_id is not None else self._database.get_device_id()
|
|
144
|
-
)
|
|
140
|
+
self._device_id = device_id if device_id is not None else self._database.get_device_id()
|
|
145
141
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
146
142
|
|
|
147
143
|
self._token = self._database.get_auth_token() or token
|
|
@@ -161,16 +157,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
161
157
|
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
162
158
|
] = []
|
|
163
159
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
164
|
-
self.
|
|
165
|
-
|
|
166
|
-
] = []
|
|
167
|
-
self.
|
|
168
|
-
self.
|
|
169
|
-
Callable[[dict[str, Any]], Any | Awaitable[Any]]
|
|
170
|
-
] = []
|
|
171
|
-
self._scheduled_tasks: list[
|
|
172
|
-
tuple[Callable[[], Any | Awaitable[Any]], float]
|
|
173
|
-
] = []
|
|
160
|
+
self._on_stop_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
161
|
+
self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
|
|
162
|
+
self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
|
|
163
|
+
self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
|
|
164
|
+
self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
|
|
174
165
|
|
|
175
166
|
self._ssl_context = ssl.create_default_context()
|
|
176
167
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
@@ -188,36 +179,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
188
179
|
self._work_dir,
|
|
189
180
|
)
|
|
190
181
|
|
|
191
|
-
def _setup_logger(self) -> None:
|
|
192
|
-
if not self.logger.handlers:
|
|
193
|
-
if not self.logger.level:
|
|
194
|
-
self.logger.setLevel(logging.INFO)
|
|
195
|
-
handler = logging.StreamHandler()
|
|
196
|
-
formatter = ColoredFormatter(
|
|
197
|
-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
198
|
-
datefmt="%Y-%m-%d %H:%M:%S",
|
|
199
|
-
)
|
|
200
|
-
handler.setFormatter(formatter)
|
|
201
|
-
self.logger.addHandler(handler)
|
|
202
|
-
|
|
203
182
|
async def _wait_forever(self) -> None:
|
|
204
183
|
try:
|
|
205
184
|
await self.ws.wait_closed()
|
|
206
185
|
except asyncio.CancelledError:
|
|
207
186
|
self.logger.debug("wait_closed cancelled")
|
|
208
187
|
|
|
209
|
-
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
210
|
-
"""
|
|
211
|
-
Безопасно выполняет пользовательскую корутину.
|
|
212
|
-
Логирует traceback, но не роняет event loop.
|
|
213
|
-
"""
|
|
214
|
-
try:
|
|
215
|
-
return await coro
|
|
216
|
-
except Exception as e:
|
|
217
|
-
self.logger.error(
|
|
218
|
-
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
219
|
-
)
|
|
220
|
-
|
|
221
188
|
async def close(self) -> None:
|
|
222
189
|
"""
|
|
223
190
|
Закрывает клиент и освобождает ресурсы.
|
|
@@ -245,26 +212,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
245
212
|
except Exception:
|
|
246
213
|
self.logger.exception("Error closing client")
|
|
247
214
|
|
|
248
|
-
@override
|
|
249
|
-
def _create_safe_task(
|
|
250
|
-
self, coro: Awaitable[Any], *, name: str | None = None
|
|
251
|
-
) -> asyncio.Task[Any | None]:
|
|
252
|
-
async def runner():
|
|
253
|
-
try:
|
|
254
|
-
return await coro
|
|
255
|
-
except asyncio.CancelledError:
|
|
256
|
-
raise
|
|
257
|
-
except Exception as e:
|
|
258
|
-
tb = traceback.format_exc()
|
|
259
|
-
self.logger.error(
|
|
260
|
-
f"Unhandled exception in task {name or coro}: {e}\n{tb}"
|
|
261
|
-
)
|
|
262
|
-
raise
|
|
263
|
-
|
|
264
|
-
task = asyncio.create_task(runner(), name=name)
|
|
265
|
-
self._background_tasks.add(task)
|
|
266
|
-
return task
|
|
267
|
-
|
|
268
215
|
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
269
216
|
if sync:
|
|
270
217
|
await self._sync()
|
|
@@ -288,49 +235,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
288
235
|
if asyncio.iscoroutine(result):
|
|
289
236
|
await self._safe_execute(result, context="on_start handler")
|
|
290
237
|
|
|
291
|
-
async def
|
|
292
|
-
for task in list(self._background_tasks):
|
|
293
|
-
task.cancel()
|
|
294
|
-
try:
|
|
295
|
-
await task
|
|
296
|
-
except asyncio.CancelledError:
|
|
297
|
-
pass
|
|
298
|
-
except Exception:
|
|
299
|
-
self.logger.debug(
|
|
300
|
-
"Background task raised during cancellation", exc_info=True
|
|
301
|
-
)
|
|
302
|
-
self._background_tasks.discard(task)
|
|
303
|
-
|
|
304
|
-
if self._recv_task:
|
|
305
|
-
self._recv_task.cancel()
|
|
306
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
307
|
-
await self._recv_task
|
|
308
|
-
self._recv_task = None
|
|
309
|
-
|
|
310
|
-
if self._outgoing_task:
|
|
311
|
-
self._outgoing_task.cancel()
|
|
312
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
313
|
-
await self._outgoing_task
|
|
314
|
-
self._outgoing_task = None
|
|
315
|
-
|
|
316
|
-
for fut in self._pending.values():
|
|
317
|
-
if not fut.done():
|
|
318
|
-
fut.set_exception(WebSocketNotConnectedError)
|
|
319
|
-
self._pending.clear()
|
|
320
|
-
|
|
321
|
-
if self._ws:
|
|
322
|
-
try:
|
|
323
|
-
await self._ws.close()
|
|
324
|
-
except Exception:
|
|
325
|
-
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
326
|
-
self._ws = None
|
|
327
|
-
|
|
328
|
-
self.is_connected = False
|
|
329
|
-
self.logger.info("Client start() cleaned up")
|
|
330
|
-
|
|
331
|
-
async def login_with_code(
|
|
332
|
-
self, temp_token: str, code: str, start: bool = False
|
|
333
|
-
) -> None:
|
|
238
|
+
async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
|
|
334
239
|
"""
|
|
335
240
|
Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
|
|
336
241
|
|
|
@@ -348,7 +253,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
348
253
|
if not token:
|
|
349
254
|
raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
|
|
350
255
|
self._token = token
|
|
351
|
-
self._database.update_auth_token(
|
|
256
|
+
self._database.update_auth_token(self._device_id, token)
|
|
352
257
|
if start:
|
|
353
258
|
while True:
|
|
354
259
|
try:
|
|
@@ -385,12 +290,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
385
290
|
await self._register(self.first_name, self.last_name)
|
|
386
291
|
|
|
387
292
|
if self._token and self._database.get_auth_token() is None:
|
|
388
|
-
self._database.update_auth_token(
|
|
293
|
+
self._database.update_auth_token(self._device_id, self._token)
|
|
389
294
|
|
|
390
295
|
if self._token is None:
|
|
391
296
|
await self._login()
|
|
392
|
-
|
|
393
|
-
|
|
297
|
+
|
|
298
|
+
await self._sync(self.user_agent)
|
|
394
299
|
|
|
395
300
|
await self._post_login_tasks(sync=False)
|
|
396
301
|
|
|
@@ -412,43 +317,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
412
317
|
self.logger.info("Reconnect enabled — restarting client")
|
|
413
318
|
await asyncio.sleep(self.reconnect_delay)
|
|
414
319
|
|
|
415
|
-
async def idle(self):
|
|
416
|
-
"""
|
|
417
|
-
Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
|
|
418
|
-
|
|
419
|
-
:return: Никогда не возвращает значение; функция блокирует выполнение.
|
|
420
|
-
:rtype: None
|
|
421
|
-
"""
|
|
422
|
-
await asyncio.Event().wait()
|
|
423
|
-
|
|
424
|
-
def inspect(self) -> None:
|
|
425
|
-
"""
|
|
426
|
-
Выводит в лог текущий статус клиента для отладки.
|
|
427
|
-
"""
|
|
428
|
-
self.logger.info("Pymax")
|
|
429
|
-
self.logger.info("---------")
|
|
430
|
-
self.logger.info(f"Connected: {self.is_connected}")
|
|
431
|
-
if self.me is not None:
|
|
432
|
-
self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
|
|
433
|
-
else:
|
|
434
|
-
self.logger.info("Me: N/A")
|
|
435
|
-
self.logger.info(f"Dialogs: {len(self.dialogs)}")
|
|
436
|
-
self.logger.info(f"Chats: {len(self.chats)}")
|
|
437
|
-
self.logger.info(f"Channels: {len(self.channels)}")
|
|
438
|
-
self.logger.info(f"Users cached: {len(self._users)}")
|
|
439
|
-
self.logger.info(f"Background tasks: {len(self._background_tasks)}")
|
|
440
|
-
self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
|
|
441
|
-
self.logger.info("---------")
|
|
442
|
-
|
|
443
|
-
async def __aenter__(self) -> Self:
|
|
444
|
-
self._create_safe_task(self.start(), name="start")
|
|
445
|
-
while not self.is_connected:
|
|
446
|
-
await asyncio.sleep(0.05)
|
|
447
|
-
return self
|
|
448
|
-
|
|
449
|
-
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
450
|
-
await self.close()
|
|
451
|
-
|
|
452
320
|
|
|
453
321
|
class SocketMaxClient(SocketMixin, MaxClient):
|
|
454
322
|
@override
|
|
@@ -463,12 +331,6 @@ class SocketMaxClient(SocketMixin, MaxClient):
|
|
|
463
331
|
|
|
464
332
|
@override
|
|
465
333
|
async def _cleanup_client(self):
|
|
466
|
-
"""
|
|
467
|
-
Socket-specific cleanup: cancel background tasks, set pending futures
|
|
468
|
-
exceptions to SocketNotConnectedError, and close socket.
|
|
469
|
-
"""
|
|
470
|
-
from .exceptions import SocketNotConnectedError
|
|
471
|
-
|
|
472
334
|
for task in list(self._background_tasks):
|
|
473
335
|
task.cancel()
|
|
474
336
|
try:
|
pymax/crud.py
CHANGED
|
@@ -48,7 +48,7 @@ class Database:
|
|
|
48
48
|
session.refresh(auth)
|
|
49
49
|
return auth
|
|
50
50
|
|
|
51
|
-
def update_auth_token(self, device_id:
|
|
51
|
+
def update_auth_token(self, device_id: UUID, token: str) -> None:
|
|
52
52
|
with self.get_session() as session:
|
|
53
53
|
auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
|
|
54
54
|
if auth:
|
pymax/formatting.py
CHANGED
|
@@ -46,14 +46,12 @@ class Formatting:
|
|
|
46
46
|
|
|
47
47
|
if inner_text is not None and fmt_type is not None:
|
|
48
48
|
next_pos = match.end()
|
|
49
|
-
has_newline = (
|
|
50
|
-
next_pos
|
|
51
|
-
)
|
|
49
|
+
has_newline = (next_pos < len(text) and text[next_pos] == "\n") or (
|
|
50
|
+
next_pos == len(text)
|
|
51
|
+
)
|
|
52
52
|
|
|
53
53
|
length = len(inner_text) + (1 if has_newline else 0)
|
|
54
|
-
elements.append(
|
|
55
|
-
Element(type=fmt_type, from_=current_pos, length=length)
|
|
56
|
-
)
|
|
54
|
+
elements.append(Element(type=fmt_type, from_=current_pos, length=length))
|
|
57
55
|
|
|
58
56
|
clean_parts.append(inner_text)
|
|
59
57
|
if has_newline:
|
pymax/interfaces.py
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
2
4
|
import socket
|
|
3
5
|
import ssl
|
|
6
|
+
import traceback
|
|
4
7
|
from abc import ABC, abstractmethod
|
|
5
8
|
from collections.abc import Awaitable, Callable
|
|
6
9
|
from logging import Logger
|
|
7
10
|
from typing import TYPE_CHECKING, Any, Literal
|
|
8
11
|
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from pymax.exceptions import WebSocketNotConnectedError
|
|
15
|
+
from pymax.formatter import ColoredFormatter
|
|
16
|
+
|
|
9
17
|
from .payloads import UserAgentPayload
|
|
10
18
|
from .static.constant import DEFAULT_TIMEOUT
|
|
11
19
|
from .static.enum import Opcode
|
|
@@ -75,16 +83,10 @@ class ClientProtocol(ABC):
|
|
|
75
83
|
self._on_message_delete_handlers: list[
|
|
76
84
|
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
77
85
|
] = []
|
|
78
|
-
self._on_reaction_change_handlers: list[
|
|
79
|
-
Callable[[str, int, ReactionInfo], Any]
|
|
80
|
-
] = []
|
|
86
|
+
self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
|
|
81
87
|
self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
|
|
82
|
-
self._on_raw_receive_handlers: list[
|
|
83
|
-
|
|
84
|
-
] = []
|
|
85
|
-
self._scheduled_tasks: list[
|
|
86
|
-
tuple[Callable[[], Any | Awaitable[Any]], float]
|
|
87
|
-
] = []
|
|
88
|
+
self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
|
|
89
|
+
self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
|
|
88
90
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
89
91
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
90
92
|
self._ssl_context: ssl.SSLContext
|
|
@@ -120,3 +122,135 @@ class ClientProtocol(ABC):
|
|
|
120
122
|
self, coro: Awaitable[Any], name: str | None = None
|
|
121
123
|
) -> asyncio.Task[Any]:
|
|
122
124
|
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BaseClient(ClientProtocol):
|
|
128
|
+
def _setup_logger(self) -> None:
|
|
129
|
+
if not self.logger.handlers:
|
|
130
|
+
if not self.logger.level:
|
|
131
|
+
self.logger.setLevel(logging.INFO)
|
|
132
|
+
handler = logging.StreamHandler()
|
|
133
|
+
formatter = ColoredFormatter(
|
|
134
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
135
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
136
|
+
)
|
|
137
|
+
handler.setFormatter(formatter)
|
|
138
|
+
self.logger.addHandler(handler)
|
|
139
|
+
|
|
140
|
+
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
141
|
+
try:
|
|
142
|
+
return await coro
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.logger.error(f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}")
|
|
145
|
+
|
|
146
|
+
def _create_safe_task(
|
|
147
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
148
|
+
) -> asyncio.Task[Any | None]:
|
|
149
|
+
async def runner():
|
|
150
|
+
try:
|
|
151
|
+
return await coro
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
raise
|
|
154
|
+
except Exception as e:
|
|
155
|
+
tb = traceback.format_exc()
|
|
156
|
+
self.logger.error(f"Unhandled exception in task {name or coro}: {e}\n{tb}")
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
task = asyncio.create_task(runner(), name=name)
|
|
160
|
+
self._background_tasks.add(task)
|
|
161
|
+
return task
|
|
162
|
+
|
|
163
|
+
async def _cleanup_client(self) -> None:
|
|
164
|
+
for task in list(self._background_tasks):
|
|
165
|
+
task.cancel()
|
|
166
|
+
try:
|
|
167
|
+
await task
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
pass
|
|
170
|
+
except Exception:
|
|
171
|
+
self.logger.debug("Background task raised during cancellation", exc_info=True)
|
|
172
|
+
self._background_tasks.discard(task)
|
|
173
|
+
|
|
174
|
+
if self._recv_task:
|
|
175
|
+
self._recv_task.cancel()
|
|
176
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
177
|
+
await self._recv_task
|
|
178
|
+
self._recv_task = None
|
|
179
|
+
|
|
180
|
+
if self._outgoing_task:
|
|
181
|
+
self._outgoing_task.cancel()
|
|
182
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
183
|
+
await self._outgoing_task
|
|
184
|
+
self._outgoing_task = None
|
|
185
|
+
|
|
186
|
+
for fut in self._pending.values():
|
|
187
|
+
if not fut.done():
|
|
188
|
+
fut.set_exception(WebSocketNotConnectedError())
|
|
189
|
+
self._pending.clear()
|
|
190
|
+
|
|
191
|
+
if self._ws:
|
|
192
|
+
try:
|
|
193
|
+
await self._ws.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
196
|
+
self._ws = None
|
|
197
|
+
|
|
198
|
+
self.is_connected = False
|
|
199
|
+
self.logger.info("Client start() cleaned up")
|
|
200
|
+
|
|
201
|
+
async def idle(self):
|
|
202
|
+
"""
|
|
203
|
+
Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
|
|
204
|
+
|
|
205
|
+
:return: Никогда не возвращает значение; функция блокирует выполнение.
|
|
206
|
+
:rtype: None
|
|
207
|
+
"""
|
|
208
|
+
await asyncio.Event().wait()
|
|
209
|
+
|
|
210
|
+
def inspect(self) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Выводит в лог текущий статус клиента для отладки.
|
|
213
|
+
"""
|
|
214
|
+
self.logger.info("Pymax")
|
|
215
|
+
self.logger.info("---------")
|
|
216
|
+
self.logger.info(f"Connected: {self.is_connected}")
|
|
217
|
+
if self.me is not None:
|
|
218
|
+
self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
|
|
219
|
+
else:
|
|
220
|
+
self.logger.info("Me: N/A")
|
|
221
|
+
self.logger.info(f"Dialogs: {len(self.dialogs)}")
|
|
222
|
+
self.logger.info(f"Chats: {len(self.chats)}")
|
|
223
|
+
self.logger.info(f"Channels: {len(self.channels)}")
|
|
224
|
+
self.logger.info(f"Users cached: {len(self._users)}")
|
|
225
|
+
self.logger.info(f"Background tasks: {len(self._background_tasks)}")
|
|
226
|
+
self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
|
|
227
|
+
self.logger.info("---------")
|
|
228
|
+
|
|
229
|
+
async def __aenter__(self) -> Self:
|
|
230
|
+
self._create_safe_task(self.start(), name="start")
|
|
231
|
+
while not self.is_connected:
|
|
232
|
+
await asyncio.sleep(0.05)
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
236
|
+
await self.close()
|
|
237
|
+
|
|
238
|
+
@abstractmethod
|
|
239
|
+
async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
@abstractmethod
|
|
243
|
+
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
@abstractmethod
|
|
247
|
+
async def _wait_forever(self) -> None:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
@abstractmethod
|
|
251
|
+
async def start(self) -> None:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
@abstractmethod
|
|
255
|
+
async def close(self) -> None:
|
|
256
|
+
pass
|
pymax/mixins/auth.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import datetime
|
|
2
3
|
import re
|
|
3
4
|
import sys
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
7
|
+
import qrcode
|
|
8
|
+
|
|
6
9
|
from pymax.exceptions import Error
|
|
7
10
|
from pymax.interfaces import ClientProtocol
|
|
8
11
|
from pymax.mixins.utils import MixinsUtils
|
|
9
12
|
from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
|
|
10
13
|
from pymax.static.constant import PHONE_REGEX
|
|
11
|
-
from pymax.static.enum import AuthType, Opcode
|
|
14
|
+
from pymax.static.enum import AuthType, DeviceType, Opcode
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class AuthMixin(ClientProtocol):
|
|
@@ -130,21 +133,67 @@ class AuthMixin(ClientProtocol):
|
|
|
130
133
|
self.logger.error("Invalid payload data received")
|
|
131
134
|
raise ValueError("Invalid payload data received")
|
|
132
135
|
|
|
136
|
+
def _print_qr(self, qr_link: str) -> None:
|
|
137
|
+
qr = qrcode.QRCode(
|
|
138
|
+
version=1,
|
|
139
|
+
error_correction=qrcode.ERROR_CORRECT_L,
|
|
140
|
+
box_size=1,
|
|
141
|
+
border=1,
|
|
142
|
+
)
|
|
143
|
+
qr.add_data(qr_link)
|
|
144
|
+
qr.make(fit=True)
|
|
145
|
+
|
|
146
|
+
qr.print_ascii()
|
|
147
|
+
|
|
148
|
+
async def _request_qr_login(self) -> dict[str, Any]:
|
|
149
|
+
self.logger.info("Requesting QR login data")
|
|
150
|
+
|
|
151
|
+
data = await self._send_and_wait(opcode=Opcode.GET_QR, payload={})
|
|
152
|
+
|
|
153
|
+
if data.get("payload", {}).get("error"):
|
|
154
|
+
MixinsUtils.handle_error(data)
|
|
155
|
+
|
|
156
|
+
self.logger.debug(
|
|
157
|
+
"QR login data response opcode=%s seq=%s",
|
|
158
|
+
data.get("opcode"),
|
|
159
|
+
data.get("seq"),
|
|
160
|
+
)
|
|
161
|
+
payload_data = data.get("payload")
|
|
162
|
+
if isinstance(payload_data, dict):
|
|
163
|
+
return payload_data
|
|
164
|
+
else:
|
|
165
|
+
self.logger.error("Invalid payload data received")
|
|
166
|
+
raise ValueError("Invalid payload data received")
|
|
167
|
+
|
|
168
|
+
def _validate_version(self, version: str, min_version: str) -> bool:
|
|
169
|
+
def version_tuple(v: str) -> tuple[int, ...]:
|
|
170
|
+
return tuple(map(int, (v.split("."))))
|
|
171
|
+
|
|
172
|
+
return version_tuple(version) >= version_tuple(min_version)
|
|
173
|
+
|
|
133
174
|
async def _login(self) -> None:
|
|
134
175
|
self.logger.info("Starting login flow")
|
|
135
176
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
if self.user_agent.device_type == DeviceType.WEB.value and self._ws:
|
|
178
|
+
if not self._validate_version(self.user_agent.app_version, "25.12.13"):
|
|
179
|
+
self.logger.error("Your app version is too old")
|
|
180
|
+
raise ValueError("Your app version is too old")
|
|
140
181
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
login_resp = await self._login_by_qr()
|
|
183
|
+
else:
|
|
184
|
+
temp_token = await self.request_code(self.phone)
|
|
185
|
+
if not temp_token or not isinstance(temp_token, str):
|
|
186
|
+
self.logger.critical("Failed to request code: token missing")
|
|
187
|
+
raise ValueError("Failed to request code")
|
|
188
|
+
|
|
189
|
+
print("Введите код: ", end="", flush=True)
|
|
190
|
+
code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
|
|
191
|
+
if len(code) != 6 or not code.isdigit():
|
|
192
|
+
self.logger.error("Invalid code format entered")
|
|
193
|
+
raise ValueError("Invalid code format")
|
|
194
|
+
|
|
195
|
+
login_resp = await self._send_code(code, temp_token)
|
|
146
196
|
|
|
147
|
-
login_resp = await self._send_code(code, temp_token)
|
|
148
197
|
token = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token", "")
|
|
149
198
|
|
|
150
199
|
if not token:
|
|
@@ -152,9 +201,108 @@ class AuthMixin(ClientProtocol):
|
|
|
152
201
|
raise ValueError("Failed to login, token not received")
|
|
153
202
|
|
|
154
203
|
self._token = token
|
|
155
|
-
self._database.update_auth_token(
|
|
204
|
+
self._database.update_auth_token((self._device_id), self._token)
|
|
156
205
|
self.logger.info("Login successful, token saved to database")
|
|
157
206
|
|
|
207
|
+
async def _poll_qr_login(self, track_id: str, poll_interval: int) -> bool:
|
|
208
|
+
self.logger.info("Polling for QR login confirmation")
|
|
209
|
+
|
|
210
|
+
while True:
|
|
211
|
+
data = await self._send_and_wait(
|
|
212
|
+
opcode=Opcode.GET_QR_STATUS,
|
|
213
|
+
payload={"trackId": track_id},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
payload = data.get("payload", {})
|
|
217
|
+
|
|
218
|
+
if payload.get("error"):
|
|
219
|
+
MixinsUtils.handle_error(data)
|
|
220
|
+
status = payload.get("status")
|
|
221
|
+
|
|
222
|
+
if not status:
|
|
223
|
+
self.logger.warning("No status in QR login response")
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
if status.get("loginAvailable"):
|
|
227
|
+
self.logger.info("QR login confirmed")
|
|
228
|
+
return True
|
|
229
|
+
else:
|
|
230
|
+
exp_at = status.get("expiresAt")
|
|
231
|
+
if (
|
|
232
|
+
exp_at
|
|
233
|
+
and isinstance(exp_at, (int, float))
|
|
234
|
+
and exp_at < datetime.datetime.now().timestamp() * 1000
|
|
235
|
+
):
|
|
236
|
+
self.logger.warning("QR code expired")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
await asyncio.sleep(poll_interval / 1000)
|
|
240
|
+
|
|
241
|
+
async def _get_qr_login_data(self, track_id: str) -> dict[str, Any]:
|
|
242
|
+
self.logger.info("Getting QR login data")
|
|
243
|
+
|
|
244
|
+
data = await self._send_and_wait(
|
|
245
|
+
opcode=Opcode.LOGIN_BY_QR,
|
|
246
|
+
payload={"trackId": track_id},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
self.logger.debug(
|
|
250
|
+
"QR login data response opcode=%s seq=%s",
|
|
251
|
+
data.get("opcode"),
|
|
252
|
+
data.get("seq"),
|
|
253
|
+
)
|
|
254
|
+
payload_data = data.get("payload")
|
|
255
|
+
if isinstance(payload_data, dict):
|
|
256
|
+
return payload_data
|
|
257
|
+
else:
|
|
258
|
+
self.logger.error("Invalid payload data received")
|
|
259
|
+
raise ValueError("Invalid payload data received")
|
|
260
|
+
|
|
261
|
+
async def _login_by_qr(self) -> dict[str, Any]:
|
|
262
|
+
data = await self._request_qr_login()
|
|
263
|
+
|
|
264
|
+
poll_interval = data.get("pollingInterval")
|
|
265
|
+
link = data.get("qrLink")
|
|
266
|
+
track_id = data.get("trackId")
|
|
267
|
+
expires_at = data.get("expiresAt")
|
|
268
|
+
|
|
269
|
+
if not poll_interval or not link or not track_id or not expires_at:
|
|
270
|
+
self.logger.critical("Invalid QR login data received")
|
|
271
|
+
raise ValueError("Invalid QR login data received")
|
|
272
|
+
|
|
273
|
+
self.logger.info("Starting QR login flow")
|
|
274
|
+
self._print_qr(link)
|
|
275
|
+
|
|
276
|
+
poll_qr_task = asyncio.create_task(self._poll_qr_login(track_id, poll_interval))
|
|
277
|
+
|
|
278
|
+
while True:
|
|
279
|
+
now_ms = datetime.datetime.now().timestamp() * 1000
|
|
280
|
+
|
|
281
|
+
done, pending = await asyncio.wait(
|
|
282
|
+
[poll_qr_task],
|
|
283
|
+
timeout=1,
|
|
284
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if now_ms >= expires_at:
|
|
288
|
+
poll_qr_task.cancel()
|
|
289
|
+
self.logger.error("QR code expired before confirmation")
|
|
290
|
+
raise RuntimeError("QR code expired before confirmation")
|
|
291
|
+
|
|
292
|
+
if poll_qr_task in done:
|
|
293
|
+
exc = poll_qr_task.exception()
|
|
294
|
+
if exc is not None:
|
|
295
|
+
raise exc
|
|
296
|
+
elif poll_qr_task.result():
|
|
297
|
+
self.logger.info("QR login successful")
|
|
298
|
+
|
|
299
|
+
data = await self._get_qr_login_data(track_id)
|
|
300
|
+
return data
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
self.logger.error("QR login failed or expired")
|
|
304
|
+
raise RuntimeError("QR login failed or expired")
|
|
305
|
+
|
|
158
306
|
async def _submit_reg_info(
|
|
159
307
|
self, first_name: str, last_name: str | None, token: str
|
|
160
308
|
) -> dict[str, Any]:
|
|
@@ -167,9 +315,7 @@ class AuthMixin(ClientProtocol):
|
|
|
167
315
|
token=token,
|
|
168
316
|
).model_dump(by_alias=True)
|
|
169
317
|
|
|
170
|
-
data = await self._send_and_wait(
|
|
171
|
-
opcode=Opcode.AUTH_CONFIRM, payload=payload
|
|
172
|
-
)
|
|
318
|
+
data = await self._send_and_wait(opcode=Opcode.AUTH_CONFIRM, payload=payload)
|
|
173
319
|
if data.get("payload", {}).get("error"):
|
|
174
320
|
MixinsUtils.handle_error(data)
|
|
175
321
|
|
|
@@ -203,11 +349,7 @@ class AuthMixin(ClientProtocol):
|
|
|
203
349
|
raise ValueError("Invalid code format")
|
|
204
350
|
|
|
205
351
|
registration_response = await self._send_code(code, temp_token)
|
|
206
|
-
token = (
|
|
207
|
-
registration_response.get("tokenAttrs", {})
|
|
208
|
-
.get("REGISTER", {})
|
|
209
|
-
.get("token", "")
|
|
210
|
-
)
|
|
352
|
+
token = registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token", "")
|
|
211
353
|
if not token:
|
|
212
354
|
self.logger.critical("Failed to register, token not received")
|
|
213
355
|
raise ValueError("Failed to register, token not received")
|
pymax/mixins/socket.py
CHANGED
|
@@ -92,7 +92,7 @@ class SocketMixin(ClientProtocol):
|
|
|
92
92
|
payload_len_b = payload_len.to_bytes(4, "big")
|
|
93
93
|
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
|
|
94
94
|
|
|
95
|
-
async def connect(self, user_agent: UserAgentPayload) -> dict[str, Any]:
|
|
95
|
+
async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any]:
|
|
96
96
|
"""
|
|
97
97
|
Устанавливает соединение с сервером и выполняет handshake.
|
|
98
98
|
|
|
@@ -101,6 +101,8 @@ class SocketMixin(ClientProtocol):
|
|
|
101
101
|
:return: Результат handshake.
|
|
102
102
|
:rtype: dict[str, Any] | None
|
|
103
103
|
"""
|
|
104
|
+
if user_agent is None:
|
|
105
|
+
user_agent = UserAgentPayload()
|
|
104
106
|
if sys.version_info[:2] == (3, 12):
|
|
105
107
|
self.logger.warning(
|
|
106
108
|
"""
|
|
@@ -115,9 +117,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
115
117
|
raw_sock = await loop.run_in_executor(
|
|
116
118
|
None, lambda: socket.create_connection((self.host, self.port))
|
|
117
119
|
)
|
|
118
|
-
self._socket = self._ssl_context.wrap_socket(
|
|
119
|
-
raw_sock, server_hostname=self.host
|
|
120
|
-
)
|
|
120
|
+
self._socket = self._ssl_context.wrap_socket(raw_sock, server_hostname=self.host)
|
|
121
121
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
122
122
|
self.is_connected = True
|
|
123
123
|
self._incoming = asyncio.Queue()
|
|
@@ -159,9 +159,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
159
159
|
async def _parse_header(
|
|
160
160
|
self, loop: asyncio.AbstractEventLoop, sock: socket.socket
|
|
161
161
|
) -> bytes | None:
|
|
162
|
-
header = await loop.run_in_executor(
|
|
163
|
-
None, lambda: self._recv_exactly(sock=sock, n=10)
|
|
164
|
-
)
|
|
162
|
+
header = await loop.run_in_executor(None, lambda: self._recv_exactly(sock=sock, n=10))
|
|
165
163
|
if not header or len(header) < 10:
|
|
166
164
|
self.logger.info("Socket connection closed; exiting recv loop")
|
|
167
165
|
self.is_connected = False
|
|
@@ -182,9 +180,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
182
180
|
|
|
183
181
|
while remaining > 0:
|
|
184
182
|
min_read = min(remaining, 8192)
|
|
185
|
-
chunk = await loop.run_in_executor(
|
|
186
|
-
None, lambda: self._recv_exactly(sock, min_read)
|
|
187
|
-
)
|
|
183
|
+
chunk = await loop.run_in_executor(None, lambda: self._recv_exactly(sock, min_read))
|
|
188
184
|
if not chunk:
|
|
189
185
|
self.logger.error("Connection closed while reading payload")
|
|
190
186
|
break
|
|
@@ -245,9 +241,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
245
241
|
fut = self._file_upload_waiters.pop(id_, None)
|
|
246
242
|
if fut and not fut.done():
|
|
247
243
|
fut.set_result(data)
|
|
248
|
-
self.logger.debug(
|
|
249
|
-
"Fulfilled file upload waiter for %s=%s", key, id_
|
|
250
|
-
)
|
|
244
|
+
self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
|
|
251
245
|
|
|
252
246
|
async def _handle_message_notifications(self, data: dict) -> None:
|
|
253
247
|
if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
|
|
@@ -337,38 +331,35 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
337
331
|
sock = self._socket
|
|
338
332
|
loop = asyncio.get_running_loop()
|
|
339
333
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
header = await self._parse_header(loop, sock)
|
|
334
|
+
while True:
|
|
335
|
+
try:
|
|
336
|
+
header = await self._parse_header(loop, sock)
|
|
344
337
|
|
|
345
|
-
|
|
346
|
-
|
|
338
|
+
if not header:
|
|
339
|
+
break
|
|
347
340
|
|
|
348
|
-
|
|
341
|
+
datas = await self._recv_data(loop, header, sock)
|
|
349
342
|
|
|
350
|
-
|
|
351
|
-
|
|
343
|
+
if not datas:
|
|
344
|
+
continue
|
|
352
345
|
|
|
353
|
-
|
|
354
|
-
|
|
346
|
+
for data_item in datas:
|
|
347
|
+
seq = data_item.get("seq")
|
|
355
348
|
|
|
356
|
-
|
|
357
|
-
|
|
349
|
+
if self._handle_pending(seq, data_item):
|
|
350
|
+
continue
|
|
358
351
|
|
|
359
|
-
|
|
360
|
-
|
|
352
|
+
if self._incoming is not None:
|
|
353
|
+
await self._handle_incoming_queue(data_item)
|
|
361
354
|
|
|
362
|
-
|
|
355
|
+
await self._dispatch_incoming(data_item)
|
|
363
356
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
finally:
|
|
371
|
-
self.logger.warning("<<< Recv loop exited (socket)")
|
|
357
|
+
except asyncio.CancelledError:
|
|
358
|
+
self.logger.debug("Recv loop cancelled")
|
|
359
|
+
raise
|
|
360
|
+
except Exception:
|
|
361
|
+
self.logger.exception("Error in recv_loop; backing off briefly")
|
|
362
|
+
await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
|
|
372
363
|
|
|
373
364
|
def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
|
|
374
365
|
try:
|
|
@@ -418,9 +409,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
418
409
|
opcode=opcode.value,
|
|
419
410
|
payload=payload,
|
|
420
411
|
).model_dump(by_alias=True)
|
|
421
|
-
self.logger.debug(
|
|
422
|
-
"make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
|
|
423
|
-
)
|
|
412
|
+
self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
|
|
424
413
|
return msg
|
|
425
414
|
|
|
426
415
|
@override
|
|
@@ -472,9 +461,7 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
472
461
|
raise exc from conn_err
|
|
473
462
|
raise SocketNotConnectedError from conn_err
|
|
474
463
|
except Exception as exc:
|
|
475
|
-
self.logger.exception(
|
|
476
|
-
"Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
|
|
477
|
-
)
|
|
464
|
+
self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
|
|
478
465
|
raise SocketSendError from exc
|
|
479
466
|
|
|
480
467
|
finally:
|
pymax/mixins/websocket.py
CHANGED
|
@@ -50,9 +50,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
50
50
|
payload=payload,
|
|
51
51
|
).model_dump(by_alias=True)
|
|
52
52
|
|
|
53
|
-
self.logger.debug(
|
|
54
|
-
"make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
|
|
55
|
-
)
|
|
53
|
+
self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
|
|
56
54
|
return msg
|
|
57
55
|
|
|
58
56
|
async def _send_interactive_ping(self) -> None:
|
|
@@ -68,9 +66,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
68
66
|
self.logger.warning("Interactive ping failed", exc_info=True)
|
|
69
67
|
await asyncio.sleep(DEFAULT_PING_INTERVAL)
|
|
70
68
|
|
|
71
|
-
async def connect(
|
|
72
|
-
self, user_agent: UserAgentPayload | None = None
|
|
73
|
-
) -> dict[str, Any] | None:
|
|
69
|
+
async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any] | None:
|
|
74
70
|
"""
|
|
75
71
|
Устанавливает соединение WebSocket с сервером и выполняет handshake.
|
|
76
72
|
|
|
@@ -173,9 +169,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
173
169
|
fut = self._file_upload_waiters.pop(id_, None)
|
|
174
170
|
if fut and not fut.done():
|
|
175
171
|
fut.set_result(data)
|
|
176
|
-
self.logger.debug(
|
|
177
|
-
"Fulfilled file upload waiter for %s=%s", key, id_
|
|
178
|
-
)
|
|
172
|
+
self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
|
|
179
173
|
|
|
180
174
|
async def _handle_message_notifications(self, data: dict) -> None:
|
|
181
175
|
if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
|
|
@@ -359,9 +353,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
359
353
|
)
|
|
360
354
|
return data
|
|
361
355
|
except Exception:
|
|
362
|
-
self.logger.exception(
|
|
363
|
-
"Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
|
|
364
|
-
)
|
|
356
|
+
self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
|
|
365
357
|
raise RuntimeError("Send and wait failed")
|
|
366
358
|
finally:
|
|
367
359
|
self._pending.pop(msg["seq"], None)
|
|
@@ -442,7 +434,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
442
434
|
else:
|
|
443
435
|
return float(2**retry_count)
|
|
444
436
|
|
|
445
|
-
async def _sync(self) -> None:
|
|
437
|
+
async def _sync(self, user_agent: UserAgentPayload) -> None:
|
|
446
438
|
self.logger.info("Starting initial sync")
|
|
447
439
|
|
|
448
440
|
payload = SyncPayload(
|
|
@@ -453,6 +445,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
453
445
|
presence_sync=0,
|
|
454
446
|
drafts_sync=0,
|
|
455
447
|
chats_count=40,
|
|
448
|
+
user_agent=user_agent,
|
|
456
449
|
).model_dump(by_alias=True)
|
|
457
450
|
try:
|
|
458
451
|
data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
|
|
@@ -473,9 +466,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
473
466
|
self.logger.exception("Error parsing chat entry")
|
|
474
467
|
|
|
475
468
|
if raw_payload.get("profile", {}).get("contact"):
|
|
476
|
-
self.me = Me.from_dict(
|
|
477
|
-
raw_payload.get("profile", {}).get("contact", {})
|
|
478
|
-
)
|
|
469
|
+
self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
|
|
479
470
|
|
|
480
471
|
self.logger.info(
|
|
481
472
|
"Sync completed: dialogs=%d chats=%d channels=%d",
|
pymax/payloads.py
CHANGED
|
@@ -39,6 +39,20 @@ class BaseWebSocketMessage(BaseModel):
|
|
|
39
39
|
payload: dict[str, Any]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class UserAgentPayload(CamelModel):
|
|
43
|
+
device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
|
|
44
|
+
locale: str = Field(default=DEFAULT_LOCALE)
|
|
45
|
+
device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
|
|
46
|
+
os_version: str = Field(default=DEFAULT_OS_VERSION)
|
|
47
|
+
device_name: str = Field(default=DEFAULT_DEVICE_NAME)
|
|
48
|
+
header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
|
|
49
|
+
app_version: str = Field(default=DEFAULT_APP_VERSION)
|
|
50
|
+
screen: str = Field(default=DEFAULT_SCREEN)
|
|
51
|
+
timezone: str = Field(default=DEFAULT_TIMEZONE)
|
|
52
|
+
client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID)
|
|
53
|
+
build_number: int = Field(default=DEFAULT_BUILD_NUMBER)
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
class RequestCodePayload(CamelModel):
|
|
43
57
|
phone: str
|
|
44
58
|
type: AuthType = AuthType.START_AUTH
|
|
@@ -59,6 +73,21 @@ class SyncPayload(CamelModel):
|
|
|
59
73
|
presence_sync: int = 0
|
|
60
74
|
drafts_sync: int = 0
|
|
61
75
|
chats_count: int = 40
|
|
76
|
+
user_agent: UserAgentPayload = Field(
|
|
77
|
+
default_factory=lambda: UserAgentPayload(
|
|
78
|
+
device_type=DEFAULT_DEVICE_TYPE,
|
|
79
|
+
locale=DEFAULT_LOCALE,
|
|
80
|
+
device_locale=DEFAULT_DEVICE_LOCALE,
|
|
81
|
+
os_version=DEFAULT_OS_VERSION,
|
|
82
|
+
device_name=DEFAULT_DEVICE_NAME,
|
|
83
|
+
header_user_agent=DEFAULT_USER_AGENT,
|
|
84
|
+
app_version=DEFAULT_APP_VERSION,
|
|
85
|
+
screen=DEFAULT_SCREEN,
|
|
86
|
+
timezone=DEFAULT_TIMEZONE,
|
|
87
|
+
client_session_id=DEFAULT_CLIENT_SESSION_ID,
|
|
88
|
+
build_number=DEFAULT_BUILD_NUMBER,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
62
91
|
|
|
63
92
|
|
|
64
93
|
class ReplyLink(CamelModel):
|
|
@@ -276,20 +305,6 @@ class RemoveReactionPayload(CamelModel):
|
|
|
276
305
|
message_id: str
|
|
277
306
|
|
|
278
307
|
|
|
279
|
-
class UserAgentPayload(CamelModel):
|
|
280
|
-
device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
|
|
281
|
-
locale: str = Field(default=DEFAULT_LOCALE)
|
|
282
|
-
device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
|
|
283
|
-
os_version: str = Field(default=DEFAULT_OS_VERSION)
|
|
284
|
-
device_name: str = Field(default=DEFAULT_DEVICE_NAME)
|
|
285
|
-
header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
|
|
286
|
-
app_version: str = Field(default=DEFAULT_APP_VERSION)
|
|
287
|
-
screen: str = Field(default=DEFAULT_SCREEN)
|
|
288
|
-
timezone: str = Field(default=DEFAULT_TIMEZONE)
|
|
289
|
-
client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID)
|
|
290
|
-
build_number: int = Field(default=DEFAULT_BUILD_NUMBER)
|
|
291
|
-
|
|
292
|
-
|
|
293
308
|
class ReworkInviteLinkPayload(CamelModel):
|
|
294
309
|
revoke_private_link: bool = True
|
|
295
310
|
chat_id: int
|
pymax/static/constant.py
CHANGED
|
@@ -9,11 +9,11 @@ WEBSOCKET_ORIGIN: Final[Origin] = Origin("https://web.max.ru")
|
|
|
9
9
|
HOST: Final[str] = "api.oneme.ru"
|
|
10
10
|
PORT: Final[int] = 443
|
|
11
11
|
DEFAULT_TIMEOUT: Final[float] = 20.0
|
|
12
|
-
DEFAULT_DEVICE_TYPE: Final[str] = "
|
|
12
|
+
DEFAULT_DEVICE_TYPE: Final[str] = "DESKTOP"
|
|
13
13
|
DEFAULT_LOCALE: Final[str] = "ru"
|
|
14
14
|
DEFAULT_DEVICE_LOCALE: Final[str] = "ru"
|
|
15
15
|
DEFAULT_DEVICE_NAME: Final[str] = "Chrome"
|
|
16
|
-
DEFAULT_APP_VERSION: Final[str] = "25.12.
|
|
16
|
+
DEFAULT_APP_VERSION: Final[str] = "25.12.13"
|
|
17
17
|
DEFAULT_SCREEN: Final[str] = "1080x1920 1.0x"
|
|
18
18
|
DEFAULT_OS_VERSION: Final[str] = "Linux"
|
|
19
19
|
DEFAULT_USER_AGENT: Final[str] = (
|
pymax/static/enum.py
CHANGED
|
File without changes
|
|
File without changes
|