maxapi-python 1.2.3__py3-none-any.whl → 1.2.5__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,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.2.3
3
+ Version: 1.2.5
4
4
  Summary: Python wrapper для API мессенджера Max
5
- Project-URL: Homepage, https://github.com/ink-developer/PyMax
6
- Project-URL: Repository, https://github.com/ink-developer/PyMax
7
- Project-URL: Issues, https://github.com/ink-developer/PyMax/issues
5
+ Project-URL: Homepage, https://github.com/MaxApiTeam/PyMax
6
+ Project-URL: Repository, https://github.com/MaxApiTeam/PyMax
7
+ Project-URL: Issues, https://github.com/MaxApiTeam/PyMax/issues
8
8
  Author-email: ink <mail@gmail.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
@@ -18,6 +18,7 @@ Requires-Dist: lz4>=4.4.4
18
18
  Requires-Dist: msgpack>=1.1.1
19
19
  Requires-Dist: qrcode>=8.2
20
20
  Requires-Dist: sqlmodel>=0.0.24
21
+ Requires-Dist: ua-generator>=2.0.19
21
22
  Requires-Dist: websockets>=15.0
22
23
  Provides-Extra: test
23
24
  Requires-Dist: flake8; extra == 'test'
@@ -36,6 +37,7 @@ Description-Content-Type: text/markdown
36
37
  <strong>Python wrapper для API мессенджера Max</strong>
37
38
  </p>
38
39
 
40
+
39
41
  <p align="center">
40
42
  <img src="https://img.shields.io/badge/python-3.10+-3776AB.svg" alt="Python 3.11+">
41
43
  <img src="https://img.shields.io/badge/License-MIT-2f9872.svg" alt="License: MIT">
@@ -92,12 +94,12 @@ uv add -U maxapi-python
92
94
  **Вход по номеру телефона (DESKTOP):**
93
95
 
