maxapi-python 1.0.1__py3-none-any.whl → 1.1.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.0.1
3
+ Version: 1.1.2
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/noxzion/PyMax
6
6
  Project-URL: Repository, https://github.com/noxzion/PyMax
@@ -14,11 +14,9 @@ Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.10
15
15
  Requires-Dist: aiofiles>=24.1.0
16
16
  Requires-Dist: aiohttp>=3.12.15
17
- Requires-Dist: build>=1.3.0
18
17
  Requires-Dist: lz4>=4.4.4
19
18
  Requires-Dist: msgpack>=1.1.1
20
19
  Requires-Dist: sqlmodel>=0.0.24
21
- Requires-Dist: twine>=6.2.0
22
20
  Requires-Dist: websockets>=11.0
23
21
  Description-Content-Type: text/markdown
24
22
 
@@ -1,27 +1,28 @@
1
- pymax/__init__.py,sha256=LiFVkKUiO1OTVppKcpnVON1xiH3j4XXgCuUCMMzSkmM,895
2
- pymax/core.py,sha256=1X0_-r8E5_mtRhdKDapueYSy_YG9sUUtiGenQ2-FqyY,6472
1
+ pymax/__init__.py,sha256=Wu5eniruWbotDUHBymdAyDNzt4g40r7Lb2pb9usQgjU,939
2
+ pymax/core.py,sha256=6fqnP-eAgbUTM6F0Kj09TPJ3IOgvkHz5EsjJTI18r5A,7813
3
3
  pymax/crud.py,sha256=Mk-c87GItS91BlJu6INDbw1-ovXyoB2D9rXHK8voxpU,3207
4
4
  pymax/exceptions.py,sha256=msS11MD7qZPm0qZ6O8fobTm-GTldm2IA3uQLTX6eDxc,919
5
5
  pymax/files.py,sha256=Tpv-43gS7I4Pwlaimb8mZ2B-ZkF3aMsrLYT20NPaqhE,2656
6
6
  pymax/filters.py,sha256=EejNuJMmSBhw3bUqDoqXEnCnLjGy_sw5aH3Vynpxc0A,1306
7
- pymax/interfaces.py,sha256=jAJRDu7_SVCaM5YJShWvoBPpCSEu7jsyNS8eDp8RMJk,2231
7
+ pymax/interfaces.py,sha256=_D6iMQI74Gdtl6-HMoE1acRFRHeITgOZPVFpt5pvoow,2394
8
8
  pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
9
9
  pymax/navigation.py,sha256=16c1_FZrw24uFlP6W5-F8OrEQE73bkQA3HSFqTdBtgo,5725
10
10
  pymax/payloads.py,sha256=yeBRxiMq6ixUQjMBBFcBDtBpYzqqfaEII3Z1kJq8pe8,3907
11
- pymax/static.py,sha256=rLWpGtDF12i9OoBANKUAgzfRqfoP88Zb9JCY2zNl834,4697
12
- pymax/types.py,sha256=_lYohGaGEjrJBhloR-SC3qdn7RXdUigYWJ9NWti0dCg,13738
11
+ pymax/static.py,sha256=wwSV1ue5s5buqWz6TvCzjzN2ZWI-wITposTRvcS151g,4738
12
+ pymax/types.py,sha256=SFjCG9GlFtJBDydd6pWtzFbJ6kTADZlxBIbKKRk7E1A,17820
13
13
  pymax/utils.py,sha256=F2TdoWfSwDLeh2uIcMIE_GTdXd7hU7gWti2i5P727bA,1364
14
- pymax/mixins/__init__.py,sha256=KQJg77oZewBga1wsltHOYfVFhobkB7ZMC3Pv0eLzEO8,411
14
+ pymax/mixins/__init__.py,sha256=-PSMwTVioS-VTy-EGfV-epaKFLy58R4N2b-rX6wJf-M,649
15
15
  pymax/mixins/auth.py,sha256=vTNSZ6AunvDIMPQAvgYozpIZaCWMYiMDiabCBI7Sm6c,3079
16
16
  pymax/mixins/channel.py,sha256=Stnf63GPtlQnsMPVEC9P0oardEOz50I4DCXN5H5s1SM,823
17
17
  pymax/mixins/group.py,sha256=QJCd5MLYCVRrClcuAuRkLV3oylJRAOaGw0xUqFm2uXk,7820
18
18
  pymax/mixins/handler.py,sha256=I1iNPaEgpvFnphaxV6liLwVaBCJ8sN6-h7908-_tPFk,2104
19
19
  pymax/mixins/message.py,sha256=0y7fO61zg9XG46SKxOPCOLMJW1LJBWfTi-PR3HjJfJo,10249
20
20
  pymax/mixins/self.py,sha256=V0gbkY3jfX9fnd7v06n4_s7P3HTPcS1KNPqUzA0vNi8,1169
21
+ pymax/mixins/socket.py,sha256=sgGxX7FmLyO3M82Nu2L-n00dCn0W2fV5TnrCipiKhaE,15738
21
22
  pymax/mixins/telemetry.py,sha256=0sQl6kvFVxobLthNAPNS9LzMrwwzZFA1xmOnvfiHWos,3522
22
23
  pymax/mixins/user.py,sha256=U-epgvLruTDHBCrLDE0N0iWeOypGE1_SU8cKD3TE90U,3045
23
- pymax/mixins/websocket.py,sha256=ru7QoYOFd8J2fDVG9GZYHLeYrZme-xuisNiCvm9P82Y,10076
24
- maxapi_python-1.0.1.dist-info/METADATA,sha256=vAKzkTZo3RfAg9R3ZrQWZ9MKMccyFSKzGOIaouzSo2A,5986
25
- maxapi_python-1.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
- maxapi_python-1.0.1.dist-info/licenses/LICENSE,sha256=oe-AGp86WMKawV4KmqF28Q0m-kGAhPfAOPrEUm4MnVw,1064
27
- maxapi_python-1.0.1.dist-info/RECORD,,
24
+ pymax/mixins/websocket.py,sha256=ndgcezJ1xqYCpaxc17rXxiXDQrTwTmtrgvx_d1H2Pcc,10094
25
+ maxapi_python-1.1.2.dist-info/METADATA,sha256=qkRDjZ3klgt7LkzNjP6d-0w06hlQ2ph914NxphMIIWw,5930
26
+ maxapi_python-1.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
+ maxapi_python-1.1.2.dist-info/licenses/LICENSE,sha256=oe-AGp86WMKawV4KmqF28Q0m-kGAhPfAOPrEUm4MnVw,1064
28
+ maxapi_python-1.1.2.dist-info/RECORD,,
pymax/__init__.py CHANGED
@@ -5,6 +5,7 @@ Python wrapper для API мессенджера Max
5
5
  from .core import (
6
6
  InvalidPhoneError,
7
7
  MaxClient,
8
+ SocketMaxClient,
8
9
  WebSocketNotConnectedError,
9
10
  )
10
11
  from .static import (
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "MessageStatus",
51
52
  "MessageType",
52
53
  "Opcode",
54
+ "SocketMaxClient",
53
55
  "User",
54
56
  "WebSocketNotConnectedError",
55
57
  ]
pymax/core.py CHANGED
@@ -1,25 +1,23 @@
1
1
  import asyncio
2
- import json
3
2
  import logging
4
- import re
3
+ import socket
4
+ import ssl
5
5
  import time
6
6
  from collections.abc import Awaitable, Callable
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
- import websockets
10
+ from typing_extensions import override
11
11
 
12
12
  from .crud import Database
13
13
  from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
14
- from .mixins import ApiMixin, WebSocketMixin
15
- from .payloads import (
16
- BaseWebSocketMessage,
17
- SyncPayload,
18
- )
19
- from .static import ChatType, Constants, Opcode
20
- from .types import Channel, Chat, Dialog, Me, Message, User, override
14
+ from .mixins import ApiMixin, SocketMixin, WebSocketMixin
15
+ from .static import Constants
16
+ from .types import Channel, Chat, Dialog, Me, Message, User
21
17
 
22
18
  if TYPE_CHECKING:
19
+ import websockets
20
+
23
21
  from .filters import Filter
24
22
 
25
23
  logger = logging.getLogger(__name__)
@@ -36,6 +34,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
36
34
  work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
37
35
  logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
38
36
  логгер модуля с именем f"{__name__}.MaxClient".
37
+ headers (dict[str, Any] | None): Заголовки для подключения к WebSocket. По умолчанию
38
+ Constants.DEFAULT_USER_AGENT.value.
39
+ token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
40
+ процесс логина по номеру телефона.
41
+ host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
42
+ port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
39
43
 
40
44
  Raises:
41
45
  InvalidPhoneError: Если формат номера телефона неверный.
@@ -48,6 +52,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
48
52
  headers: dict[str, Any] | None = Constants.DEFAULT_USER_AGENT.value,
49
53
  token: str | None = None,
50
54
  send_fake_telemetry: bool = True,
55
+ host: str = Constants.HOST.value,
56
+ port: int = Constants.PORT.value,
51
57
  work_dir: str = ".",
52
58
  logger: logging.Logger | None = None,
53
59
  ) -> None:
@@ -61,6 +67,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
61
67
  self._users: dict[int, User] = {}
62
68
  if not self._check_phone():
63
69
  raise InvalidPhoneError(self.phone)
70
+ self.host: str = host
71
+ self.port: int = port
64
72
  self._work_dir: str = work_dir
65
73
  self._database_path: Path = Path(work_dir) / "session.db"
66
74
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
@@ -85,6 +93,13 @@ class MaxClient(ApiMixin, WebSocketMixin):
85
93
  ] = []
86
94
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
87
95
  self._background_tasks: set[asyncio.Task[Any]] = set()
96
+ self._ssl_context = ssl.create_default_context()
97
+ self._ssl_context.set_ciphers("DEFAULT")
98
+ self._ssl_context.check_hostname = True
99
+ self._ssl_context.verify_mode = ssl.CERT_REQUIRED
100
+ self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
101
+ self._ssl_context.load_default_certs()
102
+ self._socket: socket.socket | None = None
88
103
  self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
89
104
  self._setup_logger()
90
105
 
@@ -103,6 +118,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
103
118
  handler.setFormatter(formatter)
104
119
  logger.addHandler(handler)
105
120
 
121
+ async def _wait_forever(self):
122
+ try:
123
+ await self.ws.wait_closed()
124
+ except asyncio.CancelledError:
125
+ self.logger.debug("wait_closed cancelled")
126
+
106
127
  async def close(self) -> None:
107
128
  try:
108
129
  self.logger.info("Closing client")
@@ -142,29 +163,31 @@ class MaxClient(ApiMixin, WebSocketMixin):
142
163
  if asyncio.iscoroutine(result):
143
164
  await result
144
165
 
145
- if self._ws:
146
- ping_task = asyncio.create_task(self._send_interactive_ping())
147
- if self._send_fake_telemetry:
148
- telemetry_task = asyncio.create_task(self._start())
149
- self._background_tasks.add(telemetry_task)
150
- telemetry_task.add_done_callback(
151
- lambda t: self._background_tasks.discard(t)
152
- or self._log_task_exception(t)
153
- )
154
- self._background_tasks.add(ping_task)
155
- ping_task.add_done_callback(
166
+ ping_task = asyncio.create_task(self._send_interactive_ping())
167
+ self._background_tasks.add(ping_task)
168
+ if self._send_fake_telemetry:
169
+ telemetry_task = asyncio.create_task(self._start())
170
+ self._background_tasks.add(telemetry_task)
171
+ telemetry_task.add_done_callback(
156
172
  lambda t: self._background_tasks.discard(t)
157
173
  or self._log_task_exception(t)
158
174
  )
159
-
160
- try:
161
- await self._ws.wait_closed()
162
- except asyncio.CancelledError:
163
- self.logger.debug("wait_closed cancelled")
175
+ ping_task.add_done_callback(
176
+ lambda t: self._background_tasks.discard(t)
177
+ or self._log_task_exception(t)
178
+ )
179
+ await self._wait_forever()
164
180
  except Exception:
165
181
  self.logger.exception("Client start failed")
166
182
 
167
183
 
168
- class SocketMaxClient:
169
- pass # нокс займись
170
- # нет не займусь
184
+ class SocketMaxClient(SocketMixin, MaxClient):
185
+ @override
186
+ async def _wait_forever(self):
187
+ if self._recv_task:
188
+ try:
189
+ await self._recv_task
190
+ except asyncio.CancelledError:
191
+ self.logger.debug("Socket recv_task cancelled")
192
+ except Exception as e:
193
+ self.logger.exception("Socket recv_task failed: %s", e)
pymax/interfaces.py CHANGED
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
+ import socket
4
+ import ssl
3
5
  from abc import ABC, abstractmethod
4
6
  from collections.abc import Awaitable, Callable
5
7
  from logging import Logger
@@ -38,6 +40,8 @@ class ClientProtocol(ABC):
38
40
  self.dialogs: list[Dialog] = []
39
41
  self.channels: list[Channel] = []
40
42
  self.me: Me | None = None
43
+ self.host: str
44
+ self.port: int
41
45
  self._users: dict[int, User] = {}
42
46
  self._work_dir: str
43
47
  self._database_path: Path
@@ -57,6 +61,8 @@ class ClientProtocol(ABC):
57
61
  ] = []
58
62
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
59
63
  self._background_tasks: set[asyncio.Task[Any]] = set()
64
+ self._ssl_context: ssl.SSLContext
65
+ self._socket: socket.socket | None = None
60
66
 
