maxapi-python 1.2.3__py3-none-any.whl → 1.2.4__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.4
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,10 @@ Description-Content-Type: text/markdown
36
37
  <strong>Python wrapper для API мессенджера Max</strong>
37
38
  </p>
38
39
 
40
+ > [!IMPORTANT]
41
+ > (29.12.2025) Снова неожиданное изменение апи, теперь `MaxClient` с `device_type` любым кроме `WEB` не работает, для вохда по номеру телефона используйте `SocketMaxClient`
42
+
43
+
39
44
  <p align="center">
40
45
  <img src="https://img.shields.io/badge/python-3.10+-3776AB.svg" alt="Python 3.11+">
41
46
  <img src="https://img.shields.io/badge/License-MIT-2f9872.svg" alt="License: MIT">
@@ -92,12 +97,12 @@ uv add -U maxapi-python
92
97
  **Вход по номеру телефона (DESKTOP):**
93
98
 
94
99
  ```python
95
- from pymax import MaxClient
100
+ from pymax import SocketMaxClient
96
101
  from pymax.payloads import UserAgentPayload
97
102
 
98
103
  ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13")
99
104
 
100
- client = MaxClient(
105
+ client = SocketMaxClient(
101
106
  phone="+79111111111",
102
107
  work_dir="cache",
103
108
  headers=ua,
@@ -195,6 +200,6 @@ if __name__ == "__main__":
195
200
 
196
201
  Спасибо всем за помощь в разработке!
197
202
 
198
- <a href="https://github.com/ink-developer/PyMax/graphs/contributors">
203
+ <a href="https://github.com/MaxApiTeam/PyMax/graphs/contributors">
199
204
  <img src="https://contrib.rocks/image?repo=ink-developer/PyMax" />
200
205
  </a>
@@ -0,0 +1,33 @@
1
+ pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
2
+ pymax/core.py,sha256=oJEgLVWHjtTPqDd8I7mdHgYxw-Z567FZx-su-iIhVK8,15904
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=ZFmgZ9sK5j3jG8z7DuVovBkXIvPrN4H4rI0UBb2g4BY,19543
10
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
+ pymax/payloads.py,sha256=m0Fn0eEOo6hrdLB6ACh27ioT3SkYIMDMfLKaPbs2vdo,8147
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=nA1fX2ERzk6_WBSrbtBhvwkA5aUXgUdgweytqhZSAJU,14708
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=00uO4-8xer94i6Z6NDTUzzEQbs3NVMH1DB-KoPY6shU,10525
25
+ pymax/mixins/telemetry.py,sha256=EAbGyk8EB6QxijaFQ16vUmFPe6l-gEraCxXnAfhA3kY,3594
26
+ pymax/mixins/user.py,sha256=Xwb2fWM8RCq0SbVhlRyr1RBQGyjlaImtp0lT2PbgEqE,9420
27
+ pymax/mixins/websocket.py,sha256=XZ7lE8rKiNd3MXQRlS7Waz8TuoVWms9ldjCOF12pNTw,4891
28
+ pymax/static/constant.py,sha256=Gu1j4ibpaZL3tI6fUayS_jkyYOYNe9K-QlTUCOGviwQ,2260
29
+ pymax/static/enum.py,sha256=Scyi1pAUaaQlec1YQsU_nvlfxTiQZ6p7gntOJhWfBk4,4773
30
+ maxapi_python-1.2.4.dist-info/METADATA,sha256=hVzJYUCzdYWHmbcvAVlNYrEmvLRoWSVxwSbeNCL0Rqc,7069
31
+ maxapi_python-1.2.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
+ maxapi_python-1.2.4.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
33
+ maxapi_python-1.2.4.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
 
@@ -279,10 +281,9 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
279
281
  :return: None
280
282
  :rtype: None
281
283
  """
282
-
283
- while True:
284
+ self.logger.info("Client starting")
285
+ while not self._stop_event.is_set():
284
286
  try:
285
- self.logger.info("Client starting")
286
287
  await self.connect(self.user_agent)
287
288
 
288
289
  if self.registration:
@@ -297,29 +298,45 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
297
298
  await self._login()
298
299
 
299
300
  await self._sync(self.user_agent)
300
-
301
301
  await self._post_login_tasks(sync=False)
302
302
 
303
- await self._wait_forever()
304
- self.logger.info("WebSocket closed (wait_forever exited)")
303
+ wait_task = asyncio.create_task(self._wait_forever())
304
+ stop_task = asyncio.create_task(self._stop_event.wait())
305
+
306
+ done, pending = await asyncio.wait(
307
+ [wait_task, stop_task], return_when=asyncio.FIRST_COMPLETED
308
+ )
309
+
310
+ for task in pending:
311
+ task.cancel()
312
+ with contextlib.suppress(asyncio.CancelledError):
313
+ await task
314
+
315
+ except asyncio.CancelledError:
316
+ self.logger.info("Client task cancelled, stopping")
317
+ break
305
318
  except Exception as e:
306
319
  self.logger.exception("Client start iteration failed")
307
- raise e
308
-
309
320
  finally:
310
- self.logger.debug("Cleaning up background tasks and pending futures")
311
-
312
321
  await self._cleanup_client()
313
322
 
314
- if not self.reconnect:
315
- self.logger.info("Reconnect disabled — exiting start()")
316
- return
323
+ if not self.reconnect or self._stop_event.is_set():
324
+ self.logger.info("Reconnect disabled or stop requested — exiting start()")
325
+ break
317
326
 
318
327
  self.logger.info("Reconnect enabled — restarting client")
319
328
  await asyncio.sleep(self.reconnect_delay)
320
329
 
330
+ self.logger.info("Client exited cleanly")
331
+
321
332
 
322
333
  class SocketMaxClient(SocketMixin, MaxClient):
334
+ allowed_device_types = {"ANDROID", "IOS", "DESKTOP"}
335
+
336
+ @staticmethod
337
+ def _default_headers() -> UserAgentPayload:
338
+ return UserAgentPayload(device_type="DESKTOP")
339
+
323
340
  @override
324
341
  async def _wait_forever(self):
325
342
  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: