maxapi-python 1.2.2__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.2
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,
@@ -116,10 +116,11 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
116
116
  self.dialogs: list[Dialog] = []
117
117
  self.channels: list[Channel] = []
118
118
  self.me: Me | None = None
119
+ self.contacts: list[User] = []
119
120
  self._users: dict[int, User] = {}
120
121
 
121
122
  self._work_dir: str = work_dir
122
- self._database_path: Path = Path(work_dir) / "session.db"
123
+ self._database_path: Path = Path(work_dir) / session_name
123
124
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
124
125
  self._database_path.touch(exist_ok=True)
125
126
  self._database = Database(self._work_dir)
@@ -131,6 +132,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
131
132
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
132
133
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
133
134
  self._background_tasks: set[asyncio.Task[Any]] = set()
135
+ self._stop_event = asyncio.Event()
134
136
 
135
137
  self._seq: int = 0
136
138
  self._error_count: int = 0
@@ -141,7 +143,10 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
141
143
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
142
144
 
143
145
  self._token = self._database.get_auth_token() or token
146
+ if headers is None:
147
+ headers = self._default_headers()
144
148
  self.user_agent = headers
149
+ self._validate_device_type()
145
150
  self._send_fake_telemetry: bool = send_fake_telemetry
146
151
  self._session_id: int = int(time.time() * 1000)
147
152
  self._action_id: int = 1
@@ -179,11 +184,24 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
179
184
  self._work_dir,
180
185
  )
181
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
+
182
198
  async def _wait_forever(self) -> None:
183
199
  try:
184
200
  await self.ws.wait_closed()
185
201
  except asyncio.CancelledError:
186
202
  self.logger.debug("wait_closed cancelled")
203
+ except WebSocketNotConnectedError:
204
+ self.logger.info("WebSocket not connected, exiting wait_forever")
187
205
 
188
206
  async def close(self) -> None:
189
207
  """
@@ -193,22 +211,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
193
211
  """
194
212
  try:
195
213
  self.logger.info("Closing client")
196
- if self._recv_task:
197
- self._recv_task.cancel()
198
- try:
199
- await self._recv_task
200
- except asyncio.CancelledError:
201
- self.logger.debug("recv_task cancelled")
202
- if self._outgoing_task:
203
- self._outgoing_task.cancel()
204
- try:
205
- await self._outgoing_task
206
- except asyncio.CancelledError:
207
- self.logger.debug("outgoing_task cancelled")
208
- if self._ws:
209
- await self._ws.close()
210
- self.is_connected = False
211
- self.logger.info("Client closed")
214
+ self._stop_event.set()
212
215
  except Exception:
213
216
  self.logger.exception("Error closing client")
214
217
 
@@ -278,10 +281,9 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
278
281
  :return: None
279
282
  :rtype: None
280
283
  """
281
-
282
- while True:
284
+ self.logger.info("Client starting")
285
+ while not self._stop_event.is_set():
283
286
  try:
284
- self.logger.info("Client starting")
285
287
  await self.connect(self.user_agent)
286
288
 
287
289
  if self.registration:
@@ -296,29 +298,45 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
296
298
  await self._login()
297
299
 
298
300
  await self._sync(self.user_agent)
299
-
300
301
  await self._post_login_tasks(sync=False)
301
302
 
302
- await self._wait_forever()
303
- 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
304
318
  except Exception as e:
305
319
  self.logger.exception("Client start iteration failed")
306
- raise e
307
-
308
320
  finally:
309
- self.logger.debug("Cleaning up background tasks and pending futures")
310
-
311
321
  await self._cleanup_client()
312
322
 
313
- if not self.reconnect:
314
- self.logger.info("Reconnect disabled — exiting start()")
315
- 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
316
326
 
317
327
  self.logger.info("Reconnect enabled — restarting client")
318
328
  await asyncio.sleep(self.reconnect_delay)
319
329
 
330
+ self.logger.info("Client exited cleanly")
331
+
320
332
 
321
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
+
322
340
  @override
323
341
  async def _wait_forever(self):
324
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,8 +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:
49
- super().__init__(url, path)
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:
62
+ if path:
63
+ self.file_name = Path(path).name
64
+ elif url:
65
+ self.file_name = Path(url).name
66
+ elif name:
67
+ self.file_name = name
68
+ else:
69
+ self.file_name = ""
70
+
71
+ super().__init__(raw=raw, url=url, path=path)
50
72
 
51
73
  def validate_photo(self) -> tuple[str, str] | None:
52
74
  if self.path:
@@ -78,7 +100,9 @@ class Photo(BaseFile):
78
100
 
79
101
 
80
102
  class Video(BaseFile):
81
- 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:
82
106
  self.file_name: str = ""
83
107
  if path:
84
108
  self.file_name = Path(path).name
@@ -87,7 +111,7 @@ class Video(BaseFile):
87
111
 
88
112
  if not self.file_name:
89
113
  raise ValueError("Either url or path must be provided.")
90
- super().__init__(url, path)
114
+ super().__init__(raw=raw, url=url, path=path)
91
115
 
92
116
  @override
93
117
  async def read(self) -> bytes:
@@ -95,7 +119,9 @@ class Video(BaseFile):
95
119
 
96
120
 
97
121
  class File(BaseFile):
98
- 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:
99
125
  self.file_name: str = ""
100
126
  if path:
101
127
  self.file_name = Path(path).name
@@ -105,7 +131,7 @@ class File(BaseFile):
105
131
  if not self.file_name:
106
132
  raise ValueError("Either url or path must be provided.")
107
133
 
108
- super().__init__(url, path)
134
+ super().__init__(raw=raw, url=url, path=path)
109
135
 
110
136
  @override
111
137
  async def read(self) -> bytes: