maxapi-python 1.1.13__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.13.dist-info → maxapi_python-1.1.15.dist-info}/METADATA +2 -2
- maxapi_python-1.1.15.dist-info/RECORD +30 -0
- pymax/__init__.py +7 -5
- pymax/core.py +52 -54
- pymax/crud.py +10 -9
- pymax/exceptions.py +18 -0
- pymax/files.py +16 -1
- pymax/filters.py +8 -3
- pymax/formatting.py +4 -4
- pymax/interfaces.py +31 -15
- pymax/mixins/auth.py +87 -6
- pymax/mixins/channel.py +93 -3
- pymax/mixins/group.py +30 -10
- pymax/mixins/handler.py +15 -14
- pymax/mixins/message.py +190 -82
- pymax/mixins/self.py +4 -2
- pymax/mixins/socket.py +236 -75
- pymax/mixins/telemetry.py +11 -6
- pymax/mixins/user.py +23 -1
- pymax/mixins/websocket.py +172 -31
- pymax/navigation.py +3 -1
- pymax/payloads.py +53 -10
- pymax/static/constant.py +26 -0
- pymax/{static.py → static/enum.py} +13 -31
- pymax/types.py +309 -39
- pymax/utils.py +11 -3
- maxapi_python-1.1.13.dist-info/RECORD +0 -29
- {maxapi_python-1.1.13.dist-info → maxapi_python-1.1.15.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.13.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
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
pymax/__init__.py,sha256=jXY_nQKTdCOqXqJWNkyNIudoxfQO5p8wNmZYbuO1Rgs,980
|
|
2
|
+
pymax/core.py,sha256=Yx8pLJ6s8MfxxaQrw5nbSBaYP5m9yZz51O78bUmK32I,8495
|
|
3
|
+
pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
|
|
4
|
+
pymax/exceptions.py,sha256=PWEjx7d70410XMvyVVyQUwR88CTgm8MUQ4RyInTut_M,2046
|
|
5
|
+
pymax/files.py,sha256=oKWylKZUPlChWIo2PUHxoq4dcEbg4hAD6ITZCkiN6Xg,3086
|
|
6
|
+
pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
|
|
7
|
+
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
8
|
+
pymax/interfaces.py,sha256=onU6yxkU7sUjSeZWlHflONzZiWIspmXYS5Byeuvscvc,3288
|
|
9
|
+
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
10
|
+
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
11
|
+
pymax/payloads.py,sha256=BwrZKVwsoBzMLWlskARMsk4xQ8bpnizoPbv_P4ez01Q,6190
|
|
12
|
+
pymax/types.py,sha256=mM2cbdwcevketJ-O1F_jeNe7WvYpooV0F1ojbELDdvE,31141
|
|
13
|
+
pymax/utils.py,sha256=r6Gm-fol6d4LdcdC12j6kQQNi1ywg7WVe24kwPh919A,1455
|
|
14
|
+
pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
|
|
15
|
+
pymax/mixins/auth.py,sha256=KlvVjdK3Hj7LeiCCWpGbQ_oPsww6KXQ_L_SxWD-yW3k,6526
|
|
16
|
+
pymax/mixins/channel.py,sha256=2oSLjZp0qm4lKAoFKGivhldajTv0F59_ozfukw7OLTI,4478
|
|
17
|
+
pymax/mixins/group.py,sha256=RAluoZUIh0KCKy37R0efjCMm0kptEjDyjjkDF7c97No,10533
|
|
18
|
+
pymax/mixins/handler.py,sha256=xlfZ9UX1iFaq7gh4E9hxPtGRwj-O2hiqx-C98IxTvQE,3877
|
|
19
|
+
pymax/mixins/message.py,sha256=-ixtb1TrNrNs3kZUe4fYdPVW3I5WdHiao9w-r_CfO8w,24369
|
|
20
|
+
pymax/mixins/self.py,sha256=TU1lWct5z9rNSsB4aVFial7UME3sfffTWlRAR2jpan8,1196
|
|
21
|
+
pymax/mixins/socket.py,sha256=OYhzOOvFgMOW4sR0wGPbObcFeoESd4pOyJAoIWSx6kU,23025
|
|
22
|
+
pymax/mixins/telemetry.py,sha256=kxsecLhDYeRGX5YviyXo4zJKQyjoyPkmPa_-vDrqMQQ,3625
|
|
23
|
+
pymax/mixins/user.py,sha256=CgFrcvpCefEB4bbuxLdcxW3W_ZZs5gupq74S45cU6Es,5538
|
|
24
|
+
pymax/mixins/websocket.py,sha256=c0cxyR2QKmv87JIcr4ZIgz-M7Oa5-FgyE5Ex0i5fOYw,16541
|
|
25
|
+
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
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/__init__.py
CHANGED
|
@@ -3,16 +3,18 @@ Python wrapper для API мессенджера Max
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from .core import (
|
|
6
|
-
InvalidPhoneError,
|
|
7
6
|
MaxClient,
|
|
8
7
|
SocketMaxClient,
|
|
8
|
+
)
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
InvalidPhoneError,
|
|
11
|
+
LoginError,
|
|
9
12
|
WebSocketNotConnectedError,
|
|
10
13
|
)
|
|
11
|
-
from .static import (
|
|
14
|
+
from .static.enum import (
|
|
12
15
|
AccessType,
|
|
13
16
|
AuthType,
|
|
14
17
|
ChatType,
|
|
15
|
-
Constants,
|
|
16
18
|
DeviceType,
|
|
17
19
|
ElementType,
|
|
18
20
|
MessageStatus,
|
|
@@ -38,13 +40,14 @@ __all__ = [
|
|
|
38
40
|
"Channel",
|
|
39
41
|
"Chat",
|
|
40
42
|
"ChatType",
|
|
41
|
-
"Constants",
|
|
42
43
|
"DeviceType",
|
|
43
44
|
"Dialog",
|
|
44
45
|
"Element",
|
|
45
46
|
"ElementType",
|
|
46
47
|
# Исключения
|
|
47
48
|
"InvalidPhoneError",
|
|
49
|
+
"LoginError",
|
|
50
|
+
"WebSocketNotConnectedError",
|
|
48
51
|
# Клиент
|
|
49
52
|
"MaxClient",
|
|
50
53
|
"Message",
|
|
@@ -53,5 +56,4 @@ __all__ = [
|
|
|
53
56
|
"Opcode",
|
|
54
57
|
"SocketMaxClient",
|
|
55
58
|
"User",
|
|
56
|
-
"WebSocketNotConnectedError",
|
|
57
59
|
]
|
pymax/core.py
CHANGED
|
@@ -3,22 +3,20 @@ import logging
|
|
|
3
3
|
import socket
|
|
4
4
|
import ssl
|
|
5
5
|
import time
|
|
6
|
-
from collections.abc import Awaitable, Callable
|
|
7
6
|
from pathlib import Path
|
|
8
|
-
from typing import
|
|
7
|
+
from typing import Literal
|
|
9
8
|
|
|
10
9
|
from typing_extensions import override
|
|
11
10
|
|
|
12
11
|
from .crud import Database
|
|
13
|
-
from .exceptions import InvalidPhoneError
|
|
12
|
+
from .exceptions import InvalidPhoneError
|
|
14
13
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
15
|
-
from .
|
|
16
|
-
from .
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from .filters import Filter
|
|
14
|
+
from .payloads import UserAgentPayload
|
|
15
|
+
from .static.constant import (
|
|
16
|
+
HOST,
|
|
17
|
+
PORT,
|
|
18
|
+
WEBSOCKET_URI,
|
|
19
|
+
)
|
|
22
20
|
|
|
23
21
|
logger = logging.getLogger(__name__)
|
|
24
22
|
|
|
@@ -34,12 +32,17 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
34
32
|
work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
|
|
35
33
|
logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
|
|
36
34
|
логгер модуля с именем f"{__name__}.MaxClient".
|
|
37
|
-
headers (
|
|
38
|
-
Constants.DEFAULT_USER_AGENT.value.
|
|
35
|
+
headers (UserAgentPayload): Заголовки для подключения к WebSocket.
|
|
39
36
|
token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
|
|
40
37
|
процесс логина по номеру телефона.
|
|
41
38
|
host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
|
|
42
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).
|
|
43
46
|
|
|
44
47
|
Raises:
|
|
45
48
|
InvalidPhoneError: Если формат номера телефона неверный.
|
|
@@ -48,57 +51,49 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
48
51
|
def __init__(
|
|
49
52
|
self,
|
|
50
53
|
phone: str,
|
|
51
|
-
uri: str =
|
|
52
|
-
headers:
|
|
54
|
+
uri: str = WEBSOCKET_URI,
|
|
55
|
+
headers: UserAgentPayload = UserAgentPayload(),
|
|
53
56
|
token: str | None = None,
|
|
54
57
|
send_fake_telemetry: bool = True,
|
|
55
|
-
host: str =
|
|
56
|
-
port: int =
|
|
58
|
+
host: str = HOST,
|
|
59
|
+
port: int = PORT,
|
|
60
|
+
proxy: str | Literal[True] | None = None,
|
|
57
61
|
work_dir: str = ".",
|
|
62
|
+
registration: bool = False,
|
|
63
|
+
first_name: str = "",
|
|
64
|
+
last_name: str | None = None,
|
|
58
65
|
logger: logging.Logger | None = None,
|
|
59
66
|
) -> None:
|
|
67
|
+
logger = logger or logging.getLogger(f"{__name__}.MaxClient")
|
|
68
|
+
ApiMixin.__init__(self, token=token, logger=logger)
|
|
69
|
+
WebSocketMixin.__init__(self, token=token, logger=logger)
|
|
60
70
|
self.uri: str = uri
|
|
61
|
-
self.is_connected: bool = False
|
|
62
71
|
self.phone: str = phone
|
|
63
|
-
self.chats: list[Chat] = []
|
|
64
|
-
self.dialogs: list[Dialog] = []
|
|
65
|
-
self.channels: list[Channel] = []
|
|
66
|
-
self.me: Me | None = None
|
|
67
|
-
self._users: dict[int, User] = {}
|
|
68
72
|
if not self._check_phone():
|
|
69
73
|
raise InvalidPhoneError(self.phone)
|
|
70
74
|
self.host: str = host
|
|
71
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
|
|
72
80
|
self._work_dir: str = work_dir
|
|
73
81
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
74
82
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
83
|
self._database_path.touch(exist_ok=True)
|
|
76
84
|
self._database = Database(self._work_dir)
|
|
77
|
-
self.
|
|
78
|
-
self.
|
|
79
|
-
self.
|
|
80
|
-
self.
|
|
81
|
-
self.
|
|
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
|
|
82
90
|
self._device_id = self._database.get_device_id()
|
|
91
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
83
92
|
self._token = self._database.get_auth_token() or token
|
|
84
93
|
self.user_agent = headers
|
|
85
|
-
|
|
86
94
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
87
95
|
self._session_id: int = int(time.time() * 1000)
|
|
88
96
|
self._action_id: int = 1
|
|
89
|
-
self._current_screen: str = "chats_list_tab"
|
|
90
|
-
|
|
91
|
-
self._on_message_handlers: list[
|
|
92
|
-
tuple[Callable[[Message], Any], Filter | None]
|
|
93
|
-
] = []
|
|
94
|
-
self._on_message_edit_handlers: list[
|
|
95
|
-
tuple[Callable[[Message], Any], Filter | None]
|
|
96
|
-
] = []
|
|
97
|
-
self._on_message_delete_handlers: list[
|
|
98
|
-
tuple[Callable[[Message], Any], Filter | None]
|
|
99
|
-
] = []
|
|
100
|
-
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
101
|
-
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
102
97
|
self._ssl_context = ssl.create_default_context()
|
|
103
98
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
104
99
|
self._ssl_context.check_hostname = True
|
|
@@ -106,16 +101,14 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
106
101
|
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
107
102
|
self._ssl_context.load_default_certs()
|
|
108
103
|
self._socket: socket.socket | None = None
|
|
109
|
-
self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
|
|
110
104
|
self._setup_logger()
|
|
111
|
-
|
|
112
105
|
self.logger.debug(
|
|
113
|
-
"Initialized MaxClient uri=%s work_dir=%s",
|
|
106
|
+
"Initialized MaxClient uri=%s work_dir=%s",
|
|
107
|
+
self.uri,
|
|
108
|
+
self._work_dir,
|
|
114
109
|
)
|
|
115
110
|
|
|
116
111
|
def _setup_logger(self) -> None:
|
|
117
|
-
self.logger.setLevel(logging.INFO)
|
|
118
|
-
|
|
119
112
|
if not logger.handlers:
|
|
120
113
|
handler = logging.StreamHandler()
|
|
121
114
|
formatter = logging.Formatter(
|
|
@@ -139,6 +132,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
139
132
|
await self._recv_task
|
|
140
133
|
except asyncio.CancelledError:
|
|
141
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")
|
|
142
141
|
if self._ws:
|
|
143
142
|
await self._ws.close()
|
|
144
143
|
self.is_connected = False
|
|
@@ -155,6 +154,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
155
154
|
self.logger.info("Client starting")
|
|
156
155
|
await self._connect(self.user_agent)
|
|
157
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
|
+
|
|
158
162
|
if self._token and self._database.get_auth_token() is None:
|
|
159
163
|
self._database.update_auth_token(self._device_id, self._token)
|
|
160
164
|
|
|
@@ -170,18 +174,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
170
174
|
await result
|
|
171
175
|
|
|
172
176
|
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
177
|
+
ping_task.add_done_callback(self._log_task_exception)
|
|
173
178
|
self._background_tasks.add(ping_task)
|
|
174
179
|
if self._send_fake_telemetry:
|
|
175
180
|
telemetry_task = asyncio.create_task(self._start())
|
|
181
|
+
telemetry_task.add_done_callback(self._log_task_exception)
|
|
176
182
|
self._background_tasks.add(telemetry_task)
|
|
177
|
-
telemetry_task.add_done_callback(
|
|
178
|
-
lambda t: self._background_tasks.discard(t)
|
|
179
|
-
or self._log_task_exception(t)
|
|
180
|
-
)
|
|
181
|
-
ping_task.add_done_callback(
|
|
182
|
-
lambda t: self._background_tasks.discard(t)
|
|
183
|
-
or self._log_task_exception(t)
|
|
184
|
-
)
|
|
185
183
|
await self._wait_forever()
|
|
186
184
|
except Exception:
|
|
187
185
|
self.logger.exception("Client start failed")
|
pymax/crud.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
from typing import cast
|
|
1
2
|
from uuid import UUID
|
|
2
3
|
|
|
3
4
|
from sqlalchemy.engine.base import Engine
|
|
4
5
|
from sqlmodel import Session, SQLModel, create_engine, select
|
|
5
6
|
|
|
6
7
|
from .models import Auth
|
|
7
|
-
from .static import DeviceType
|
|
8
|
+
from .static.enum import DeviceType
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class Database:
|
|
@@ -14,11 +15,6 @@ class Database:
|
|
|
14
15
|
self.create_all()
|
|
15
16
|
self._ensure_single_auth()
|
|
16
17
|
|
|
17
|
-
self.workdir = workdir
|
|
18
|
-
self.engine = self.get_engine(workdir)
|
|
19
|
-
self.create_all()
|
|
20
|
-
self._ensure_single_auth()
|
|
21
|
-
|
|
22
18
|
def create_all(self) -> None:
|
|
23
19
|
SQLModel.metadata.create_all(self.engine)
|
|
24
20
|
|
|
@@ -30,11 +26,14 @@ class Database:
|
|
|
30
26
|
|
|
31
27
|
def get_auth_token(self) -> str | None:
|
|
32
28
|
with self.get_session() as session:
|
|
33
|
-
|
|
29
|
+
token = cast(str | None, session.exec(select(Auth.token)).first())
|
|
30
|
+
return token
|
|
34
31
|
|
|
35
32
|
def get_device_id(self) -> UUID:
|
|
36
33
|
with self.get_session() as session:
|
|
37
|
-
device_id =
|
|
34
|
+
device_id = cast(
|
|
35
|
+
UUID | None, session.exec(select(Auth.device_id)).first()
|
|
36
|
+
)
|
|
38
37
|
if device_id is None:
|
|
39
38
|
auth = Auth()
|
|
40
39
|
session.add(auth)
|
|
@@ -52,7 +51,9 @@ class Database:
|
|
|
52
51
|
|
|
53
52
|
def update_auth_token(self, device_id: UUID, token: str) -> None:
|
|
54
53
|
with self.get_session() as session:
|
|
55
|
-
auth = session.exec(
|
|
54
|
+
auth = session.exec(
|
|
55
|
+
select(Auth).where(Auth.device_id == device_id)
|
|
56
|
+
).first()
|
|
56
57
|
if auth:
|
|
57
58
|
auth.token = token
|
|
58
59
|
session.add(auth)
|
pymax/exceptions.py
CHANGED
|
@@ -46,3 +46,21 @@ class LoginError(Exception):
|
|
|
46
46
|
|
|
47
47
|
def __init__(self, message: str) -> None:
|
|
48
48
|
super().__init__(f"Login error: {message}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ResponseError(Exception):
|
|
52
|
+
"""
|
|
53
|
+
Исключение, вызываемое при ошибке в ответе от сервера.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str) -> None:
|
|
57
|
+
super().__init__(f"Response error: {message}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ResponseStructureError(Exception):
|
|
61
|
+
"""
|
|
62
|
+
Исключение, вызываемое при неверной структуре ответа от сервера.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str) -> None:
|
|
66
|
+
super().__init__(f"Response structure error: {message}")
|
pymax/files.py
CHANGED
|
@@ -22,7 +22,10 @@ class BaseFile(ABC):
|
|
|
22
22
|
@abstractmethod
|
|
23
23
|
async def read(self) -> bytes:
|
|
24
24
|
if self.url:
|
|
25
|
-
async with
|
|
25
|
+
async with (
|
|
26
|
+
ClientSession() as session,
|
|
27
|
+
session.get(self.url) as response,
|
|
28
|
+
):
|
|
26
29
|
response.raise_for_status()
|
|
27
30
|
return await response.read()
|
|
28
31
|
elif self.path:
|
|
@@ -81,6 +84,18 @@ class Video(BaseFile):
|
|
|
81
84
|
|
|
82
85
|
|
|
83
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
|
+
|
|
84
99
|
@override
|
|
85
100
|
async def read(self) -> bytes:
|
|
86
101
|
return await super().read()
|
pymax/filters.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .static import MessageStatus, MessageType
|
|
1
|
+
from .static.enum import MessageStatus, MessageType
|
|
2
2
|
from .types import Message
|
|
3
3
|
|
|
4
4
|
|
|
@@ -30,13 +30,18 @@ class Filter:
|
|
|
30
30
|
text not in message.text for text in self.text
|
|
31
31
|
):
|
|
32
32
|
return False
|
|
33
|
-
if
|
|
33
|
+
if (
|
|
34
|
+
self.text_contains is not None
|
|
35
|
+
and self.text_contains not in message.text
|
|
36
|
+
):
|
|
34
37
|
return False
|
|
35
38
|
if self.status is not None and message.status != self.status:
|
|
36
39
|
return False
|
|
37
40
|
if self.type is not None and message.type != self.type:
|
|
38
41
|
return False
|
|
39
|
-
if
|
|
42
|
+
if (
|
|
43
|
+
self.reaction_info is not None and message.reactionInfo is None
|
|
44
|
+
): # noqa: SIM103
|
|
40
45
|
return False
|
|
41
46
|
|
|
42
47
|
return True
|
pymax/formatting.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
|
-
from pymax.static import FormattingType
|
|
3
|
+
from pymax.static.enum import FormattingType
|
|
4
4
|
from pymax.types import Element
|
|
5
5
|
|
|
6
6
|
|
|
@@ -46,9 +46,9 @@ class Formatting:
|
|
|
46
46
|
|
|
47
47
|
if inner_text is not None and fmt_type is not None:
|
|
48
48
|
next_pos = match.end()
|
|
49
|
-
has_newline = (
|
|
50
|
-
next_pos
|
|
51
|
-
)
|
|
49
|
+
has_newline = (
|
|
50
|
+
next_pos < len(text) and text[next_pos] == "\n"
|
|
51
|
+
) or (next_pos == len(text))
|
|
52
52
|
|
|
53
53
|
length = len(inner_text) + (1 if has_newline else 0)
|
|
54
54
|
elements.append(
|
pymax/interfaces.py
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import logging
|
|
3
2
|
import socket
|
|
4
3
|
import ssl
|
|
5
4
|
from abc import ABC, abstractmethod
|
|
6
5
|
from collections.abc import Awaitable, Callable
|
|
7
6
|
from logging import Logger
|
|
8
7
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
10
9
|
|
|
11
10
|
import websockets
|
|
12
11
|
|
|
12
|
+
from pymax.static.constant import DEFAULT_USER_AGENT
|
|
13
|
+
|
|
13
14
|
from .filters import Filter
|
|
14
|
-
from .
|
|
15
|
+
from .payloads import UserAgentPayload
|
|
16
|
+
from .static.constant import DEFAULT_TIMEOUT
|
|
17
|
+
from .static.enum import Opcode
|
|
15
18
|
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
16
19
|
|
|
17
20
|
if TYPE_CHECKING:
|
|
@@ -26,23 +29,20 @@ class ClientProtocol(ABC):
|
|
|
26
29
|
self.logger = logger
|
|
27
30
|
self._users: dict[int, User] = {}
|
|
28
31
|
self.chats: list[Chat] = []
|
|
29
|
-
self.phone: str = ""
|
|
30
32
|
self._database: Database
|
|
31
33
|
self._device_id: UUID
|
|
32
|
-
self._on_message_handlers: list[
|
|
33
|
-
tuple[Callable[[Message], Any], Filter | None]
|
|
34
|
-
] = []
|
|
35
34
|
self.uri: str
|
|
36
|
-
|
|
37
35
|
self.is_connected: bool = False
|
|
38
36
|
self.phone: str
|
|
39
|
-
self.chats: list[Chat] = []
|
|
40
37
|
self.dialogs: list[Dialog] = []
|
|
41
38
|
self.channels: list[Channel] = []
|
|
42
39
|
self.me: Me | None = None
|
|
43
40
|
self.host: str
|
|
44
41
|
self.port: int
|
|
45
|
-
self.
|
|
42
|
+
self.proxy: str | Literal[True] | None
|
|
43
|
+
self.registration: bool
|
|
44
|
+
self.first_name: str
|
|
45
|
+
self.last_name: str | None
|
|
46
46
|
self._work_dir: str
|
|
47
47
|
self._database_path: Path
|
|
48
48
|
self._ws: websockets.ClientConnection | None = None
|
|
@@ -50,12 +50,17 @@ class ClientProtocol(ABC):
|
|
|
50
50
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
51
51
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
52
52
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
53
|
-
self.
|
|
54
|
-
|
|
53
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
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
|
|
55
61
|
self._session_id: int
|
|
56
62
|
self._action_id: int = 0
|
|
57
63
|
self._current_screen: str = "chats_list_tab"
|
|
58
|
-
|
|
59
64
|
self._on_message_handlers: list[
|
|
60
65
|
tuple[Callable[[Message], Any], Filter | None]
|
|
61
66
|
] = []
|
|
@@ -73,13 +78,24 @@ class ClientProtocol(ABC):
|
|
|
73
78
|
@abstractmethod
|
|
74
79
|
async def _send_and_wait(
|
|
75
80
|
self,
|
|
76
|
-
opcode:
|
|
81
|
+
opcode: Opcode,
|
|
77
82
|
payload: dict[str, Any],
|
|
78
83
|
cmd: int = 0,
|
|
79
|
-
timeout: float =
|
|
84
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
80
85
|
) -> dict[str, Any]:
|
|
81
86
|
pass
|
|
82
87
|
|
|
83
88
|
@abstractmethod
|
|
84
89
|
async def _get_chat(self, chat_id: int) -> Chat | None:
|
|
85
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,21 @@
|
|
|
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.static import
|
|
7
|
+
from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
|
|
8
|
+
from pymax.static.constant import PHONE_REGEX
|
|
9
|
+
from pymax.static.enum import AuthType, Opcode
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class AuthMixin(ClientProtocol):
|
|
13
|
+
def __init__(self, token: str | None = None, *args, **kwargs) -> None:
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
self._token = token
|
|
16
|
+
|
|
11
17
|
def _check_phone(self) -> bool:
|
|
12
|
-
return bool(re.match(
|
|
18
|
+
return bool(re.match(PHONE_REGEX, self.phone))
|
|
13
19
|
|
|
14
20
|
async def _request_code(
|
|
15
21
|
self, phone: str, language: str = "ru"
|
|
@@ -29,7 +35,12 @@ class AuthMixin(ClientProtocol):
|
|
|
29
35
|
data.get("opcode"),
|
|
30
36
|
data.get("seq"),
|
|
31
37
|
)
|
|
32
|
-
|
|
38
|
+
payload_data = data.get("payload")
|
|
39
|
+
if isinstance(payload_data, dict):
|
|
40
|
+
return payload_data
|
|
41
|
+
else:
|
|
42
|
+
self.logger.error("Invalid payload data received")
|
|
43
|
+
raise ValueError("Invalid payload data received")
|
|
33
44
|
except Exception:
|
|
34
45
|
self.logger.error("Request code failed", exc_info=True)
|
|
35
46
|
raise RuntimeError("Request code failed")
|
|
@@ -50,20 +61,27 @@ class AuthMixin(ClientProtocol):
|
|
|
50
61
|
data.get("opcode"),
|
|
51
62
|
data.get("seq"),
|
|
52
63
|
)
|
|
53
|
-
|
|
64
|
+
payload_data = data.get("payload")
|
|
65
|
+
if isinstance(payload_data, dict):
|
|
66
|
+
return payload_data
|
|
67
|
+
else:
|
|
68
|
+
self.logger.error("Invalid payload data received")
|
|
69
|
+
raise ValueError("Invalid payload data received")
|
|
54
70
|
except Exception:
|
|
55
71
|
self.logger.error("Send code failed", exc_info=True)
|
|
56
72
|
raise RuntimeError("Send code failed")
|
|
57
73
|
|
|
58
74
|
async def _login(self) -> None:
|
|
59
75
|
self.logger.info("Starting login flow")
|
|
76
|
+
|
|
60
77
|
request_code_payload = await self._request_code(self.phone)
|
|
61
78
|
temp_token = request_code_payload.get("token")
|
|
62
79
|
if not temp_token or not isinstance(temp_token, str):
|
|
63
80
|
self.logger.critical("Failed to request code: token missing")
|
|
64
81
|
raise ValueError("Failed to request code")
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
print("Введите код: ", end="", flush=True)
|
|
84
|
+
code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
|
|
67
85
|
if len(code) != 6 or not code.isdigit():
|
|
68
86
|
self.logger.error("Invalid code format entered")
|
|
69
87
|
raise ValueError("Invalid code format")
|
|
@@ -79,3 +97,66 @@ class AuthMixin(ClientProtocol):
|
|
|
79
97
|
self._token = token
|
|
80
98
|
self._database.update_auth_token(self._device_id, self._token)
|
|
81
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")
|