94
96
  ```python
95
- from pymax import MaxClient
97
+ from pymax import SocketMaxClient
96
98
  from pymax.payloads import UserAgentPayload
97
99
 
98
100
  ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13")
99
101
 
100
- client = MaxClient(
102
+ client = SocketMaxClient(
101
103
  phone="+79111111111",
102
104
  work_dir="cache",
103
105
  headers=ua,
@@ -195,6 +197,6 @@ if __name__ == "__main__":
195
197
 
196
198
  Спасибо всем за помощь в разработке!
197
199
 
198
- <a href="https://github.com/ink-developer/PyMax/graphs/contributors">
200
+ <a href="https://github.com/MaxApiTeam/PyMax/graphs/contributors">
199
201
  <img src="https://contrib.rocks/image?repo=ink-developer/PyMax" />
200
202
  </a>
@@ -0,0 +1,33 @@
1
+ pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
2
+ pymax/core.py,sha256=rJKUFlFjdCPywkOP5-lFUwIMnxNHk4oJLf1VYfEhqak,16137
3
+ pymax/crud.py,sha256=YC92TyhA2mr1tJCcfd-tvh8umtXKgqJfgiLo7nXUl3Q,3076
4
+ pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
5
+ pymax/files.py,sha256=nx7oZfIJ8ZvO-TuG5LzSmk8esbBtNrkKdFQgTQVbUA8,4063
6
+ pymax/filters.py,sha256=gSHPJ1Vi37HKPxf0jRRv9Q3iGwhiQjw1MGrCaouqHzs,4325
7
+ pymax/formatter.py,sha256=RJ_5VbY7Li8UM3xL1AvcXo8v1iYnY8GvDDkreaFqtnY,860
8
+ pymax/formatting.py,sha256=XRtuXJGweuNZevJFdPxksDftIrfuMGEA-AOUc_v6IhQ,2484
9
+ pymax/interfaces.py,sha256=GXHi4TjmXPb60KtLXe7CduQ8hSIVHXhA5Ak1OcvSS2w,19793
10
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
+ pymax/payloads.py,sha256=-hUgJYwCFfWFcWedAGYg1v82NliEfoTEteEEI7sgUEQ,8756
13
+ pymax/protocols.py,sha256=PoNvri9jFH6WBXGwugrkU6lwtwJEw0DO2s13HOH8_KI,4025
14
+ pymax/types.py,sha256=z1HXNl8CP_X3jTUlENlF9_vzZKdb7gF5PHG5d4rG3BY,37209
15
+ pymax/utils.py,sha256=HK6E6UYyjtUoJ2KXWeDycyiXm_9j5shZme6VFA2ixeM,2960
16
+ pymax/mixins/__init__.py,sha256=5sXJME34S1EssuDETaN4DLRH7vhMw_Q3Jmay9myAIZM,775
17
+ pymax/mixins/auth.py,sha256=f6IH_gvwB8hLvj7hNI21vgVhR1kju7ABRNjcRS656_o,22843
18
+ pymax/mixins/channel.py,sha256=Qi5ujw5X7QYx4Lq1XnvlJ3BYjmTFmYDfu7_jRcx4Mx8,5335
19
+ pymax/mixins/group.py,sha256=6bsWSx0ULZonDM_dJSC0EkqpZWd6tv9lmmKWj_gEaaw,15903
20
+ pymax/mixins/handler.py,sha256=duL3Q5Bvv8tjUhOKaDr_k7w09BeCwjVdws2ga7v_zNE,12432
21
+ pymax/mixins/message.py,sha256=MpUED92iWONkJRjb0f7lwPj9gYJhDuv0KKzbveOEaAk,33397
22
+ pymax/mixins/scheduler.py,sha256=K4HB9IfksnXPujJnkipIS5um9nuzC8EjbtQn65RtbfI,963
23
+ pymax/mixins/self.py,sha256=Wn9l3zDF5VzFzzisryOQknf3Ngl81Q98_9jqqbE9ZAw,9174
24
+ pymax/mixins/socket.py,sha256=0WCK9FDzkfgRpKeTfCB9GwuNkccww3RTI7P-W8_409c,10904
25
+ pymax/mixins/telemetry.py,sha256=9gbzEXTp9W9BLJ1wJVXOaHU7Q4inL981b-xq93xJyrw,3913
26
+ pymax/mixins/user.py,sha256=Xwb2fWM8RCq0SbVhlRyr1RBQGyjlaImtp0lT2PbgEqE,9420
27
+ pymax/mixins/websocket.py,sha256=CvrfMv0iNYpPtfwZXKm0V-ZOlSohtXZ7QH5fDET5Pec,5223
28
+ pymax/static/constant.py,sha256=hZby1gZmzWTzDgwW_EX1NGgqwdQxwcjl7pOE0thfxT0,2304
29
+ pymax/static/enum.py,sha256=YWNzDfPqSBBT8w9m3XesedjiTETn1juJn5UEOZ3zMe0,5477
30
+ maxapi_python-1.2.5.dist-info/METADATA,sha256=BbvzqfDdavSaBRJSDJ0DqxV_f5Nrzs-2-V0LXMlV52o,6790
31
+ maxapi_python-1.2.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
+ maxapi_python-1.2.5.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
33
+ maxapi_python-1.2.5.dist-info/RECORD,,
pymax/core.py CHANGED
@@ -17,15 +17,12 @@ from .crud import Database
17
17
  from .exceptions import (
18
18
  InvalidPhoneError,
19
19
  SocketNotConnectedError,
20
+ WebSocketNotConnectedError,
20
21
  )
21
22
  from .interfaces import BaseClient
22
23
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
23
24
  from .payloads import UserAgentPayload
24
- from .static.constant import (
25
- HOST,
26
- PORT,
27
- WEBSOCKET_URI,
28
- )
25
+ from .static.constant import HOST, PORT, SESSION_STORAGE_DB, WEBSOCKET_URI
29
26
 
30
27
  if TYPE_CHECKING:
31
28
  from collections.abc import Callable
@@ -34,7 +31,6 @@ if TYPE_CHECKING:
34
31
 
35
32
  from pymax.filters import BaseFilter
36
33
 
37
- from .filters import Filters
38
34
  from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
39
35
 
40
36
 
@@ -42,6 +38,7 @@ logger = logging.getLogger(__name__)
42
38
 
43
39
 
44
40
  class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
41
+ allowed_device_types: set[str] = {"WEB"}
45
42
  """
46
43
  Основной клиент для работы с WebSocket API сервиса Max.
47
44
 
@@ -49,6 +46,8 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
49
46
  :type phone: str
50
47
  :param uri: URI WebSocket сервера.
51
48
  :type uri: str, optional
49
+ :param session_name: Название сессии для хранения базы данных.
50
+ :type session_name: str, optional
52
51
  :param work_dir: Рабочая директория для хранения базы данных.
53
52
  :type work_dir: str, optional
54
53
  :param logger: Пользовательский логгер. Если не передан, используется логгер модуля с именем f"{__name__}.MaxClient".
@@ -81,7 +80,8 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
81
80
  self,
82
81
  phone: str,
83
82
  uri: str = WEBSOCKET_URI,
84
- headers: UserAgentPayload = UserAgentPayload(),
83
+ session_name: str = SESSION_STORAGE_DB,
84
+ headers: UserAgentPayload | None = None,
85
85
  token: str | None = None,
86
86
  send_fake_telemetry: bool = True,
87
87
  host: str = HOST,
@@ -120,7 +120,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
120
120
  self._users: dict[int, User] = {}
121
121
 
122
122
  self._work_dir: str = work_dir
123
- self._database_path: Path = Path(work_dir) / "session.db"
123
+ self._database_path: Path = Path(work_dir) / session_name
124
124
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
125
125
  self._database_path.touch(exist_ok=True)
126
126
  self._database = Database(self._work_dir)
@@ -132,6 +132,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
132
132
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
133
133
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
134
134
  self._background_tasks: set[asyncio.Task[Any]] = set()
135
+ self._stop_event = asyncio.Event()
135
136
 
136
137
  self._seq: int = 0
137
138
  self._error_count: int = 0
@@ -142,7 +143,10 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
142
143
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
143
144
 
144
145
  self._token = self._database.get_auth_token() or token
146
+ if headers is None:
147
+ headers = self._default_headers()
145
148
  self.user_agent = headers
149
+ self._validate_device_type()
146
150
  self._send_fake_telemetry: bool = send_fake_telemetry
147
151
  self._session_id: int = int(time.time() * 1000)
148
152
  self._action_id: int = 1
@@ -180,11 +184,24 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
180
184
  self._work_dir,
181
185
  )
182
186
 
187
+ @staticmethod
188
+ def _default_headers() -> UserAgentPayload:
189
+ return UserAgentPayload(device_type="WEB")
190
+
191
+ def _validate_device_type(self) -> None:
192
+ if self.user_agent.device_type not in self.allowed_device_types:
193
+ raise ValueError(
194
+ f"{self.__class__.__name__} does not support "
195
+ f"device_type={self.user_agent.device_type}"
196
+ )
197
+
183
198
  async def _wait_forever(self) -> None:
184
199
  try:
185
200
  await self.ws.wait_closed()
186
201
  except asyncio.CancelledError:
187
202
  self.logger.debug("wait_closed cancelled")
203
+ except WebSocketNotConnectedError:
204
+ self.logger.info("WebSocket not connected, exiting wait_forever")
188
205
 
189
206
  async def close(self) -> None:
190
207
  """
@@ -194,22 +211,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
194
211
  """
195
212
  try:
196
213
  self.logger.info("Closing client")
197
- if self._recv_task:
198
- self._recv_task.cancel()
199
- try:
200
- await self._recv_task
201
- except asyncio.CancelledError:
202
- self.logger.debug("recv_task cancelled")
203
- if self._outgoing_task:
204
- self._outgoing_task.cancel()
205
- try:
206
- await self._outgoing_task
207
- except asyncio.CancelledError:
208
- self.logger.debug("outgoing_task cancelled")
209
- if self._ws:
210
- await self._ws.close()
211
- self.is_connected = False
212
- self.logger.info("Client closed")
214
+ self._stop_event.set()
213
215
  except Exception:
214
216
  self.logger.exception("Error closing client")
215
217
 
@@ -250,7 +252,15 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
250
252
  :rtype: None
251
253
  """
252
254
  resp = await self._send_code(code, temp_token)
253
- token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
255
+
256
+ login_attrs = resp.get("tokenAttrs", {}).get("LOGIN", {})
257
+ password_challenge = resp.get("passwordChallenge")
258
+
259
+ if password_challenge and not login_attrs:
260
+ token = await self._two_factor_auth(password_challenge)
261
+ else:
262
+ token = login_attrs.get("token")
263
+
254
264
  if not token:
255
265
  raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
256
266
  self._token = token
@@ -279,10 +289,9 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
279
289
  :return: None
280
290
  :rtype: None
281
291
  """
282
-
283
- while True:
292
+ self.logger.info("Client starting")
293
+ while not self._stop_event.is_set():
284
294
  try:
285
- self.logger.info("Client starting")
286
295
  await self.connect(self.user_agent)
287
296
 
288
297
  if self.registration:
@@ -297,29 +306,45 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
297
306
  await self._login()
298
307
 
299
308
  await self._sync(self.user_agent)
300
-
301
309
  await self._post_login_tasks(sync=False)
302
310
 
303
- await self._wait_forever()
304
- self.logger.info("WebSocket closed (wait_forever exited)")
311
+ wait_task = asyncio.create_task(self._wait_forever())
312
+ stop_task = asyncio.create_task(self._stop_event.wait())
313
+
314
+ done, pending = await asyncio.wait(
315
+ [wait_task, stop_task], return_when=asyncio.FIRST_COMPLETED
316
+ )
317
+
318
+ for task in pending:
319
+ task.cancel()
320
+ with contextlib.suppress(asyncio.CancelledError):
321
+ await task
322
+
323
+ except asyncio.CancelledError:
324
+ self.logger.info("Client task cancelled, stopping")
325
+ break
305
326
  except Exception as e:
306
327
  self.logger.exception("Client start iteration failed")
307
- raise e
308
-
309
328
  finally:
310
- self.logger.debug("Cleaning up background tasks and pending futures")
311
-
312
329
  await self._cleanup_client()
313
330
 
314
- if not self.reconnect:
315
- self.logger.info("Reconnect disabled — exiting start()")
316
- return
331
+ if not self.reconnect or self._stop_event.is_set():
332
+ self.logger.info("Reconnect disabled or stop requested — exiting start()")
333
+ break
317
334
 
318
335
  self.logger.info("Reconnect enabled — restarting client")
319
336
  await asyncio.sleep(self.reconnect_delay)
320
337
 
338
+ self.logger.info("Client exited cleanly")
339
+
321
340
 
322
341
  class SocketMaxClient(SocketMixin, MaxClient):
342
+ allowed_device_types = {"ANDROID", "IOS", "DESKTOP"}
343
+
344
+ @staticmethod
345
+ def _default_headers() -> UserAgentPayload:
346
+ return UserAgentPayload(device_type="DESKTOP")
347
+
323
348
  @override
324
349
  async def _wait_forever(self):
325
350
  if self._recv_task:
pymax/files.py CHANGED
@@ -9,7 +9,10 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
12
+ def __init__(
13
+ self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None
14
+ ) -> None:
15
+ self.raw = raw
13
16
  self.url = url
14
17
  self.path = path
15
18
 
@@ -21,6 +24,9 @@ class BaseFile(ABC):
21
24
 
22
25
  @abstractmethod
23
26
  async def read(self) -> bytes:
27
+ if self.raw is not None:
28
+ return self.raw
29
+
24
30
  if self.url:
25
31
  async with (
26
32
  ClientSession() as session,
@@ -45,13 +51,24 @@ class Photo(BaseFile):
45
51
  ".bmp",
46
52
  } # FIXME: костыль ✅
47
53
 
48
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
54
+ def __init__(
55
+ self,
56
+ raw: bytes | None = None,
57
+ *,
58
+ url: str | None = None,
59
+ path: str | None = None,
60
+ name: str | None = None,
61
+ ) -> None:
49
62
  if path:
50
63
  self.file_name = Path(path).name
51
64
  elif url:
52
65
  self.file_name = Path(url).name
66
+ elif name:
67
+ self.file_name = name
68
+ else:
69
+ self.file_name = ""
53
70
 
54
- super().__init__(url, path)
71
+ super().__init__(raw=raw, url=url, path=path)
55
72
 
56
73
  def validate_photo(self) -> tuple[str, str] | None:
57
74
  if self.path:
@@ -83,7 +100,9 @@ class Photo(BaseFile):
83
100
 
84
101
 
85
102
  class Video(BaseFile):
86
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
103
+ def __init__(
104
+ self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None
105
+ ) -> None:
87
106
  self.file_name: str = ""
88
107
  if path:
89
108
  self.file_name = Path(path).name
@@ -92,7 +111,7 @@ class Video(BaseFile):
92
111
 
93
112
  if not self.file_name:
94
113
  raise ValueError("Either url or path must be provided.")
95
- super().__init__(url, path)
114
+ super().__init__(raw=raw, url=url, path=path)
96
115
 
97
116
  @override
98
117
  async def read(self) -> bytes:
@@ -100,7 +119,9 @@ class Video(BaseFile):
100
119
 
101
120
 
102
121
  class File(BaseFile):
103
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
122
+ def __init__(
123
+ self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None
124
+ ) -> None:
104
125
  self.file_name: str = ""
105
126
  if path:
106
127
  self.file_name = Path(path).name
@@ -110,7 +131,7 @@ class File(BaseFile):
110
131
  if not self.file_name:
111
132
  raise ValueError("Either url or path must be provided.")
112
133
 
113
- super().__init__(url, path)
134
+ super().__init__(raw=raw, url=url, path=path)
114
135
 
115
136
  @override
116
137
  async def read(self) -> bytes: