maxapi-python 1.0.1__tar.gz → 1.1.2__tar.gz
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.
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/PKG-INFO +1 -3
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/examples/example.py +21 -20
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/pyproject.toml +1 -3
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/__init__.py +2 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/core.py +52 -29
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/interfaces.py +6 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/__init__.py +15 -0
- maxapi_python-1.1.2/src/pymax/mixins/socket.py +380 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/websocket.py +1 -1
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/static.py +2 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/types.py +139 -3
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/.github/FUNDING.yml +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/.github/workflows/publish.yml +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/.gitignore +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/LICENSE +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/README.md +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/assets/icon.svg +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/assets/logo.svg +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/docs/api.md +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/docs/assets/icon.svg +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/docs/examples.md +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/docs/index.md +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/mkdocs.yml +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/ruff.toml +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/scripts/build.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/crud.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/exceptions.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/files.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/filters.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/auth.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/channel.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/group.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/handler.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/message.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/self.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/telemetry.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/mixins/user.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/models.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/navigation.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/payloads.py +0 -0
- {maxapi_python-1.0.1 → maxapi_python-1.1.2}/src/pymax/utils.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: maxapi-python
|
3
|
-
Version: 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,16 +1,16 @@
|
|
1
1
|
import asyncio
|
2
|
+
import logging
|
2
3
|
|
3
|
-
from pymax import MaxClient, Message
|
4
|
+
from pymax import MaxClient, Message, SocketMaxClient
|
4
5
|
from pymax.files import Photo
|
5
6
|
from pymax.filters import Filter
|
7
|
+
from pymax.static import AttachType
|
6
8
|
|
7
9
|
phone = "+1234567890"
|
8
10
|
|
9
11
|
|
10
|
-
client = MaxClient(
|
11
|
-
|
12
|
-
work_dir="cache",
|
13
|
-
)
|
12
|
+
# client = MaxClient(phone=phone, work_dir="cache")
|
13
|
+
client = SocketMaxClient(phone=phone, work_dir="cache")
|
14
14
|
|
15
15
|
|
16
16
|
async def main() -> None:
|
@@ -46,25 +46,26 @@ async def handle_message(message: Message) -> None:
|
|
46
46
|
@client.on_start
|
47
47
|
async def handle_start() -> None:
|
48
48
|
print("Client started successfully!")
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
49
|
+
history = await client.fetch_history(chat_id=0)
|
50
|
+
if history:
|
51
|
+
for message in history:
|
52
|
+
user_id = message.sender
|
53
|
+
user = await client.get_user(user_id)
|
54
54
|
|
55
|
-
|
56
|
-
|
55
|
+
if user:
|
56
|
+
print(f"{user.names[0].name}: {message.text}")
|
57
57
|
|
58
|
-
|
59
|
-
|
58
|
+
print(client.me.names[0].first_name)
|
59
|
+
user = await client.get_user(client.me.id)
|
60
60
|
|
61
|
-
|
62
|
-
# photo1 = Photo(path="tests/test.jpeg")
|
63
|
-
# photo2 = Photo(path="tests/test.jpg")
|
61
|
+
print(user.names[0].first_name)
|
64
62
|
|
65
|
-
|
66
|
-
|
67
|
-
|
63
|
+
photo1 = Photo(path="tests/test.jpeg")
|
64
|
+
photo2 = Photo(path="tests/test.jpg")
|
65
|
+
|
66
|
+
await client.send_message(
|
67
|
+
"Hello with photo!", chat_id=0, photos=[photo1, photo2], notify=True
|
68
|
+
)
|
68
69
|
|
69
70
|
|
70
71
|
if __name__ == "__main__":
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "maxapi-python"
|
3
|
-
version = "1.
|
3
|
+
version = "1.1.2"
|
4
4
|
description = "Python wrapper для API мессенджера Max"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -21,8 +21,6 @@ dependencies = [
|
|
21
21
|
"lz4>=4.4.4",
|
22
22
|
"aiohttp>=3.12.15",
|
23
23
|
"aiofiles>=24.1.0",
|
24
|
-
"build>=1.3.0",
|
25
|
-
"twine>=6.2.0",
|
26
24
|
]
|
27
25
|
|
28
26
|
[project.urls]
|
@@ -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
|
]
|
@@ -1,25 +1,23 @@
|
|
1
1
|
import asyncio
|
2
|
-
import json
|
3
2
|
import logging
|
4
|
-
import
|
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
|
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 .
|
16
|
-
|
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
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
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
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
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
|
-
|
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)
|
@@ -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(
|
@@ -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
|
+
]
|
@@ -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
|
@@ -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 = {}
|
@@ -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",
|
@@ -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[
|
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=
|
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,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|