61
67
  @abstractmethod
62
68
  async def _send_and_wait(
pymax/mixins/__init__.py CHANGED
@@ -3,6 +3,7 @@ from .channel import ChannelMixin
3
3
  from .handler import HandlerMixin
4
4
  from .message import MessageMixin
5
5
  from .self import SelfMixin
6
+ from .socket import SocketMixin
6
7
  from .telemetry import TelemetryMixin
7
8
  from .user import UserMixin
8
9
  from .websocket import WebSocketMixin
@@ -18,3 +19,17 @@ class ApiMixin(
18
19
  TelemetryMixin,
19
20
  ):
20
21
  pass
22
+
23
+
24
+ __all__ = [
25
+ "ApiMixin",
26
+ "AuthMixin",
27
+ "ChannelMixin",
28
+ "HandlerMixin",
29
+ "MessageMixin",
30
+ "SelfMixin",
31
+ "SocketMixin",
32
+ "TelemetryMixin",
33
+ "UserMixin",
34
+ "WebSocketMixin",
35
+ ]
pymax/mixins/socket.py ADDED
@@ -0,0 +1,380 @@
1
+ import asyncio
2
+ import socket
3
+ import ssl
4
+ import sys
5
+ from typing import Any
6
+
7
+ import lz4.block
8
+ import msgpack
9
+ from typing_extensions import override
10
+
11
+ from pymax.filters import Message
12
+ from pymax.interfaces import ClientProtocol
13
+ from pymax.payloads import BaseWebSocketMessage, SyncPayload
14
+ from pymax.static import Opcode
15
+ from pymax.types import Channel, Chat, Dialog, Me
16
+
17
+
18
+ class SocketMixin(ClientProtocol):
19
+ @property
20
+ def sock(self) -> socket.socket:
21
+ if self._socket is None or not self.is_connected:
22
+ self.logger.critical("Socket not connected when access attempted")
23
+ raise ConnectionError("Socket not connected")
24
+ return self._socket
25
+
26
+ def _unpack_packet(self, data: bytes) -> dict[str, Any] | None:
27
+ ver = int.from_bytes(data[0:1], "big")
28
+ cmd = int.from_bytes(data[1:3], "big")
29
+ seq = int.from_bytes(data[3:4], "big")
30
+ opcode = int.from_bytes(data[4:6], "big")
31
+ packed_len = int.from_bytes(data[6:10], "big", signed=False)
32
+ comp_flag = packed_len >> 24
33
+ payload_length = packed_len & 0xFFFFFF
34
+ payload_bytes = data[10 : 10 + payload_length]
35
+
36
+ payload = None
37
+ if payload_bytes:
38
+ if comp_flag != 0:
39
+ uncompressed_size = int.from_bytes(payload_bytes[0:4], "big")
40
+ compressed_data = payload_bytes
41
+ try:
42
+ payload_bytes = lz4.block.decompress(
43
+ compressed_data,
44
+ uncompressed_size=99999,
45
+ )
46
+ except lz4.block.LZ4BlockError:
47
+ return None
48
+ payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
49
+
50
+ return {
51
+ "ver": ver,
52
+ "cmd": cmd,
53
+ "seq": seq,
54
+ "opcode": opcode,
55
+ "payload": payload,
56
+ }
57
+
58
+ def _pack_packet(
59
+ self, ver: int, cmd: int, seq: int, opcode: int, payload: dict[str, Any]
60
+ ) -> bytes:
61
+ ver_b = ver.to_bytes(1, "big")
62
+ cmd_b = cmd.to_bytes(2, "big")
63
+ seq_b = seq.to_bytes(1, "big")
64
+ opcode_b = opcode.to_bytes(2, "big")
65
+ payload_bytes = msgpack.packb(payload)
66
+ payload_len = len(payload_bytes) & 0xFFFFFF
67
+ self.logger.debug("Packing message: payload size=%d bytes", len(payload_bytes))
68
+ payload_len_b = payload_len.to_bytes(4, "big")
69
+ return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
70
+
71
+ async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
72
+ try:
73
+ if sys.version_info[:2] == (3, 12):
74
+ self.logger.warning(
75
+ """
76
+ ===============================================================
77
+ ⚠️⚠️ \033[0;31mWARNING: Python 3.12 detected!\033[0m ⚠️⚠️
78
+ Socket connections may be unstable, SSL issues are possible.
79
+ ===============================================================
80
+ """
81
+ )
82
+ self.logger.info("Connecting to socket %s:%s", self.host, self.port)
83
+ loop = asyncio.get_running_loop()
84
+ raw_sock = await loop.run_in_executor(
85
+ None, lambda: socket.create_connection((self.host, self.port))
86
+ )
87
+ self._socket = self._ssl_context.wrap_socket(
88
+ raw_sock, server_hostname=self.host
89
+ )
90
+ self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
91
+ self.is_connected = True
92
+ self._incoming = asyncio.Queue()
93
+ self._pending = {}
94
+ self._recv_task = asyncio.create_task(self._recv_loop())
95
+ self.logger.info("Socket connected, starting handshake")
96
+ return await self._handshake(user_agent)
97
+ except Exception as e:
98
+ self.logger.error("Failed to connect: %s", e, exc_info=True)
99
+ raise ConnectionError(f"Failed to connect: {e}")
100
+
101
+ async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
102
+ try:
103
+ self.logger.debug(
104
+ "Sending handshake with user_agent keys=%s", list(user_agent.keys())
105
+ )
106
+ resp = await self._send_and_wait(
107
+ opcode=Opcode.SESSION_INIT,
108
+ payload={"deviceId": str(self._device_id), "userAgent": user_agent},
109
+ )
110
+ self.logger.info("Handshake completed")
111
+ return resp
112
+ except Exception as e:
113
+ self.logger.error("Handshake failed: %s", e, exc_info=True)
114
+ raise ConnectionError(f"Handshake failed: {e}")
115
+
116
+ async def _recv_loop(self) -> None:
117
+ if self._socket is None:
118
+ self.logger.warning("Recv loop started without socket instance")
119
+ return
120
+
121
+ loop = asyncio.get_running_loop()
122
+
123
+ def _recv_exactly(n: int) -> bytes:
124
+ """Синхронная функция: читает ровно n байт из сокета или возвращает b'' если закрыт."""
125
+ buf = bytearray()
126
+ sock = self._socket
127
+ while len(buf) < n:
128
+ chunk = sock.recv(n - len(buf))
129
+ if not chunk:
130
+ return bytes(buf)
131
+ buf.extend(chunk)
132
+ return bytes(buf)
133
+
134
+ try:
135
+ while True:
136
+ try:
137
+ header = await loop.run_in_executor(None, lambda: _recv_exactly(10))
138
+ if not header or len(header) < 10:
139
+ self.logger.info("Socket connection closed; exiting recv loop")
140
+ self.is_connected = False
141
+ try:
142
+ self._socket.close()
143
+ except Exception:
144
+ pass
145
+ break
146
+
147
+ packed_len = int.from_bytes(header[6:10], "big", signed=False)
148
+ payload_length = packed_len & 0xFFFFFF
149
+ remaining = payload_length
150
+ payload = bytearray()
151
+
152
+ while remaining > 0:
153
+ chunk = await loop.run_in_executor(
154
+ None, lambda r=remaining: _recv_exactly(min(r, 8192))
155
+ )
156
+ if not chunk:
157
+ self.logger.error("Connection closed while reading payload")
158
+ break
159
+ payload.extend(chunk)
160
+ remaining -= len(chunk)
161
+
162
+ if remaining > 0:
163
+ self.logger.error(
164
+ "Incomplete payload received; skipping packet"
165
+ )
166
+ continue
167
+
168
+ raw = header + payload
169
+ if len(raw) < 10 + payload_length:
170
+ self.logger.error(
171
+ "Incomplete packet: expected %d bytes, got %d",
172
+ 10 + payload_length,
173
+ len(raw),
174
+ )
175
+ await asyncio.sleep(0.5)
176
+ continue
177
+
178
+ data = self._unpack_packet(raw)
179
+ if not data:
180
+ self.logger.warning("Failed to unpack packet, skipping")
181
+ continue
182
+
183
+ payload_objs = data.get("payload")
184
+ datas = (
185
+ [{**data, "payload": obj} for obj in payload_objs]
186
+ if isinstance(payload_objs, list)
187
+ else [data]
188
+ )
189
+
190
+ for data_item in datas:
191
+ seq = data_item.get("seq")
192
+ fut = self._pending.get(seq) if isinstance(seq, int) else None
193
+ if fut and not fut.done():
194
+ fut.set_result(data_item)
195
+ self.logger.debug(
196
+ "Matched response for pending seq=%s", seq
197
+ )
198
+ continue
199
+
200
+ if self._incoming is not None:
201
+ try:
202
+ self._incoming.put_nowait(data_item)
203
+ except asyncio.QueueFull:
204
+ self.logger.warning(
205
+ "Incoming queue full; dropping message seq=%s",
206
+ seq,
207
+ )
208
+
209
+ if (
210
+ data_item.get("opcode") == Opcode.NOTIF_MESSAGE
211
+ and self._on_message_handlers
212
+ ):
213
+ try:
214
+ for handler, filter in self._on_message_handlers:
215
+ payload = data_item.get("payload", {})
216
+ msg_dict = (
217
+ payload.get("message")
218
+ if isinstance(payload, dict)
219
+ else None
220
+ )
221
+ msg = (
222
+ Message.from_dict(msg_dict)
223
+ if msg_dict
224
+ else None
225
+ )
226
+ if msg and not msg.status:
227
+ if filter and not filter.match(msg):
228
+ continue
229
+ result = handler(msg)
230
+ if asyncio.iscoroutine(result):
231
+ task = asyncio.create_task(result)
232
+ self._background_tasks.add(task)
233
+ task.add_done_callback(
234
+ lambda t: self._background_tasks.discard(
235
+ t
236
+ )
237
+ or self._log_task_exception(t)
238
+ )
239
+ except Exception:
240
+ self.logger.exception("Error in on_message_handler")
241
+ except asyncio.CancelledError:
242
+ self.logger.debug("Recv loop cancelled")
243
+ break
244
+ except Exception:
245
+ self.logger.exception("Error in recv_loop; backing off briefly")
246
+ await asyncio.sleep(0.5)
247
+ finally:
248
+ self.logger.warning("<<< Recv loop exited (socket)")
249
+
250
+ def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
251
+ try:
252
+ exc = task.exception()
253
+ if exc:
254
+ self.logger.exception("Background task exception: %s", exc)
255
+ except Exception:
256
+ pass
257
+
258
+ async def _send_interactive_ping(self) -> None:
259
+ while self.is_connected:
260
+ try:
261
+ await self._send_and_wait(
262
+ opcode=Opcode.PING,
263
+ payload={"interactive": True},
264
+ cmd=0,
265
+ )
266
+ self.logger.debug("Interactive ping sent successfully (socket)")
267
+ except Exception:
268
+ self.logger.warning("Interactive ping failed (socket)", exc_info=True)
269
+ await asyncio.sleep(30)
270
+
271
+ def _make_message(
272
+ self, opcode: int, payload: dict[str, Any], cmd: int = 0
273
+ ) -> dict[str, Any]:
274
+ self._seq += 1
275
+ msg = BaseWebSocketMessage(
276
+ ver=11,
277
+ cmd=cmd,
278
+ seq=self._seq,
279
+ opcode=opcode,
280
+ payload=payload,
281
+ ).model_dump(by_alias=True)
282
+ self.logger.debug(
283
+ "make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
284
+ )
285
+ return msg
286
+
287
+ @override
288
+ async def _send_and_wait(
289
+ self,
290
+ opcode: int,
291
+ payload: dict[str, Any],
292
+ cmd: int = 0,
293
+ timeout: float = 10.0,
294
+ ) -> dict[str, Any]:
295
+ if not self.is_connected or self._socket is None:
296
+ raise ConnectionError("Socket not connected")
297
+ sock = self.sock
298
+ msg = self._make_message(opcode, payload, cmd)
299
+ loop = asyncio.get_running_loop()
300
+ fut: asyncio.Future[dict[str, Any]] = loop.create_future()
301
+ self._pending[msg["seq"]] = fut
302
+ try:
303
+ self.logger.debug(
304
+ "Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"]
305
+ )
306
+ packet = self._pack_packet(
307
+ msg["ver"], msg["cmd"], msg["seq"], msg["opcode"], msg["payload"]
308
+ )
309
+ await loop.run_in_executor(None, lambda: sock.sendall(packet))
310
+ data = await asyncio.wait_for(fut, timeout=timeout)
311
+ self.logger.debug(
312
+ "Received frame for seq=%s opcode=%s",
313
+ data.get("seq"),
314
+ data.get("opcode"),
315
+ )
316
+ return data
317
+
318
+ except (ssl.SSLEOFError, ssl.SSLError, ConnectionError):
319
+ self.logger.warning("Connection lost, reconnecting...")
320
+ self.is_connected = False
321
+ try:
322
+ await self._connect(self.user_agent)
323
+ except Exception:
324
+ self.logger.error("Reconnect failed", exc_info=True)
325
+ raise
326
+ except Exception:
327
+ self.logger.exception(
328
+ "Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
329
+ )
330
+ raise RuntimeError("Send and wait failed (socket)")
331
+
332
+ finally:
333
+ self._pending.pop(msg["seq"], None)
334
+
335
+ async def _sync(self) -> None:
336
+ try:
337
+ self.logger.info("Starting initial sync (socket)")
338
+ payload = SyncPayload(
339
+ interactive=True,
340
+ token=self._token,
341
+ chats_sync=0,
342
+ contacts_sync=0,
343
+ presence_sync=0,
344
+ drafts_sync=0,
345
+ chats_count=40,
346
+ ).model_dump(by_alias=True)
347
+ data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
348
+ raw_payload = data.get("payload", {})
349
+ if error := raw_payload.get("error"):
350
+ self.logger.error("Sync error: %s", error)
351
+ return
352
+ for raw_chat in raw_payload.get("chats", []):
353
+ try:
354
+ if raw_chat.get("type") == "DIALOG":
355
+ self.dialogs.append(Dialog.from_dict(raw_chat))
356
+ elif raw_chat.get("type") == "CHAT":
357
+ self.chats.append(Chat.from_dict(raw_chat))
358
+ elif raw_chat.get("type") == "CHANNEL":
359
+ self.channels.append(Channel.from_dict(raw_chat))
360
+ except Exception:
361
+ self.logger.exception("Error parsing chat entry (socket)")
362
+ if raw_payload.get("profile", {}).get("contact"):
363
+ self.me = Me.from_dict(
364
+ raw_payload.get("profile", {}).get("contact", {})
365
+ )
366
+ self.logger.info(
367
+ "Sync completed: dialogs=%d chats=%d channels=%d",
368
+ len(self.dialogs),
369
+ len(self.chats),
370
+ len(self.channels),
371
+ )
372
+ except Exception:
373
+ self.logger.exception("Sync failed (socket)")
374
+
375
+ @override
376
+ async def _get_chat(self, chat_id: int) -> Chat | None:
377
+ for chat in self.chats:
378
+ if chat.id == chat_id:
379
+ return chat
380
+ return None
pymax/mixins/websocket.py CHANGED
@@ -54,7 +54,7 @@ class WebSocketMixin(ClientProtocol):
54
54
  async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
