maxapi-python 1.1.14__py3-none-any.whl → 1.1.15__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.
- {maxapi_python-1.1.14.dist-info → maxapi_python-1.1.15.dist-info}/METADATA +2 -2
- {maxapi_python-1.1.14.dist-info → maxapi_python-1.1.15.dist-info}/RECORD +13 -13
- pymax/core.py +32 -0
- pymax/files.py +15 -9
- pymax/interfaces.py +26 -4
- pymax/mixins/auth.py +69 -6
- pymax/mixins/message.py +187 -126
- pymax/mixins/socket.py +93 -0
- pymax/mixins/websocket.py +127 -29
- pymax/payloads.py +21 -9
- pymax/static/enum.py +1 -0
- {maxapi_python-1.1.14.dist-info → maxapi_python-1.1.15.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.14.dist-info → maxapi_python-1.1.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.15
|
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
|
5
5
|
Project-URL: Homepage, https://github.com/ink-developer/PyMax
|
|
6
6
|
Project-URL: Repository, https://github.com/ink-developer/PyMax
|
|
@@ -145,7 +145,7 @@ if __name__ == "__main__":
|
|
|
145
145
|
|
|
146
146
|
## Документация
|
|
147
147
|
|
|
148
|
-
[WIP](https://ink-developer.github.io/)
|
|
148
|
+
[WIP](https://ink-developer.github.io/PyMax)
|
|
149
149
|
|
|
150
150
|
## Лицензия
|
|
151
151
|
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
pymax/__init__.py,sha256=jXY_nQKTdCOqXqJWNkyNIudoxfQO5p8wNmZYbuO1Rgs,980
|
|
2
|
-
pymax/core.py,sha256=
|
|
2
|
+
pymax/core.py,sha256=Yx8pLJ6s8MfxxaQrw5nbSBaYP5m9yZz51O78bUmK32I,8495
|
|
3
3
|
pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
|
|
4
4
|
pymax/exceptions.py,sha256=PWEjx7d70410XMvyVVyQUwR88CTgm8MUQ4RyInTut_M,2046
|
|
5
|
-
pymax/files.py,sha256=
|
|
5
|
+
pymax/files.py,sha256=oKWylKZUPlChWIo2PUHxoq4dcEbg4hAD6ITZCkiN6Xg,3086
|
|
6
6
|
pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
|
|
7
7
|
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
8
|
-
pymax/interfaces.py,sha256=
|
|
8
|
+
pymax/interfaces.py,sha256=onU6yxkU7sUjSeZWlHflONzZiWIspmXYS5Byeuvscvc,3288
|
|
9
9
|
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
10
10
|
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
11
|
-
pymax/payloads.py,sha256=
|
|
11
|
+
pymax/payloads.py,sha256=BwrZKVwsoBzMLWlskARMsk4xQ8bpnizoPbv_P4ez01Q,6190
|
|
12
12
|
pymax/types.py,sha256=mM2cbdwcevketJ-O1F_jeNe7WvYpooV0F1ojbELDdvE,31141
|
|
13
13
|
pymax/utils.py,sha256=r6Gm-fol6d4LdcdC12j6kQQNi1ywg7WVe24kwPh919A,1455
|
|
14
14
|
pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
|
|
15
|
-
pymax/mixins/auth.py,sha256=
|
|
15
|
+
pymax/mixins/auth.py,sha256=KlvVjdK3Hj7LeiCCWpGbQ_oPsww6KXQ_L_SxWD-yW3k,6526
|
|
16
16
|
pymax/mixins/channel.py,sha256=2oSLjZp0qm4lKAoFKGivhldajTv0F59_ozfukw7OLTI,4478
|
|
17
17
|
pymax/mixins/group.py,sha256=RAluoZUIh0KCKy37R0efjCMm0kptEjDyjjkDF7c97No,10533
|
|
18
18
|
pymax/mixins/handler.py,sha256=xlfZ9UX1iFaq7gh4E9hxPtGRwj-O2hiqx-C98IxTvQE,3877
|
|
19
|
-
pymax/mixins/message.py,sha256
|
|
19
|
+
pymax/mixins/message.py,sha256=-ixtb1TrNrNs3kZUe4fYdPVW3I5WdHiao9w-r_CfO8w,24369
|
|
20
20
|
pymax/mixins/self.py,sha256=TU1lWct5z9rNSsB4aVFial7UME3sfffTWlRAR2jpan8,1196
|
|
21
|
-
pymax/mixins/socket.py,sha256=
|
|
21
|
+
pymax/mixins/socket.py,sha256=OYhzOOvFgMOW4sR0wGPbObcFeoESd4pOyJAoIWSx6kU,23025
|
|
22
22
|
pymax/mixins/telemetry.py,sha256=kxsecLhDYeRGX5YviyXo4zJKQyjoyPkmPa_-vDrqMQQ,3625
|
|
23
23
|
pymax/mixins/user.py,sha256=CgFrcvpCefEB4bbuxLdcxW3W_ZZs5gupq74S45cU6Es,5538
|
|
24
|
-
pymax/mixins/websocket.py,sha256=
|
|
24
|
+
pymax/mixins/websocket.py,sha256=c0cxyR2QKmv87JIcr4ZIgz-M7Oa5-FgyE5Ex0i5fOYw,16541
|
|
25
25
|
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
26
|
-
pymax/static/enum.py,sha256=
|
|
27
|
-
maxapi_python-1.1.
|
|
28
|
-
maxapi_python-1.1.
|
|
29
|
-
maxapi_python-1.1.
|
|
30
|
-
maxapi_python-1.1.
|
|
26
|
+
pymax/static/enum.py,sha256=9DgJ8BNwLrqxTqQjpdq1vC8YYgFDlL3VFDOfgHsWQqY,4480
|
|
27
|
+
maxapi_python-1.1.15.dist-info/METADATA,sha256=lt3zV8IXZTKOT-v21Bw90vYl1uRkLtbZTmayergCpV8,6052
|
|
28
|
+
maxapi_python-1.1.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
29
|
+
maxapi_python-1.1.15.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
30
|
+
maxapi_python-1.1.15.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -4,6 +4,7 @@ import socket
|
|
|
4
4
|
import ssl
|
|
5
5
|
import time
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
7
8
|
|
|
8
9
|
from typing_extensions import override
|
|
9
10
|
|
|
@@ -36,6 +37,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
36
37
|
процесс логина по номеру телефона.
|
|
37
38
|
host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
|
|
38
39
|
port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
|
|
40
|
+
registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
|
|
41
|
+
first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
|
|
42
|
+
last_name (str | None, optional): Фамилия пользователя для регистрации.
|
|
43
|
+
send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
|
|
44
|
+
proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
|
|
45
|
+
(См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
|
|
39
46
|
|
|
40
47
|
Raises:
|
|
41
48
|
InvalidPhoneError: Если формат номера телефона неверный.
|
|
@@ -50,7 +57,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
50
57
|
send_fake_telemetry: bool = True,
|
|
51
58
|
host: str = HOST,
|
|
52
59
|
port: int = PORT,
|
|
60
|
+
proxy: str | Literal[True] | None = None,
|
|
53
61
|
work_dir: str = ".",
|
|
62
|
+
registration: bool = False,
|
|
63
|
+
first_name: str = "",
|
|
64
|
+
last_name: str | None = None,
|
|
54
65
|
logger: logging.Logger | None = None,
|
|
55
66
|
) -> None:
|
|
56
67
|
logger = logger or logging.getLogger(f"{__name__}.MaxClient")
|
|
@@ -62,12 +73,22 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
62
73
|
raise InvalidPhoneError(self.phone)
|
|
63
74
|
self.host: str = host
|
|
64
75
|
self.port: int = port
|
|
76
|
+
self.registration: bool = registration
|
|
77
|
+
self.first_name: str = first_name
|
|
78
|
+
self.last_name: str | None = last_name
|
|
79
|
+
self.proxy: str | Literal[True] | None = proxy
|
|
65
80
|
self._work_dir: str = work_dir
|
|
66
81
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
67
82
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
83
|
self._database_path.touch(exist_ok=True)
|
|
69
84
|
self._database = Database(self._work_dir)
|
|
85
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
86
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
87
|
+
self._error_count: int = 0
|
|
88
|
+
self._circuit_breaker: bool = False
|
|
89
|
+
self._last_error_time: float = 0.0
|
|
70
90
|
self._device_id = self._database.get_device_id()
|
|
91
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
71
92
|
self._token = self._database.get_auth_token() or token
|
|
72
93
|
self.user_agent = headers
|
|
73
94
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
@@ -111,6 +132,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
111
132
|
await self._recv_task
|
|
112
133
|
except asyncio.CancelledError:
|
|
113
134
|
self.logger.debug("recv_task cancelled")
|
|
135
|
+
if self._outgoing_task:
|
|
136
|
+
self._outgoing_task.cancel()
|
|
137
|
+
try:
|
|
138
|
+
await self._outgoing_task
|
|
139
|
+
except asyncio.CancelledError:
|
|
140
|
+
self.logger.debug("outgoing_task cancelled")
|
|
114
141
|
if self._ws:
|
|
115
142
|
await self._ws.close()
|
|
116
143
|
self.is_connected = False
|
|
@@ -127,6 +154,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
127
154
|
self.logger.info("Client starting")
|
|
128
155
|
await self._connect(self.user_agent)
|
|
129
156
|
|
|
157
|
+
if self.registration:
|
|
158
|
+
if not self.first_name:
|
|
159
|
+
raise ValueError("First name is required for registration")
|
|
160
|
+
await self._register(self.first_name, self.last_name)
|
|
161
|
+
|
|
130
162
|
if self._token and self._database.get_auth_token() is None:
|
|
131
163
|
self._database.update_auth_token(self._device_id, self._token)
|
|
132
164
|
|
pymax/files.py
CHANGED
|
@@ -9,9 +9,7 @@ from typing_extensions import override
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class BaseFile(ABC):
|
|
12
|
-
def __init__(
|
|
13
|
-
self, url: str | None = None, path: str | None = None
|
|
14
|
-
) -> None:
|
|
12
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
15
13
|
self.url = url
|
|
16
14
|
self.path = path
|
|
17
15
|
|
|
@@ -47,9 +45,7 @@ class Photo(BaseFile):
|
|
|
47
45
|
".bmp",
|
|
48
46
|
} # FIXME: костыль ✅
|
|
49
47
|
|
|
50
|
-
def __init__(
|
|
51
|
-
self, url: str | None = None, path: str | None = None
|
|
52
|
-
) -> None:
|
|
48
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
53
49
|
super().__init__(url, path)
|
|
54
50
|
|
|
55
51
|
def validate_photo(self) -> tuple[str, str] | None:
|
|
@@ -71,9 +67,7 @@ class Photo(BaseFile):
|
|
|
71
67
|
mime_type = mimetypes.guess_type(self.url)[0]
|
|
72
68
|
|
|
73
69
|
if not mime_type or not mime_type.startswith("image/"):
|
|
74
|
-
raise ValueError(
|
|
75
|
-
f"URL does not appear to be an image: {self.url}"
|
|
76
|
-
)
|
|
70
|
+
raise ValueError(f"URL does not appear to be an image: {self.url}")
|
|
77
71
|
|
|
78
72
|
return (extension[1:], mime_type)
|
|
79
73
|
return None
|
|
@@ -90,6 +84,18 @@ class Video(BaseFile):
|
|
|
90
84
|
|
|
91
85
|
|
|
92
86
|
class File(BaseFile):
|
|
87
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
88
|
+
self.file_name: str = ""
|
|
89
|
+
if path:
|
|
90
|
+
self.file_name = Path(path).name
|
|
91
|
+
elif url:
|
|
92
|
+
self.file_name = Path(url).name
|
|
93
|
+
|
|
94
|
+
if not self.file_name:
|
|
95
|
+
raise ValueError("Either url or path must be provided.")
|
|
96
|
+
|
|
97
|
+
super().__init__(url, path)
|
|
98
|
+
|
|
93
99
|
@override
|
|
94
100
|
async def read(self) -> bytes:
|
|
95
101
|
return await super().read()
|
pymax/interfaces.py
CHANGED
|
@@ -5,10 +5,12 @@ from abc import ABC, abstractmethod
|
|
|
5
5
|
from collections.abc import Awaitable, Callable
|
|
6
6
|
from logging import Logger
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
9
|
|
|
10
10
|
import websockets
|
|
11
11
|
|
|
12
|
+
from pymax.static.constant import DEFAULT_USER_AGENT
|
|
13
|
+
|
|
12
14
|
from .filters import Filter
|
|
13
15
|
from .payloads import UserAgentPayload
|
|
14
16
|
from .static.constant import DEFAULT_TIMEOUT
|
|
@@ -37,6 +39,10 @@ class ClientProtocol(ABC):
|
|
|
37
39
|
self.me: Me | None = None
|
|
38
40
|
self.host: str
|
|
39
41
|
self.port: int
|
|
42
|
+
self.proxy: str | Literal[True] | None
|
|
43
|
+
self.registration: bool
|
|
44
|
+
self.first_name: str
|
|
45
|
+
self.last_name: str | None
|
|
40
46
|
self._work_dir: str
|
|
41
47
|
self._database_path: Path
|
|
42
48
|
self._ws: websockets.ClientConnection | None = None
|
|
@@ -44,7 +50,14 @@ class ClientProtocol(ABC):
|
|
|
44
50
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
45
51
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
46
52
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
53
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
47
54
|
self.user_agent = UserAgentPayload()
|
|
55
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
56
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
57
|
+
self._error_count: int = 0
|
|
58
|
+
self._circuit_breaker: bool = False
|
|
59
|
+
self._last_error_time: float = 0.0
|
|
60
|
+
self.user_agent = DEFAULT_USER_AGENT
|
|
48
61
|
self._session_id: int
|
|
49
62
|
self._action_id: int = 0
|
|
50
63
|
self._current_screen: str = "chats_list_tab"
|
|
@@ -57,9 +70,7 @@ class ClientProtocol(ABC):
|
|
|
57
70
|
self._on_message_delete_handlers: list[
|
|
58
71
|
tuple[Callable[[Message], Any], Filter | None]
|
|
59
72
|
] = []
|
|
60
|
-
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None =
|
|
61
|
-
None
|
|
62
|
-
)
|
|
73
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
63
74
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
64
75
|
self._ssl_context: ssl.SSLContext
|
|
65
76
|
self._socket: socket.socket | None = None
|
|
@@ -77,3 +88,14 @@ class ClientProtocol(ABC):
|
|
|
77
88
|
@abstractmethod
|
|
78
89
|
async def _get_chat(self, chat_id: int) -> Chat | None:
|
|
79
90
|
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def _queue_message(
|
|
94
|
+
self,
|
|
95
|
+
opcode: int,
|
|
96
|
+
payload: dict[str, Any],
|
|
97
|
+
cmd: int = 0,
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
99
|
+
max_retries: int = 3,
|
|
100
|
+
) -> Message | None:
|
|
101
|
+
pass
|
pymax/mixins/auth.py
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import re
|
|
3
|
+
import sys
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from pymax.interfaces import ClientProtocol
|
|
6
|
-
from pymax.payloads import RequestCodePayload, SendCodePayload
|
|
7
|
+
from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
|
|
7
8
|
from pymax.static.constant import PHONE_REGEX
|
|
8
9
|
from pymax.static.enum import AuthType, Opcode
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class AuthMixin(ClientProtocol):
|
|
12
|
-
|
|
13
13
|
def __init__(self, token: str | None = None, *args, **kwargs) -> None:
|
|
14
14
|
super().__init__(*args, **kwargs)
|
|
15
15
|
self._token = token
|
|
@@ -55,9 +55,7 @@ class AuthMixin(ClientProtocol):
|
|
|
55
55
|
auth_token_type=AuthType.CHECK_CODE,
|
|
56
56
|
).model_dump(by_alias=True)
|
|
57
57
|
|
|
58
|
-
data = await self._send_and_wait(
|
|
59
|
-
opcode=Opcode.AUTH, payload=payload
|
|
60
|
-
)
|
|
58
|
+
data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
|
|
61
59
|
self.logger.debug(
|
|
62
60
|
"Send code response opcode=%s seq=%s",
|
|
63
61
|
data.get("opcode"),
|
|
@@ -75,13 +73,15 @@ class AuthMixin(ClientProtocol):
|
|
|
75
73
|
|
|
76
74
|
async def _login(self) -> None:
|
|
77
75
|
self.logger.info("Starting login flow")
|
|
76
|
+
|
|
78
77
|
request_code_payload = await self._request_code(self.phone)
|
|
79
78
|
temp_token = request_code_payload.get("token")
|
|
80
79
|
if not temp_token or not isinstance(temp_token, str):
|
|
81
80
|
self.logger.critical("Failed to request code: token missing")
|
|
82
81
|
raise ValueError("Failed to request code")
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
print("Введите код: ", end="", flush=True)
|
|
84
|
+
code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
|
|
85
85
|
if len(code) != 6 or not code.isdigit():
|
|
86
86
|
self.logger.error("Invalid code format entered")
|
|
87
87
|
raise ValueError("Invalid code format")
|
|
@@ -97,3 +97,66 @@ class AuthMixin(ClientProtocol):
|
|
|
97
97
|
self._token = token
|
|
98
98
|
self._database.update_auth_token(self._device_id, self._token)
|
|
99
99
|
self.logger.info("Login successful, token saved to database")
|
|
100
|
+
|
|
101
|
+
async def _submit_reg_info(
|
|
102
|
+
self, first_name: str, last_name: str | None, token: str
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
try:
|
|
105
|
+
self.logger.info("Submitting registration info")
|
|
106
|
+
|
|
107
|
+
payload = RegisterPayload(
|
|
108
|
+
first_name=first_name,
|
|
109
|
+
last_name=last_name,
|
|
110
|
+
token=token,
|
|
111
|
+
).model_dump(by_alias=True)
|
|
112
|
+
|
|
113
|
+
data = await self._send_and_wait(
|
|
114
|
+
opcode=Opcode.AUTH_CONFIRM, payload=payload
|
|
115
|
+
)
|
|
116
|
+
self.logger.debug(
|
|
117
|
+
"Registration info response opcode=%s seq=%s",
|
|
118
|
+
data.get("opcode"),
|
|
119
|
+
data.get("seq"),
|
|
120
|
+
)
|
|
121
|
+
payload_data = data.get("payload")
|
|
122
|
+
if isinstance(payload_data, dict):
|
|
123
|
+
return payload_data
|
|
124
|
+
else:
|
|
125
|
+
self.logger.error("Invalid payload data received")
|
|
126
|
+
raise ValueError("Invalid payload data received")
|
|
127
|
+
except Exception:
|
|
128
|
+
self.logger.error("Submit registration info failed", exc_info=True)
|
|
129
|
+
raise RuntimeError("Submit registration info failed")
|
|
130
|
+
|
|
131
|
+
async def _register(self, first_name: str, last_name: str | None = None) -> None:
|
|
132
|
+
self.logger.info("Starting registration flow")
|
|
133
|
+
|
|
134
|
+
request_code_payload = await self._request_code(self.phone)
|
|
135
|
+
temp_token = request_code_payload.get("token")
|
|
136
|
+
|
|
137
|
+
if not temp_token or not isinstance(temp_token, str):
|
|
138
|
+
self.logger.critical("Failed to request code: token missing")
|
|
139
|
+
raise ValueError("Failed to request code")
|
|
140
|
+
|
|
141
|
+
print("Введите код: ", end="", flush=True)
|
|
142
|
+
code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
|
|
143
|
+
if len(code) != 6 or not code.isdigit():
|
|
144
|
+
self.logger.error("Invalid code format entered")
|
|
145
|
+
raise ValueError("Invalid code format")
|
|
146
|
+
|
|
147
|
+
registration_response = await self._send_code(code, temp_token)
|
|
148
|
+
token: str | None = (
|
|
149
|
+
registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token")
|
|
150
|
+
)
|
|
151
|
+
if not token:
|
|
152
|
+
self.logger.critical("Failed to register, token not received")
|
|
153
|
+
raise ValueError("Failed to register, token not received")
|
|
154
|
+
|
|
155
|
+
data = await self._submit_reg_info(first_name, last_name, token)
|
|
156
|
+
self._token = data.get("token")
|
|
157
|
+
if not self._token:
|
|
158
|
+
self.logger.critical("Failed to register, token not received")
|
|
159
|
+
raise ValueError("Failed to register, token not received")
|
|
160
|
+
|
|
161
|
+
self._database.update_auth_token(self._device_id, self._token)
|
|
162
|
+
self.logger.info("Registration successful, token saved to database")
|
pymax/mixins/message.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import time
|
|
2
3
|
|
|
3
4
|
import aiohttp
|
|
4
5
|
from aiohttp import ClientSession
|
|
5
6
|
|
|
6
|
-
from pymax.files import Photo
|
|
7
|
+
from pymax.files import File, Photo
|
|
7
8
|
from pymax.formatting import Formatting
|
|
8
9
|
from pymax.interfaces import ClientProtocol
|
|
9
10
|
from pymax.payloads import (
|
|
10
11
|
AddReactionPayload,
|
|
12
|
+
AttachFilePayload,
|
|
11
13
|
AttachPhotoPayload,
|
|
12
14
|
DeleteMessagePayload,
|
|
13
15
|
EditMessagePayload,
|
|
@@ -22,8 +24,9 @@ from pymax.payloads import (
|
|
|
22
24
|
ReplyLink,
|
|
23
25
|
SendMessagePayload,
|
|
24
26
|
SendMessagePayloadMessage,
|
|
25
|
-
|
|
27
|
+
UploadPayload,
|
|
26
28
|
)
|
|
29
|
+
from pymax.static.constant import DEFAULT_TIMEOUT
|
|
27
30
|
from pymax.static.enum import AttachType, Opcode
|
|
28
31
|
from pymax.types import (
|
|
29
32
|
Attach,
|
|
@@ -35,10 +38,70 @@ from pymax.types import (
|
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class MessageMixin(ClientProtocol):
|
|
41
|
+
async def _upload_file(self, file: File) -> None | Attach:
|
|
42
|
+
try:
|
|
43
|
+
self.logger.info("Uploading file")
|
|
44
|
+
payload = UploadPayload().model_dump(by_alias=True)
|
|
45
|
+
data = await self._send_and_wait(
|
|
46
|
+
opcode=Opcode.FILE_UPLOAD,
|
|
47
|
+
payload=payload,
|
|
48
|
+
)
|
|
49
|
+
if error := data.get("payload", {}).get("error"):
|
|
50
|
+
self.logger.error("Upload file error: %s", error)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
url = data.get("payload", {}).get("info", [None])[0].get("url", None)
|
|
54
|
+
file_id = data.get("payload", {}).get("info", [None])[0].get("fileId", None)
|
|
55
|
+
if not url or not file_id:
|
|
56
|
+
self.logger.error("No upload URL or file ID received")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
file_bytes = await file.read()
|
|
60
|
+
|
|
61
|
+
headers = {
|
|
62
|
+
"Content-Disposition": f"attachment; filename={file.file_name}",
|
|
63
|
+
"Content-Range": f"0-{len(file_bytes) - 1}/{len(file_bytes)}",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loop = asyncio.get_running_loop()
|
|
67
|
+
fut: asyncio.Future[dict] = loop.create_future()
|
|
68
|
+
try:
|
|
69
|
+
self._file_upload_waiters[int(file_id)] = fut
|
|
70
|
+
except Exception:
|
|
71
|
+
self.logger.exception("Failed to register file upload waiter")
|
|
72
|
+
|
|
73
|
+
async with (
|
|
74
|
+
ClientSession() as session,
|
|
75
|
+
session.post(
|
|
76
|
+
url=url,
|
|
77
|
+
headers=headers,
|
|
78
|
+
data=file_bytes,
|
|
79
|
+
) as response,
|
|
80
|
+
):
|
|
81
|
+
if response.status != 200:
|
|
82
|
+
self.logger.error(f"Upload failed with status {response.status}")
|
|
83
|
+
# cleanup waiter
|
|
84
|
+
self._file_upload_waiters.pop(int(file_id), None)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT)
|
|
89
|
+
return Attach(_type=AttachType.FILE, file_id=file_id)
|
|
90
|
+
except asyncio.TimeoutError:
|
|
91
|
+
self.logger.error(
|
|
92
|
+
"Timed out waiting for file processing notification for fileId=%s",
|
|
93
|
+
file_id,
|
|
94
|
+
)
|
|
95
|
+
self._file_upload_waiters.pop(int(file_id), None)
|
|
96
|
+
return None
|
|
97
|
+
except Exception as e:
|
|
98
|
+
self.logger.exception("Upload file failed: %s", str(e))
|
|
99
|
+
return None
|
|
100
|
+
|
|
38
101
|
async def _upload_photo(self, photo: Photo) -> None | Attach:
|
|
39
102
|
try:
|
|
40
103
|
self.logger.info("Uploading photo")
|
|
41
|
-
payload =
|
|
104
|
+
payload = UploadPayload().model_dump(by_alias=True)
|
|
42
105
|
|
|
43
106
|
data = await self._send_and_wait(
|
|
44
107
|
opcode=Opcode.PHOTO_UPLOAD,
|
|
@@ -74,9 +137,7 @@ class MessageMixin(ClientProtocol):
|
|
|
74
137
|
) as response,
|
|
75
138
|
):
|
|
76
139
|
if response.status != 200:
|
|
77
|
-
self.logger.error(
|
|
78
|
-
f"Upload failed with status {response.status}"
|
|
79
|
-
)
|
|
140
|
+
self.logger.error(f"Upload failed with status {response.status}")
|
|
80
141
|
return None
|
|
81
142
|
|
|
82
143
|
result = await response.json()
|
|
@@ -99,52 +160,61 @@ class MessageMixin(ClientProtocol):
|
|
|
99
160
|
self.logger.exception("Upload photo failed: %s", str(e))
|
|
100
161
|
return None
|
|
101
162
|
|
|
163
|
+
async def _upload_attachment(self, attach: Photo | File) -> dict | None:
|
|
164
|
+
if isinstance(attach, Photo):
|
|
165
|
+
uploaded = await self._upload_photo(attach)
|
|
166
|
+
if uploaded and uploaded.photo_token:
|
|
167
|
+
return AttachPhotoPayload(photo_token=uploaded.photo_token).model_dump(
|
|
168
|
+
by_alias=True
|
|
169
|
+
)
|
|
170
|
+
elif isinstance(attach, File):
|
|
171
|
+
uploaded = await self._upload_file(attach)
|
|
172
|
+
if uploaded and uploaded.file_id:
|
|
173
|
+
return AttachFilePayload(file_id=uploaded.file_id).model_dump(
|
|
174
|
+
by_alias=True
|
|
175
|
+
)
|
|
176
|
+
self.logger.error(f"Attachment upload failed for {attach}")
|
|
177
|
+
return None
|
|
178
|
+
|
|
102
179
|
async def send_message(
|
|
103
180
|
self,
|
|
104
181
|
text: str,
|
|
105
182
|
chat_id: int,
|
|
106
183
|
notify: bool,
|
|
107
|
-
|
|
108
|
-
|
|
184
|
+
attachment: Photo | File | None = None,
|
|
185
|
+
attachments: list[Photo | File] | None = None,
|
|
109
186
|
reply_to: int | None = None,
|
|
187
|
+
use_queue: bool = False,
|
|
110
188
|
) -> Message | None:
|
|
111
189
|
"""
|
|
112
190
|
Отправляет сообщение в чат.
|
|
113
191
|
"""
|
|
114
192
|
try:
|
|
115
|
-
self.logger.info(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
self.logger.warning(
|
|
120
|
-
"Both photo and photos provided; using photos"
|
|
121
|
-
)
|
|
122
|
-
photo = None
|
|
193
|
+
self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
|
|
194
|
+
if attachments and attachment:
|
|
195
|
+
self.logger.warning("Both photo and photos provided; using photos")
|
|
196
|
+
attachment = None
|
|
123
197
|
attaches = []
|
|
124
|
-
if
|
|
125
|
-
self.logger.info("Uploading
|
|
126
|
-
|
|
127
|
-
if not
|
|
128
|
-
self.logger.error("
|
|
198
|
+
if attachment:
|
|
199
|
+
self.logger.info("Uploading attachment for message")
|
|
200
|
+
result = await self._upload_attachment(attachment)
|
|
201
|
+
if not result:
|
|
202
|
+
self.logger.error("Attachment upload failed, message not sent")
|
|
129
203
|
return None
|
|
130
|
-
attaches
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
AttachPhotoPayload(
|
|
142
|
-
photo_token=attach.photo_token
|
|
143
|
-
).model_dump(by_alias=True)
|
|
144
|
-
)
|
|
204
|
+
attaches.append(result)
|
|
205
|
+
|
|
206
|
+
elif attachments:
|
|
207
|
+
self.logger.info("Uploading multiple attachments for message")
|
|
208
|
+
for p in attachments:
|
|
209
|
+
result = await self._upload_attachment(p)
|
|
210
|
+
if result:
|
|
211
|
+
attaches.append(result)
|
|
212
|
+
else:
|
|
213
|
+
self.logger.error("One of attachments upload failed")
|
|
214
|
+
|
|
145
215
|
if not attaches:
|
|
146
216
|
self.logger.error(
|
|
147
|
-
"All
|
|
217
|
+
"All attachments uploads failed, message not sent"
|
|
148
218
|
)
|
|
149
219
|
return None
|
|
150
220
|
|
|
@@ -165,28 +235,27 @@ class MessageMixin(ClientProtocol):
|
|
|
165
235
|
cid=int(time.time() * 1000),
|
|
166
236
|
elements=elements,
|
|
167
237
|
attaches=attaches,
|
|
168
|
-
link=(
|
|
169
|
-
ReplyLink(message_id=str(reply_to))
|
|
170
|
-
if reply_to
|
|
171
|
-
else None
|
|
172
|
-
),
|
|
238
|
+
link=(ReplyLink(message_id=str(reply_to)) if reply_to else None),
|
|
173
239
|
),
|
|
174
240
|
notify=notify,
|
|
175
241
|
).model_dump(by_alias=True)
|
|
176
242
|
|
|
177
|
-
|
|
178
|
-
opcode=Opcode.MSG_SEND, payload=payload
|
|
179
|
-
|
|
180
|
-
if error := data.get("payload", {}).get("error"):
|
|
181
|
-
self.logger.error("Send message error: %s", error)
|
|
243
|
+
if use_queue:
|
|
244
|
+
await self._queue_message(opcode=Opcode.MSG_SEND, payload=payload)
|
|
245
|
+
self.logger.debug("Message queued for sending")
|
|
182
246
|
return None
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
247
|
+
else:
|
|
248
|
+
data = await self._send_and_wait(
|
|
249
|
+
opcode=Opcode.MSG_SEND, payload=payload
|
|
250
|
+
)
|
|
251
|
+
if error := data.get("payload", {}).get("error"):
|
|
252
|
+
self.logger.error("Send message error: %s", error)
|
|
253
|
+
return None
|
|
254
|
+
msg = (
|
|
255
|
+
Message.from_dict(data["payload"]) if data.get("payload") else None
|
|
256
|
+
)
|
|
257
|
+
self.logger.debug("send_message result: %r", msg)
|
|
258
|
+
return msg
|
|
190
259
|
except Exception:
|
|
191
260
|
self.logger.exception("Send message failed")
|
|
192
261
|
return None
|
|
@@ -196,47 +265,39 @@ class MessageMixin(ClientProtocol):
|
|
|
196
265
|
chat_id: int,
|
|
197
266
|
message_id: int,
|
|
198
267
|
text: str,
|
|
199
|
-
|
|
200
|
-
|
|
268
|
+
attachment: Photo | None = None,
|
|
269
|
+
attachments: list[Photo] | None = None,
|
|
270
|
+
use_queue: bool = False,
|
|
201
271
|
) -> Message | None:
|
|
202
|
-
"""
|
|
203
|
-
Редактирует сообщение.
|
|
204
|
-
"""
|
|
205
272
|
try:
|
|
206
273
|
self.logger.info(
|
|
207
274
|
"Editing message chat_id=%s message_id=%s", chat_id, message_id
|
|
208
275
|
)
|
|
209
276
|
|
|
210
|
-
if
|
|
211
|
-
self.logger.warning(
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
photo = None
|
|
277
|
+
if attachments and attachment:
|
|
278
|
+
self.logger.warning("Both photo and photos provided; using photos")
|
|
279
|
+
attachment = None
|
|
215
280
|
attaches = []
|
|
216
|
-
if
|
|
217
|
-
self.logger.info("Uploading
|
|
218
|
-
|
|
219
|
-
if not
|
|
220
|
-
self.logger.error("
|
|
281
|
+
if attachment:
|
|
282
|
+
self.logger.info("Uploading attachment for message")
|
|
283
|
+
result = await self._upload_attachment(attachment)
|
|
284
|
+
if not result:
|
|
285
|
+
self.logger.error("Attachment upload failed, message not sent")
|
|
221
286
|
return None
|
|
222
|
-
attaches
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
AttachPhotoPayload(
|
|
234
|
-
photo_token=attach.photo_token
|
|
235
|
-
).model_dump(by_alias=True)
|
|
236
|
-
)
|
|
287
|
+
attaches.append(result)
|
|
288
|
+
|
|
289
|
+
elif attachments:
|
|
290
|
+
self.logger.info("Uploading multiple attachments for message")
|
|
291
|
+
for p in attachment:
|
|
292
|
+
result = await self._upload_attachment(p)
|
|
293
|
+
if result:
|
|
294
|
+
attaches.append(result)
|
|
295
|
+
else:
|
|
296
|
+
self.logger.error("One of attachments upload failed")
|
|
297
|
+
|
|
237
298
|
if not attaches:
|
|
238
299
|
self.logger.error(
|
|
239
|
-
"All
|
|
300
|
+
"All attachments uploads failed, message not sent"
|
|
240
301
|
)
|
|
241
302
|
return None
|
|
242
303
|
|
|
@@ -257,24 +318,32 @@ class MessageMixin(ClientProtocol):
|
|
|
257
318
|
elements=elements,
|
|
258
319
|
attaches=attaches,
|
|
259
320
|
).model_dump(by_alias=True)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
321
|
+
|
|
322
|
+
if use_queue:
|
|
323
|
+
await self._queue_message(opcode=Opcode.MSG_EDIT, payload=payload)
|
|
324
|
+
self.logger.debug("Edit message queued for sending")
|
|
325
|
+
return None
|
|
326
|
+
else:
|
|
327
|
+
data = await self._send_and_wait(
|
|
328
|
+
opcode=Opcode.MSG_EDIT, payload=payload
|
|
329
|
+
)
|
|
330
|
+
if error := data.get("payload", {}).get("error"):
|
|
331
|
+
self.logger.error("Edit message error: %s", error)
|
|
332
|
+
msg = (
|
|
333
|
+
Message.from_dict(data["payload"]) if data.get("payload") else None
|
|
334
|
+
)
|
|
335
|
+
self.logger.debug("edit_message result: %r", msg)
|
|
336
|
+
return msg
|
|
272
337
|
except Exception:
|
|
273
338
|
self.logger.exception("Edit message failed")
|
|
274
339
|
return None
|
|
275
340
|
|
|
276
341
|
async def delete_message(
|
|
277
|
-
self,
|
|
342
|
+
self,
|
|
343
|
+
chat_id: int,
|
|
344
|
+
message_ids: list[int],
|
|
345
|
+
for_me: bool,
|
|
346
|
+
use_queue: bool = False,
|
|
278
347
|
) -> bool:
|
|
279
348
|
"""
|
|
280
349
|
Удаляет сообщения.
|
|
@@ -291,14 +360,19 @@ class MessageMixin(ClientProtocol):
|
|
|
291
360
|
chat_id=chat_id, message_ids=message_ids, for_me=for_me
|
|
292
361
|
).model_dump(by_alias=True)
|
|
293
362
|
|
|
294
|
-
|
|
295
|
-
opcode=Opcode.MSG_DELETE, payload=payload
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
363
|
+
if use_queue:
|
|
364
|
+
await self._queue_message(opcode=Opcode.MSG_DELETE, payload=payload)
|
|
365
|
+
self.logger.debug("Delete message queued for sending")
|
|
366
|
+
return True
|
|
367
|
+
else:
|
|
368
|
+
data = await self._send_and_wait(
|
|
369
|
+
opcode=Opcode.MSG_DELETE, payload=payload
|
|
370
|
+
)
|
|
371
|
+
if error := data.get("payload", {}).get("error"):
|
|
372
|
+
self.logger.error("Delete message error: %s", error)
|
|
373
|
+
return False
|
|
374
|
+
self.logger.debug("delete_message success")
|
|
375
|
+
return True
|
|
302
376
|
except Exception:
|
|
303
377
|
self.logger.exception("Delete message failed")
|
|
304
378
|
return False
|
|
@@ -324,9 +398,7 @@ class MessageMixin(ClientProtocol):
|
|
|
324
398
|
pin_message_id=message_id,
|
|
325
399
|
).model_dump(by_alias=True)
|
|
326
400
|
|
|
327
|
-
data = await self._send_and_wait(
|
|
328
|
-
opcode=Opcode.CHAT_UPDATE, payload=payload
|
|
329
|
-
)
|
|
401
|
+
data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload)
|
|
330
402
|
if error := data.get("payload", {}).get("error"):
|
|
331
403
|
self.logger.error("Pin message error: %s", error)
|
|
332
404
|
return False
|
|
@@ -376,8 +448,7 @@ class MessageMixin(ClientProtocol):
|
|
|
376
448
|
return None
|
|
377
449
|
|
|
378
450
|
messages = [
|
|
379
|
-
Message.from_dict(msg)
|
|
380
|
-
for msg in data["payload"].get("messages", [])
|
|
451
|
+
Message.from_dict(msg) for msg in data["payload"].get("messages", [])
|
|
381
452
|
]
|
|
382
453
|
self.logger.debug("History fetched: %d messages", len(messages))
|
|
383
454
|
return messages
|
|
@@ -405,9 +476,7 @@ class MessageMixin(ClientProtocol):
|
|
|
405
476
|
url (str): Ссылка на видео
|
|
406
477
|
"""
|
|
407
478
|
try:
|
|
408
|
-
self.logger.info(
|
|
409
|
-
"Getting video_id=%s message_id=%s", video_id, message_id
|
|
410
|
-
)
|
|
479
|
+
self.logger.info("Getting video_id=%s message_id=%s", video_id, message_id)
|
|
411
480
|
|
|
412
481
|
if self.is_connected and self._socket is not None:
|
|
413
482
|
payload = GetVideoPayload(
|
|
@@ -420,18 +489,14 @@ class MessageMixin(ClientProtocol):
|
|
|
420
489
|
video_id=video_id,
|
|
421
490
|
).model_dump(by_alias=True)
|
|
422
491
|
|
|
423
|
-
data = await self._send_and_wait(
|
|
424
|
-
opcode=Opcode.VIDEO_PLAY, payload=payload
|
|
425
|
-
)
|
|
492
|
+
data = await self._send_and_wait(opcode=Opcode.VIDEO_PLAY, payload=payload)
|
|
426
493
|
|
|
427
494
|
if error := data.get("payload", {}).get("error"):
|
|
428
495
|
self.logger.error("Get video error: %s", error)
|
|
429
496
|
return None
|
|
430
497
|
|
|
431
498
|
video = (
|
|
432
|
-
VideoRequest.from_dict(data["payload"])
|
|
433
|
-
if data.get("payload")
|
|
434
|
-
else None
|
|
499
|
+
VideoRequest.from_dict(data["payload"]) if data.get("payload") else None
|
|
435
500
|
)
|
|
436
501
|
self.logger.debug("result: %r", video)
|
|
437
502
|
return video
|
|
@@ -458,9 +523,7 @@ class MessageMixin(ClientProtocol):
|
|
|
458
523
|
url (str): Ссылка на скачивание файла
|
|
459
524
|
"""
|
|
460
525
|
try:
|
|
461
|
-
self.logger.info(
|
|
462
|
-
"Getting file_id=%s message_id=%s", file_id, message_id
|
|
463
|
-
)
|
|
526
|
+
self.logger.info("Getting file_id=%s message_id=%s", file_id, message_id)
|
|
464
527
|
if self.is_connected and self._socket is not None:
|
|
465
528
|
payload = GetFilePayload(
|
|
466
529
|
chat_id=chat_id, message_id=message_id, file_id=file_id
|
|
@@ -480,9 +543,7 @@ class MessageMixin(ClientProtocol):
|
|
|
480
543
|
return None
|
|
481
544
|
|
|
482
545
|
file = (
|
|
483
|
-
FileRequest.from_dict(data["payload"])
|
|
484
|
-
if data.get("payload")
|
|
485
|
-
else None
|
|
546
|
+
FileRequest.from_dict(data["payload"]) if data.get("payload") else None
|
|
486
547
|
)
|
|
487
548
|
self.logger.debug(" result: %r", file)
|
|
488
549
|
return file
|
pymax/mixins/socket.py
CHANGED
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import socket
|
|
3
3
|
import ssl
|
|
4
4
|
import sys
|
|
5
|
+
import time
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -116,8 +117,10 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
116
117
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
117
118
|
self.is_connected = True
|
|
118
119
|
self._incoming = asyncio.Queue()
|
|
120
|
+
self._outgoing = asyncio.Queue()
|
|
119
121
|
self._pending = {}
|
|
120
122
|
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
123
|
+
self._outgoing_task = asyncio.create_task(self._outgoing_loop())
|
|
121
124
|
self.logger.info("Socket connected, starting handshake")
|
|
122
125
|
return await self._handshake(user_agent)
|
|
123
126
|
except Exception as e:
|
|
@@ -431,6 +434,96 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
431
434
|
finally:
|
|
432
435
|
self._pending.pop(msg["seq"], None)
|
|
433
436
|
|
|
437
|
+
async def _outgoing_loop(self) -> None:
|
|
438
|
+
while self.is_connected:
|
|
439
|
+
try:
|
|
440
|
+
if self._outgoing is None:
|
|
441
|
+
await asyncio.sleep(0.1)
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if self._circuit_breaker:
|
|
445
|
+
if time.time() - self._last_error_time > 60:
|
|
446
|
+
self._circuit_breaker = False
|
|
447
|
+
self._error_count = 0
|
|
448
|
+
self.logger.info("Circuit breaker reset (socket)")
|
|
449
|
+
else:
|
|
450
|
+
await asyncio.sleep(5)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
message = await self._outgoing.get() # TODO: persistent msg q mb?
|
|
454
|
+
if not message:
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
retry_count = message.get("retry_count", 0)
|
|
458
|
+
max_retries = message.get("max_retries", 3)
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
await self._send_and_wait(
|
|
462
|
+
opcode=message["opcode"],
|
|
463
|
+
payload=message["payload"],
|
|
464
|
+
cmd=message.get("cmd", 0),
|
|
465
|
+
timeout=message.get("timeout", 10.0)
|
|
466
|
+
)
|
|
467
|
+
self.logger.debug("Message sent successfully from queue (socket)")
|
|
468
|
+
self._error_count = max(0, self._error_count - 1)
|
|
469
|
+
except Exception as e:
|
|
470
|
+
self._error_count += 1
|
|
471
|
+
self._last_error_time = time.time()
|
|
472
|
+
|
|
473
|
+
if self._error_count > 10: # TODO: export to constant
|
|
474
|
+
self._circuit_breaker = True
|
|
475
|
+
self.logger.warning("Circuit breaker activated due to %d consecutive errors (socket)", self._error_count)
|
|
476
|
+
await self._outgoing.put(message)
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
retry_delay = self._get_retry_delay(e, retry_count)
|
|
480
|
+
self.logger.warning("Failed to send message from queue (socket): %s (delay: %ds)", e, retry_delay)
|
|
481
|
+
|
|
482
|
+
if retry_count < max_retries:
|
|
483
|
+
message["retry_count"] = retry_count + 1
|
|
484
|
+
await asyncio.sleep(retry_delay)
|
|
485
|
+
await self._outgoing.put(message)
|
|
486
|
+
else:
|
|
487
|
+
self.logger.error("Message failed after %d retries, dropping (socket)", max_retries)
|
|
488
|
+
|
|
489
|
+
except Exception:
|
|
490
|
+
self.logger.exception("Error in outgoing loop (socket)")
|
|
491
|
+
await asyncio.sleep(1)
|
|
492
|
+
|
|
493
|
+
def _get_retry_delay(self, error: Exception, retry_count: int) -> float: # TODO: tune delays later
|
|
494
|
+
if isinstance(error, (ConnectionError, OSError, ssl.SSLError)):
|
|
495
|
+
return 1.0
|
|
496
|
+
elif isinstance(error, TimeoutError):
|
|
497
|
+
return 5.0
|
|
498
|
+
elif isinstance(error, SocketNotConnectedError):
|
|
499
|
+
return 2.0
|
|
500
|
+
else:
|
|
501
|
+
return 2 ** retry_count
|
|
502
|
+
|
|
503
|
+
async def _queue_message(
|
|
504
|
+
self,
|
|
505
|
+
opcode: int,
|
|
506
|
+
payload: dict[str, Any],
|
|
507
|
+
cmd: int = 0,
|
|
508
|
+
timeout: float = 10.0,
|
|
509
|
+
max_retries: int = 3,
|
|
510
|
+
) -> None:
|
|
511
|
+
if self._outgoing is None:
|
|
512
|
+
self.logger.warning("Outgoing queue not initialized (socket)")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
message = {
|
|
516
|
+
"opcode": opcode,
|
|
517
|
+
"payload": payload,
|
|
518
|
+
"cmd": cmd,
|
|
519
|
+
"timeout": timeout,
|
|
520
|
+
"retry_count": 0,
|
|
521
|
+
"max_retries": max_retries,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
await self._outgoing.put(message)
|
|
525
|
+
self.logger.debug("Message queued for sending (socket)")
|
|
526
|
+
|
|
434
527
|
async def _sync(self) -> None:
|
|
435
528
|
try:
|
|
436
529
|
self.logger.info("Starting initial sync (socket)")
|
pymax/mixins/websocket.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import time
|
|
3
4
|
from collections.abc import Callable
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
@@ -21,7 +22,6 @@ from pymax.types import Channel, Chat, Dialog, Me, Message
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class WebSocketMixin(ClientProtocol):
|
|
24
|
-
|
|
25
25
|
def __init__(self, token: str | None = None, *args, **kwargs) -> None:
|
|
26
26
|
super().__init__(*args, **kwargs)
|
|
27
27
|
self._token = token
|
|
@@ -29,9 +29,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
29
29
|
@property
|
|
30
30
|
def ws(self) -> websockets.ClientConnection:
|
|
31
31
|
if self._ws is None or not self.is_connected:
|
|
32
|
-
self.logger.critical(
|
|
33
|
-
"WebSocket not connected when access attempted"
|
|
34
|
-
)
|
|
32
|
+
self.logger.critical("WebSocket not connected when access attempted")
|
|
35
33
|
raise WebSocketNotConnectedError
|
|
36
34
|
return self._ws
|
|
37
35
|
|
|
@@ -71,12 +69,15 @@ class WebSocketMixin(ClientProtocol):
|
|
|
71
69
|
self._ws = await websockets.connect(
|
|
72
70
|
self.uri,
|
|
73
71
|
origin=WEBSOCKET_ORIGIN,
|
|
74
|
-
user_agent_header=user_agent.
|
|
72
|
+
user_agent_header=user_agent.header_user_agent,
|
|
73
|
+
proxy=self.proxy,
|
|
75
74
|
)
|
|
76
75
|
self.is_connected = True
|
|
77
76
|
self._incoming = asyncio.Queue()
|
|
77
|
+
self._outgoing = asyncio.Queue()
|
|
78
78
|
self._pending = {}
|
|
79
79
|
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
80
|
+
self._outgoing_task = asyncio.create_task(self._outgoing_loop())
|
|
80
81
|
self.logger.info("WebSocket connected, starting handshake")
|
|
81
82
|
return await self._handshake(user_agent)
|
|
82
83
|
except Exception as e:
|
|
@@ -141,9 +142,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
141
142
|
|
|
142
143
|
if fut and not fut.done():
|
|
143
144
|
fut.set_result(data)
|
|
144
|
-
self.logger.debug(
|
|
145
|
-
"Matched response for pending seq=%s", seq
|
|
146
|
-
)
|
|
145
|
+
self.logger.debug("Matched response for pending seq=%s", seq)
|
|
147
146
|
else:
|
|
148
147
|
if self._incoming is not None:
|
|
149
148
|
try:
|
|
@@ -154,6 +153,20 @@ class WebSocketMixin(ClientProtocol):
|
|
|
154
153
|
data.get("seq"),
|
|
155
154
|
)
|
|
156
155
|
|
|
156
|
+
try: # TODO: переделать, временное решение
|
|
157
|
+
if data.get("opcode") == Opcode.NOTIF_ATTACH:
|
|
158
|
+
file_id = data.get("payload", {}).get("fileId", None)
|
|
159
|
+
if isinstance(file_id, int):
|
|
160
|
+
fut = self._file_upload_waiters.pop(file_id, None)
|
|
161
|
+
if fut and not fut.done():
|
|
162
|
+
fut.set_result(data)
|
|
163
|
+
self.logger.debug(
|
|
164
|
+
"Fulfilled file upload waiter for fileId=%s",
|
|
165
|
+
file_id,
|
|
166
|
+
)
|
|
167
|
+
except Exception:
|
|
168
|
+
self.logger.exception("Error handling file upload notification")
|
|
169
|
+
|
|
157
170
|
if (
|
|
158
171
|
data.get("opcode") == Opcode.NOTIF_MESSAGE.value
|
|
159
172
|
and self._on_message_handlers
|
|
@@ -168,23 +181,17 @@ class WebSocketMixin(ClientProtocol):
|
|
|
168
181
|
for (
|
|
169
182
|
edit_handler,
|
|
170
183
|
edit_filter,
|
|
171
|
-
) in
|
|
172
|
-
self._on_message_edit_handlers
|
|
173
|
-
):
|
|
184
|
+
) in self._on_message_edit_handlers:
|
|
174
185
|
await self._process_message_handler(
|
|
175
186
|
edit_handler,
|
|
176
187
|
edit_filter,
|
|
177
188
|
msg,
|
|
178
189
|
)
|
|
179
|
-
elif
|
|
180
|
-
msg.status == MessageStatus.REMOVED
|
|
181
|
-
):
|
|
190
|
+
elif msg.status == MessageStatus.REMOVED:
|
|
182
191
|
for (
|
|
183
192
|
remove_handler,
|
|
184
193
|
remove_filter,
|
|
185
|
-
) in
|
|
186
|
-
self._on_message_delete_handlers
|
|
187
|
-
):
|
|
194
|
+
) in self._on_message_delete_handlers:
|
|
188
195
|
await self._process_message_handler(
|
|
189
196
|
remove_handler,
|
|
190
197
|
remove_filter,
|
|
@@ -194,19 +201,13 @@ class WebSocketMixin(ClientProtocol):
|
|
|
194
201
|
handler, filter, msg
|
|
195
202
|
)
|
|
196
203
|
except Exception:
|
|
197
|
-
self.logger.exception(
|
|
198
|
-
"Error in on_message_handler"
|
|
199
|
-
)
|
|
204
|
+
self.logger.exception("Error in on_message_handler")
|
|
200
205
|
|
|
201
206
|
except websockets.exceptions.ConnectionClosed:
|
|
202
|
-
self.logger.info(
|
|
203
|
-
"WebSocket connection closed; exiting recv loop"
|
|
204
|
-
)
|
|
207
|
+
self.logger.info("WebSocket connection closed; exiting recv loop")
|
|
205
208
|
break
|
|
206
209
|
except Exception:
|
|
207
|
-
self.logger.exception(
|
|
208
|
-
"Error in recv_loop; backing off briefly"
|
|
209
|
-
)
|
|
210
|
+
self.logger.exception("Error in recv_loop; backing off briefly")
|
|
210
211
|
await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
|
|
211
212
|
|
|
212
213
|
def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
|
|
@@ -218,6 +219,30 @@ class WebSocketMixin(ClientProtocol):
|
|
|
218
219
|
self.logger.exception("Error retrieving task exception: %s", e)
|
|
219
220
|
pass
|
|
220
221
|
|
|
222
|
+
async def _queue_message(
|
|
223
|
+
self,
|
|
224
|
+
opcode: int,
|
|
225
|
+
payload: dict[str, Any],
|
|
226
|
+
cmd: int = 0,
|
|
227
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
228
|
+
max_retries: int = 3,
|
|
229
|
+
) -> None:
|
|
230
|
+
if self._outgoing is None:
|
|
231
|
+
self.logger.warning("Outgoing queue not initialized")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
message = {
|
|
235
|
+
"opcode": opcode,
|
|
236
|
+
"payload": payload,
|
|
237
|
+
"cmd": cmd,
|
|
238
|
+
"timeout": timeout,
|
|
239
|
+
"retry_count": 0,
|
|
240
|
+
"max_retries": max_retries,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await self._outgoing.put(message)
|
|
244
|
+
self.logger.debug("Message queued for sending")
|
|
245
|
+
|
|
221
246
|
@override
|
|
222
247
|
async def _send_and_wait(
|
|
223
248
|
self,
|
|
@@ -255,6 +280,81 @@ class WebSocketMixin(ClientProtocol):
|
|
|
255
280
|
finally:
|
|
256
281
|
self._pending.pop(msg["seq"], None)
|
|
257
282
|
|
|
283
|
+
async def _outgoing_loop(self) -> None:
|
|
284
|
+
while self.is_connected:
|
|
285
|
+
try:
|
|
286
|
+
if self._outgoing is None:
|
|
287
|
+
await asyncio.sleep(0.1)
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if self._circuit_breaker:
|
|
291
|
+
if time.time() - self._last_error_time > 60:
|
|
292
|
+
self._circuit_breaker = False
|
|
293
|
+
self._error_count = 0
|
|
294
|
+
self.logger.info("Circuit breaker reset")
|
|
295
|
+
else:
|
|
296
|
+
await asyncio.sleep(5)
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
message = await self._outgoing.get() # TODO: persistent msg q mb?
|
|
300
|
+
if not message:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
retry_count = message.get("retry_count", 0)
|
|
304
|
+
max_retries = message.get("max_retries", 3)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
await self._send_and_wait(
|
|
308
|
+
opcode=message["opcode"],
|
|
309
|
+
payload=message["payload"],
|
|
310
|
+
cmd=message.get("cmd", 0),
|
|
311
|
+
timeout=message.get("timeout", DEFAULT_TIMEOUT),
|
|
312
|
+
)
|
|
313
|
+
self.logger.debug("Message sent successfully from queue")
|
|
314
|
+
self._error_count = max(0, self._error_count - 1)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
self._error_count += 1
|
|
317
|
+
self._last_error_time = time.time()
|
|
318
|
+
|
|
319
|
+
if self._error_count > 10:
|
|
320
|
+
self._circuit_breaker = True
|
|
321
|
+
self.logger.warning(
|
|
322
|
+
"Circuit breaker activated due to %d consecutive errors",
|
|
323
|
+
self._error_count,
|
|
324
|
+
)
|
|
325
|
+
await self._outgoing.put(message)
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
retry_delay = self._get_retry_delay(e, retry_count)
|
|
329
|
+
self.logger.warning(
|
|
330
|
+
"Failed to send message from queue: %s (delay: %ds)",
|
|
331
|
+
e,
|
|
332
|
+
retry_delay,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if retry_count < max_retries:
|
|
336
|
+
message["retry_count"] = retry_count + 1
|
|
337
|
+
await asyncio.sleep(retry_delay)
|
|
338
|
+
await self._outgoing.put(message)
|
|
339
|
+
else:
|
|
340
|
+
self.logger.error(
|
|
341
|
+
"Message failed after %d retries, dropping", max_retries
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
except Exception:
|
|
345
|
+
self.logger.exception("Error in outgoing loop")
|
|
346
|
+
await asyncio.sleep(1)
|
|
347
|
+
|
|
348
|
+
def _get_retry_delay(self, error: Exception, retry_count: int) -> float:
|
|
349
|
+
if isinstance(error, (ConnectionError, OSError)):
|
|
350
|
+
return 1.0
|
|
351
|
+
elif isinstance(error, TimeoutError):
|
|
352
|
+
return 5.0
|
|
353
|
+
elif isinstance(error, WebSocketNotConnectedError):
|
|
354
|
+
return 2.0
|
|
355
|
+
else:
|
|
356
|
+
return 2**retry_count
|
|
357
|
+
|
|
258
358
|
async def _sync(self) -> None:
|
|
259
359
|
self.logger.info("Starting initial sync")
|
|
260
360
|
|
|
@@ -269,9 +369,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
269
369
|
).model_dump(by_alias=True)
|
|
270
370
|
|
|
271
371
|
try:
|
|
272
|
-
data = await self._send_and_wait(
|
|
273
|
-
opcode=Opcode.LOGIN, payload=payload
|
|
274
|
-
)
|
|
372
|
+
data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
|
|
275
373
|
raw_payload = data.get("payload", {})
|
|
276
374
|
|
|
277
375
|
if error := raw_payload.get("error"):
|
pymax/payloads.py
CHANGED
|
@@ -64,7 +64,7 @@ class ReplyLink(CamelModel):
|
|
|
64
64
|
message_id: str
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
class
|
|
67
|
+
class UploadPayload(CamelModel):
|
|
68
68
|
count: int = 1
|
|
69
69
|
|
|
70
70
|
|
|
@@ -73,6 +73,11 @@ class AttachPhotoPayload(CamelModel):
|
|
|
73
73
|
photo_token: str
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
class AttachFilePayload(CamelModel):
|
|
77
|
+
type: AttachType = Field(default=AttachType.FILE, alias="_type")
|
|
78
|
+
file_id: int
|
|
79
|
+
|
|
80
|
+
|
|
76
81
|
class MessageElement(CamelModel):
|
|
77
82
|
type: str
|
|
78
83
|
from_: int = Field(..., alias="from")
|
|
@@ -83,7 +88,7 @@ class SendMessagePayloadMessage(CamelModel):
|
|
|
83
88
|
text: str
|
|
84
89
|
cid: int
|
|
85
90
|
elements: list[MessageElement]
|
|
86
|
-
attaches: list[AttachPhotoPayload]
|
|
91
|
+
attaches: list[AttachPhotoPayload | AttachFilePayload]
|
|
87
92
|
link: ReplyLink | None = None
|
|
88
93
|
|
|
89
94
|
|
|
@@ -263,14 +268,14 @@ class RemoveReactionPayload(CamelModel):
|
|
|
263
268
|
message_id: str
|
|
264
269
|
|
|
265
270
|
|
|
266
|
-
class UserAgentPayload(
|
|
267
|
-
|
|
271
|
+
class UserAgentPayload(CamelModel):
|
|
272
|
+
device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
|
|
268
273
|
locale: str = Field(default=DEFAULT_LOCALE)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
|
|
275
|
+
os_version: str = Field(default=DEFAULT_OS_VERSION)
|
|
276
|
+
device_name: str = Field(default=DEFAULT_DEVICE_NAME)
|
|
277
|
+
header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
|
|
278
|
+
app_version: str = Field(default=DEFAULT_APP_VERSION)
|
|
274
279
|
screen: str = Field(default=DEFAULT_SCREEN)
|
|
275
280
|
timezone: str = Field(default=DEFAULT_TIMEZONE)
|
|
276
281
|
|
|
@@ -278,3 +283,10 @@ class UserAgentPayload(BaseModel):
|
|
|
278
283
|
class ReworkInviteLinkPayload(CamelModel):
|
|
279
284
|
revoke_private_link: bool = True
|
|
280
285
|
chat_id: int
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class RegisterPayload(CamelModel):
|
|
289
|
+
last_name: str | None = None
|
|
290
|
+
first_name: str
|
|
291
|
+
token: str
|
|
292
|
+
token_type: AuthType = AuthType.REGISTER
|
pymax/static/enum.py
CHANGED
|
File without changes
|
|
File without changes
|