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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.21
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=aF-nGQG92KV9ZBIXWTxl2mpyfXgmZ2OQq2-OnouB2O4,19720
3
- pymax/crud.py,sha256=uphxDTCj1tGCrQ1lE2osLIZY7WLWbS-pkG46i2pU8Z0,3075
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=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
9
- pymax/interfaces.py,sha256=2wdS5BfguU9zH3yLSGBWtSq0_SWwcSVLHSd1Z4ZIS2g,4003
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=-GEJVXXlmJiFSTX4ToVNzmSZSrvSRe-BLOwYyRxGkWY,7280
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=zErX2vItVwHV8rAKWXX74dEEYSlECr2oYmnzEzw86eM,9661
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=9_TtTzB1mXH2U-odtwvOEXViUOCzPXBgTEXbMckhiq4,23122
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=m2swhSHIcFG6iABAik_oWxIpHfr0sxZ74I6VRU-iVO8,17809
27
- pymax/static/constant.py,sha256=BjSb2G6CTN6Idpo5GeLa-07YodnLNycYDmI7nOk0YGs,1111
28
- pymax/static/enum.py,sha256=Lf_qHbA2e2oK7X2uuiXInvaV1ql6hX-4wypFnokaazM,4584
29
- maxapi_python-1.1.21.dist-info/METADATA,sha256=_VPALryggwzxWHAcYKsKSb4rP094y1PmLEFKIOFolRs,5560
30
- maxapi_python-1.1.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
- maxapi_python-1.1.21.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
32
- maxapi_python-1.1.21.dist-info/RECORD,,
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 Self, override
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 .formatter import ColoredFormatter
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._on_reaction_change_handlers: list[
165
- tuple[Callable[[str, int, ReactionInfo], Any]]
166
- ] = []
167
- self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
168
- self._on_raw_receive_handlers: list[
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 _cleanup_client(self) -> None:
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(str(self._device_id), 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(str(self._device_id), self._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
- else:
393
- await self._sync()
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: str, token: str) -> None:
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 < len(text) and text[next_pos] == "\n"
51
- ) or (next_pos == len(text))
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
- Callable[[dict[str, Any]], Any | Awaitable[Any]]
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
- temp_token = await self.request_code(self.phone)
137
- if not temp_token or not isinstance(temp_token, str):
138
- self.logger.critical("Failed to request code: token missing")
139
- raise ValueError("Failed to request code")
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
- print("Введите код: ", end="", flush=True)
142
- code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
143
- if len(code) != 6 or not code.isdigit():
144
- self.logger.error("Invalid code format entered")
145
- raise ValueError("Invalid code format")
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(str(self._device_id), self._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
- try:
341
- while True:
342
- try:
343
- header = await self._parse_header(loop, sock)
334
+ while True:
335
+ try:
336
+ header = await self._parse_header(loop, sock)
344
337
 
345
- if not header:
346
- break
338
+ if not header:
339
+ break
347
340
 
348
- datas = await self._recv_data(loop, header, sock)
341
+ datas = await self._recv_data(loop, header, sock)
349
342
 
350
- if not datas:
351
- continue
343
+ if not datas:
344
+ continue
352
345
 
353
- for data_item in datas:
354
- seq = data_item.get("seq")
346
+ for data_item in datas:
347
+ seq = data_item.get("seq")
355
348
 
356
- if self._handle_pending(seq, data_item):
357
- continue
349
+ if self._handle_pending(seq, data_item):
350
+ continue
358
351
 
359
- if self._incoming is not None:
360
- await self._handle_incoming_queue(data_item)
352
+ if self._incoming is not None:
353
+ await self._handle_incoming_queue(data_item)
361
354
 
362
- await self._dispatch_incoming(data_item)
355
+ await self._dispatch_incoming(data_item)
363
356
 
364
- except asyncio.CancelledError:
365
- self.logger.debug("Recv loop cancelled")
366
- break
367
- except Exception:
368
- self.logger.exception("Error in recv_loop; backing off briefly")
369
- await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
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] = "WEB"
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.1"
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
@@ -141,6 +141,10 @@ class Opcode(int, Enum):
141
141
  FOLDERS_DELETE = 276
142
142
  NOTIF_FOLDERS = 277
143
143
 
144
+ GET_QR = 288
145
+ GET_QR_STATUS = 289
146
+ LOGIN_BY_QR = 291
147
+
144
148
 
145
149
  class ChatType(str, Enum):
146
150
  DIALOG = "DIALOG"