55
55
  try:
56
56
  self.logger.info("Connecting to WebSocket %s", self.uri)
57
- self._ws = await websockets.connect(self.uri, origin="https://web.max.ru")
57
+ self._ws = await websockets.connect(self.uri, origin="https://web.max.ru") # type: ignore[]
58
58
  self.is_connected = True
59
59
  self._incoming = asyncio.Queue()
60
60
  self._pending = {}
pymax/static.py CHANGED
@@ -196,6 +196,8 @@ class AttachType(str, Enum):
196
196
  class Constants(Enum):
197
197
  PHONE_REGEX = r"^\+?\d{10,15}$"
198
198
  WEBSOCKET_URI = "wss://ws-api.oneme.ru/websocket"
199
+ HOST = "api.oneme.ru"
200
+ PORT = 443
199
201
  DEFAULT_TIMEOUT = 10.0
200
202
  DEFAULT_USER_AGENT = { # noqa: RUF012
201
203
  "deviceType": "WEB",
pymax/types.py CHANGED
@@ -39,6 +39,134 @@ class Names:
39
39
  return self.name
40
40
 
41
41
 
42
+ class PhotoAttach:
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ height: int,
47
+ width: int,
48
+ photo_id: int,
49
+ photo_token: str,
50
+ preview_data: str,
51
+ type: AttachType,
52
+ ) -> None:
53
+ self.base_url = base_url
54
+ self.height = height
55
+ self.width = width
56
+ self.photo_id = photo_id
57
+ self.photo_token = photo_token
58
+ self.preview_data = preview_data
59
+ self.type = type
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any]) -> "PhotoAttach":
63
+ return cls(
64
+ base_url=data["baseUrl"],
65
+ height=data["height"],
66
+ width=data["width"],
67
+ photo_id=data["photoId"],
68
+ photo_token=data["photoToken"],
69
+ preview_data=data["previewData"],
70
+ type=AttachType(data["_type"]),
71
+ )
72
+
73
+ @override
74
+ def __repr__(self) -> str:
75
+ return (
76
+ f"PhotoAttach(photo_id={self.photo_id!r}, base_url={self.base_url!r}, "
77
+ f"height={self.height!r}, width={self.width!r}, photo_token={self.photo_token!r}, "
78
+ f"preview_data={self.preview_data!r}, type={self.type!r})"
79
+ )
80
+
81
+ @override
82
+ def __str__(self) -> str:
83
+ return f"PhotoAttach: {self.photo_id}"
84
+
85
+
86
+ class VideoAttach:
87
+ def __init__(
88
+ self,
89
+ height: int,
90
+ width: int,
91
+ video_id: int,
92
+ duration: int,
93
+ preview_data: str,
94
+ type: AttachType,
95
+ thumbnail: str,
96
+ token: str,
97
+ video_type: int,
98
+ ) -> None:
99
+ self.height = height
100
+ self.width = width
101
+ self.video_id = video_id
102
+ self.duration = duration
103
+ self.preview_data = preview_data
104
+ self.type = type
105
+ self.thumbnail = thumbnail
106
+ self.token = token
107
+ self.video_type = video_type
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict[str, Any]) -> "VideoAttach":
111
+ return cls(
112
+ height=data["height"],
113
+ width=data["width"],
114
+ video_id=data["videoId"],
115
+ duration=data["duration"],
116
+ preview_data=data["previewData"],
117
+ type=AttachType(data["_type"]),
118
+ thumbnail=data["thumbnail"],
119
+ token=data["token"],
120
+ video_type=data["videoType"],
121
+ )
122
+
123
+ @override
124
+ def __repr__(self) -> str:
125
+ return (
126
+ f"VideoAttach(video_id={self.video_id!r}, height={self.height!r}, "
127
+ f"width={self.width!r}, duration={self.duration!r}, "
128
+ f"preview_data={self.preview_data!r}, type={self.type!r}, "
129
+ f"thumbnail={self.thumbnail!r}, token={self.token!r}, "
130
+ f"video_type={self.video_type!r})"
131
+ )
132
+
133
+ @override
134
+ def __str__(self) -> str:
135
+ return f"VideoAttach: {self.video_id}"
136
+
137
+
138
+ class FileAttach:
139
+ def __init__(
140
+ self, file_id: int, name: str, size: int, token: str, type: AttachType
141
+ ) -> None:
142
+ self.file_id = file_id
143
+ self.name = name
144
+ self.size = size
145
+ self.token = token
146
+ self.type = type
147
+
148
+ @classmethod
149
+ def from_dict(cls, data: dict[str, Any]) -> "FileAttach":
150
+ return cls(
151
+ file_id=data["fileId"],
152
+ name=data["name"],
153
+ size=data["size"],
154
+ token=data["token"],
155
+ type=AttachType(data["_type"]),
156
+ )
157
+
158
+ @override
159
+ def __repr__(self) -> str:
160
+ return (
161
+ f"FileAttach(file_id={self.file_id!r}, name={self.name!r}, "
162
+ f"size={self.size!r}, token={self.token!r}, type={self.type!r})"
163
+ )
164
+
165
+ @override
166
+ def __str__(self) -> str:
167
+ return f"FileAttach: {self.file_id}"
168
+
169
+
42
170
  class Me:
43
171
  def __init__(
44
172
  self,
@@ -111,7 +239,7 @@ class Message:
111
239
  text: str,
112
240
  status: MessageStatus | str | None,
113
241
  type: MessageType | str,
114
- attaches: list[Any],
242
+ attaches: list[PhotoAttach | VideoAttach | FileAttach],
115
243
  ) -> None:
116
244
  self.sender = sender
117
245
  self.elements = elements
@@ -126,6 +254,14 @@ class Message:
126
254
 
127
255
  @classmethod
128
256
  def from_dict(cls, data: dict[Any, Any]) -> "Message":
257
+ attaches = []
258
+ for a in data.get("attaches", []):
259
+ if a["_type"] == AttachType.PHOTO:
260
+ attaches.append(PhotoAttach.from_dict(a))
261
+ elif a["_type"] == AttachType.VIDEO:
262
+ attaches.append(VideoAttach.from_dict(a))
263
+ elif a["_type"] == AttachType.FILE:
264
+ attaches.append(FileAttach.from_dict(a))
129
265
  return cls(
130
266
  sender=data.get("sender"),
131
267
  elements=[Element.from_dict(e) for e in data.get("elements", [])],
@@ -134,7 +270,7 @@ class Message:
134
270
  time=data["time"],
135
271
  text=data["text"],
136
272
  type=data["type"],
137
- attaches=data.get("attaches", []),
273
+ attaches=attaches,
138
274
  status=data.get("status"),
139
275
  reaction_info=data.get("reactionInfo"),
140
276
  )
@@ -397,7 +533,7 @@ class User:
397
533
  return f"User {self.id}: {', '.join(str(n) for n in self.names)}"
398
534
 
399
535
 
400
- class Attach:
536
+ class Attach: # УБРАТЬ ГАДА!!!
401
537
  def __init__(
402
538
  self,
403
539
  _type: AttachType,