maxapi-python 2.3.1__py3-none-any.whl → 2.4.1__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-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
- pymax/__init__.py +9 -2
- pymax/_data/apk_fingerprints.json +767 -0
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/enums.py +1 -0
- pymax/api/auth/payloads.py +28 -2
- pymax/api/auth/service.py +100 -3
- pymax/api/chats/__init__.py +1 -0
- pymax/api/chats/enums.py +15 -0
- pymax/api/chats/payloads.py +18 -2
- pymax/api/chats/service.py +43 -1
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/payloads.py +21 -7
- pymax/api/messages/service.py +161 -24
- pymax/api/self/__init__.py +2 -1
- pymax/api/self/enums.py +9 -0
- pymax/api/self/payloads.py +40 -1
- pymax/api/self/service.py +37 -0
- pymax/api/session/service.py +21 -8
- pymax/api/uploads/payloads.py +39 -1
- pymax/api/uploads/service.py +247 -55
- pymax/app.py +91 -18
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/sms.py +11 -1
- pymax/base.py +90 -8
- pymax/client.py +39 -10
- pymax/client_web.py +17 -14
- pymax/config.py +98 -19
- pymax/connection/connection.py +29 -18
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +3 -1
- pymax/dispatch/resolvers.py +7 -1
- pymax/files/__init__.py +4 -1
- pymax/files/base.py +58 -1
- pymax/files/video.py +51 -1
- pymax/files/voice.py +53 -0
- pymax/fingerprint/__init__.py +1 -0
- pymax/fingerprint/fingerprint.py +30 -0
- pymax/fingerprint/models.py +12 -0
- pymax/infra/auth.py +4 -0
- pymax/infra/bots.py +3 -3
- pymax/infra/chat.py +47 -5
- pymax/infra/message.py +49 -14
- pymax/infra/protocol.py +5 -1
- pymax/infra/self.py +25 -1
- pymax/protocol/enums.py +13 -0
- pymax/protocol/tcp/payload.py +21 -1
- pymax/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +17 -4
- pymax/transport/websocket.py +6 -3
- pymax/types/domain/__init__.py +2 -1
- pymax/types/domain/attachments/__init__.py +2 -1
- pymax/types/domain/attachments/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +33 -1
- pymax/types/domain/attachments/poll.py +111 -0
- pymax/types/domain/attachments/sticker.py +1 -1
- pymax/types/domain/attachments/video.py +32 -11
- pymax/types/domain/chat.py +9 -9
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/handshake.py +16 -0
- pymax/types/domain/login.py +30 -1
- pymax/types/domain/message.py +108 -27
- pymax/types/domain/user.py +1 -3
- pymax/types/events/__init__.py +1 -0
- pymax/types/events/reaction.py +2 -2
- pymax/types/events/voice.py +5 -0
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
pymax/auth/sms.py
CHANGED
|
@@ -6,6 +6,7 @@ from pymax.auth.base import AuthFlow
|
|
|
6
6
|
from pymax.exceptions import ApiError
|
|
7
7
|
from pymax.logging import get_logger
|
|
8
8
|
|
|
9
|
+
from .exceptions import PasswordAttemptsExceededError
|
|
9
10
|
from .models import AuthResult
|
|
10
11
|
from .providers import (
|
|
11
12
|
ConsolePasswordProvider,
|
|
@@ -116,7 +117,10 @@ class SmsAuthFlow(AuthFlow):
|
|
|
116
117
|
hint: str | None,
|
|
117
118
|
) -> str:
|
|
118
119
|
logger.info("starting 2fa password authentication")
|
|
119
|
-
|
|
120
|
+
attempt = 0
|
|
121
|
+
while (
|
|
122
|
+
app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
|
|
123
|
+
):
|
|
120
124
|
password = await self.password_provider.get_password(hint)
|
|
121
125
|
logger.debug(
|
|
122
126
|
"2fa password provider returned password_set=%s",
|
|
@@ -124,16 +128,19 @@ class SmsAuthFlow(AuthFlow):
|
|
|
124
128
|
)
|
|
125
129
|
if not password:
|
|
126
130
|
logger.warning("2fa password is empty; retrying")
|
|
131
|
+
attempt += 1
|
|
127
132
|
continue
|
|
128
133
|
|
|
129
134
|
try:
|
|
130
135
|
response = await app.api.auth.check_password(track_id, password)
|
|
131
136
|
except ApiError as e:
|
|
132
137
|
logger.error("2fa password check failed: %s", e)
|
|
138
|
+
attempt += 1
|
|
133
139
|
continue
|
|
134
140
|
|
|
135
141
|
if response.error:
|
|
136
142
|
logger.error("2fa password check failed error=%s", response.error)
|
|
143
|
+
attempt += 1
|
|
137
144
|
continue
|
|
138
145
|
|
|
139
146
|
if response.login_token:
|
|
@@ -141,3 +148,6 @@ class SmsAuthFlow(AuthFlow):
|
|
|
141
148
|
return response.login_token
|
|
142
149
|
|
|
143
150
|
logger.error("2fa password response did not contain login token; retrying")
|
|
151
|
+
attempt += 1
|
|
152
|
+
|
|
153
|
+
raise PasswordAttemptsExceededError
|
pymax/base.py
CHANGED
|
@@ -3,15 +3,16 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
|
-
from uuid import uuid4
|
|
7
6
|
|
|
8
7
|
from pymax.dispatch import ErrorScope, Router
|
|
9
8
|
from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator
|
|
9
|
+
from pymax.exceptions import ApiError
|
|
10
|
+
from pymax.fingerprint.models import ApkBuildFingerprint
|
|
10
11
|
from pymax.infra import BaseMixin
|
|
11
12
|
from pymax.logging import get_logger
|
|
12
13
|
|
|
13
14
|
from .app import App
|
|
14
|
-
from .config import ClientConfig, DeviceConfig, ExtraConfig
|
|
15
|
+
from .config import ClientConfig, DeviceConfig, ExtraConfig, generate_device_id
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
@@ -46,11 +47,21 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
46
47
|
_connection: ConnectionManager
|
|
47
48
|
_auth_flow: AuthFlow
|
|
48
49
|
_router: Router[ClientT]
|
|
49
|
-
|
|
50
|
+
__app: App[ClientT] | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def _app(self) -> App[ClientT]:
|
|
54
|
+
if self.__app is None:
|
|
55
|
+
raise RuntimeError("Client runtime is not initialized")
|
|
56
|
+
return self.__app
|
|
57
|
+
|
|
58
|
+
@_app.setter
|
|
59
|
+
def _app(self, value: App[ClientT]) -> None:
|
|
60
|
+
self.__app = value
|
|
50
61
|
|
|
51
62
|
@property
|
|
52
63
|
def me(self) -> Profile | None:
|
|
53
|
-
"""Профиль текущего аккаунта после успешного
|
|
64
|
+
"""Профиль текущего аккаунта после успешного login."""
|
|
54
65
|
return self._app.me
|
|
55
66
|
|
|
56
67
|
@property
|
|
@@ -68,11 +79,17 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
68
79
|
"""Сообщения, которые Max вернул на login/sync."""
|
|
69
80
|
return self._app.messages
|
|
70
81
|
|
|
82
|
+
@property
|
|
83
|
+
def is_connected(self) -> bool:
|
|
84
|
+
return self.__app is not None and self.__app.connection.is_open
|
|
85
|
+
|
|
71
86
|
def _build_config(
|
|
72
87
|
self,
|
|
73
88
|
*,
|
|
74
89
|
phone: str | None,
|
|
75
90
|
user_agent: MobileUserAgentPayload,
|
|
91
|
+
version: str | None,
|
|
92
|
+
fingerprint: ApkBuildFingerprint | None,
|
|
76
93
|
) -> ClientConfig:
|
|
77
94
|
logger.debug(
|
|
78
95
|
"building client config token_set=%s",
|
|
@@ -93,17 +110,38 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
93
110
|
store=self.extra_config.store,
|
|
94
111
|
proxy=self.extra_config.proxy,
|
|
95
112
|
registration_config=self.extra_config.registration_config,
|
|
113
|
+
upload_timeout=self.extra_config.upload_timeout,
|
|
114
|
+
password_max_attempts=self.extra_config.password_max_attempts,
|
|
115
|
+
relogin=self.extra_config.relogin,
|
|
116
|
+
app_version=version,
|
|
117
|
+
fingering=fingerprint,
|
|
118
|
+
persist_session=self.extra_config.persist_session,
|
|
96
119
|
device=DeviceConfig(
|
|
97
120
|
mt_instance_id=self.extra_config.mt_instance_id,
|
|
98
|
-
device_id=self.extra_config.device_id or
|
|
121
|
+
device_id=self.extra_config.device_id or generate_device_id(),
|
|
99
122
|
user_agent=user_agent,
|
|
100
123
|
),
|
|
101
124
|
)
|
|
102
125
|
|
|
103
126
|
@abstractmethod
|
|
104
127
|
def _build_connection(self) -> ConnectionManager:
|
|
128
|
+
|
|
129
|
+
raise NotImplementedError
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
async def _prepare_config(self) -> ClientConfig:
|
|
105
133
|
raise NotImplementedError
|
|
106
134
|
|
|
135
|
+
async def _ensure_runtime(self: ClientT) -> None: # noqa: PYI019
|
|
136
|
+
if getattr(self, "_config", None) is None:
|
|
137
|
+
self._config = await self._prepare_config()
|
|
138
|
+
|
|
139
|
+
if self.__app is not None and self.__app.connection.is_open:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
self._connection = self._build_connection()
|
|
143
|
+
self._app = self._build_app()
|
|
144
|
+
|
|
107
145
|
def _init_runtime(self: ClientT, *, auth_flow: AuthFlow) -> None: # noqa: PYI019
|
|
108
146
|
self._connection = self._build_connection()
|
|
109
147
|
self._auth_flow = auth_flow
|
|
@@ -124,10 +162,43 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
124
162
|
self._connection = self._build_connection()
|
|
125
163
|
self._app = self._build_app()
|
|
126
164
|
|
|
165
|
+
async def connect(self: ClientT) -> None: # noqa: PYI019
|
|
166
|
+
"""Подключает клиента один раз и возвращается после ``on_start``.
|
|
167
|
+
|
|
168
|
+
После возврата соединение остается открытым. В отличие от
|
|
169
|
+
:meth:`start`, метод не ожидает закрытия соединения и не запускает
|
|
170
|
+
reconnect loop; завершите работу через :meth:`stop` или :meth:`close`.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
await self._ensure_runtime()
|
|
174
|
+
await self._app.start()
|
|
175
|
+
if not self._app.started:
|
|
176
|
+
await self.close()
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
await self._app.dispatcher.emit_start(self)
|
|
180
|
+
except asyncio.CancelledError:
|
|
181
|
+
await self.close()
|
|
182
|
+
raise
|
|
183
|
+
except (ConnectionError, EOFError, OSError, TimeoutError, ApiError) as e:
|
|
184
|
+
await self.close()
|
|
185
|
+
|
|
186
|
+
await self._app.dispatcher.emit_disconnect(
|
|
187
|
+
e,
|
|
188
|
+
self.extra_config.reconnect,
|
|
189
|
+
self.extra_config.reconnect_delay,
|
|
190
|
+
)
|
|
191
|
+
raise
|
|
192
|
+
|
|
127
193
|
async def start(self: ClientT) -> None: # noqa: PYI019
|
|
128
|
-
"""Запускает клиента и слушает события до закрытия соединения.
|
|
194
|
+
"""Запускает клиента и слушает события до закрытия соединения.
|
|
195
|
+
|
|
196
|
+
При сетевых ошибках повторяет подключение, если ``reconnect=True``.
|
|
197
|
+
Отозванный login token автоматически сбрасывается при ``relogin=True``.
|
|
198
|
+
"""
|
|
129
199
|
while True:
|
|
130
200
|
try:
|
|
201
|
+
await self._ensure_runtime()
|
|
131
202
|
await self._app.start()
|
|
132
203
|
if not self._app.started:
|
|
133
204
|
await self.close()
|
|
@@ -138,7 +209,15 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
138
209
|
except asyncio.CancelledError:
|
|
139
210
|
await self.close()
|
|
140
211
|
raise
|
|
141
|
-
except
|
|
212
|
+
except ApiError as e:
|
|
213
|
+
if not self._app._is_invalid_login_token_error(e):
|
|
214
|
+
await self.close()
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
logger.warning("login token was revoked; starting authentication again")
|
|
218
|
+
if self.extra_config.relogin:
|
|
219
|
+
await self.relogin(start=False)
|
|
220
|
+
except (
|
|
142
221
|
ConnectionError,
|
|
143
222
|
EOFError,
|
|
144
223
|
OSError,
|
|
@@ -170,7 +249,10 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
170
249
|
|
|
171
250
|
async def close(self) -> None:
|
|
172
251
|
"""Закрывает соединение, фоновые задачи и файл сессии."""
|
|
173
|
-
|
|
252
|
+
if self.__app is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
await self.__app.close()
|
|
174
256
|
|
|
175
257
|
async def stop(self) -> None:
|
|
176
258
|
"""Останавливает клиента."""
|
pymax/client.py
CHANGED
|
@@ -7,12 +7,15 @@ from pymax.auth import (
|
|
|
7
7
|
SmsAuthFlow,
|
|
8
8
|
SmsCodeProvider,
|
|
9
9
|
)
|
|
10
|
+
from pymax.config import ClientConfig
|
|
10
11
|
from pymax.connection import ConnectionManager
|
|
11
12
|
from pymax.connection.readers import TCPReader
|
|
13
|
+
from pymax.dispatch import Router
|
|
12
14
|
from pymax.logging import configure_logging, get_logger
|
|
13
15
|
from pymax.protocol.tcp import TcpProtocol
|
|
14
16
|
from pymax.protocol.tcp.framing import TcpPacketFramer
|
|
15
17
|
from pymax.transport.tcp import TCPTransport
|
|
18
|
+
from pymax.versions.catalog import VersionCatalog
|
|
16
19
|
|
|
17
20
|
from .base import BaseClient
|
|
18
21
|
from .config import ExtraConfig
|
|
@@ -37,6 +40,15 @@ class Client(BaseClient["Client"]):
|
|
|
37
40
|
auth_flow: Полностью пользовательский сценарий авторизации.
|
|
38
41
|
sms_code_provider: Провайдер SMS-кода для стандартного ``SmsAuthFlow``.
|
|
39
42
|
password_provider: Провайдер пароля 2FA, если аккаунт его требует.
|
|
43
|
+
app_version: Версия Android-клиента для user-agent и fingerprint.
|
|
44
|
+
По умолчанию используется ``VersionCatalog.recommended()``.
|
|
45
|
+
catalog: Каталог fingerprints. Если не передан, используется
|
|
46
|
+
встроенный каталог. Каталог и версия разрешаются при
|
|
47
|
+
:meth:`connect` или :meth:`start`.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
VersionNotFoundError: При запуске, если ``app_version`` отсутствует в
|
|
51
|
+
итоговом каталоге.
|
|
40
52
|
"""
|
|
41
53
|
|
|
42
54
|
def __init__( # noqa: PLR0913
|
|
@@ -48,12 +60,16 @@ class Client(BaseClient["Client"]):
|
|
|
48
60
|
auth_flow: AuthFlow | None = None,
|
|
49
61
|
sms_code_provider: SmsCodeProvider | None = None,
|
|
50
62
|
password_provider: PasswordProvider | None = None,
|
|
63
|
+
app_version: str = VersionCatalog.RECOMMENDED_APP_VERSION,
|
|
64
|
+
catalog: VersionCatalog | None = None,
|
|
51
65
|
) -> None:
|
|
52
66
|
|
|
53
|
-
self.phone = phone
|
|
54
|
-
self.extra_config = extra_config or ExtraConfig()
|
|
55
|
-
self.session_name = session_name
|
|
56
|
-
self.work_dir = work_dir
|
|
67
|
+
self.phone: str = phone
|
|
68
|
+
self.extra_config: ExtraConfig = extra_config or ExtraConfig()
|
|
69
|
+
self.session_name: str = session_name
|
|
70
|
+
self.work_dir: str = work_dir
|
|
71
|
+
self.app_version: str = app_version
|
|
72
|
+
self.catalog: VersionCatalog = catalog or VersionCatalog()
|
|
57
73
|
|
|
58
74
|
configure_logging(self.extra_config.log_level)
|
|
59
75
|
logger.debug(
|
|
@@ -65,17 +81,13 @@ class Client(BaseClient["Client"]):
|
|
|
65
81
|
self.extra_config.reconnect,
|
|
66
82
|
)
|
|
67
83
|
|
|
68
|
-
self._config = self._build_config(
|
|
69
|
-
phone=phone,
|
|
70
|
-
user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()),
|
|
71
|
-
)
|
|
72
|
-
|
|
73
84
|
if auth_flow is None:
|
|
74
85
|
auth_flow = SmsAuthFlow(
|
|
75
86
|
sms_code_provider or ConsoleSmsCodeProvider(),
|
|
76
87
|
password_provider,
|
|
77
88
|
)
|
|
78
|
-
self.
|
|
89
|
+
self._auth_flow = auth_flow
|
|
90
|
+
self._router = Router()
|
|
79
91
|
|
|
80
92
|
logger.debug(
|
|
81
93
|
"client created transport=tcp host=%s port=%s",
|
|
@@ -83,6 +95,23 @@ class Client(BaseClient["Client"]):
|
|
|
83
95
|
self.extra_config.port,
|
|
84
96
|
)
|
|
85
97
|
|
|
98
|
+
async def _prepare_config(self) -> ClientConfig:
|
|
99
|
+
catalog = await self.catalog.load()
|
|
100
|
+
fingerprint = catalog.resolve(self.app_version)
|
|
101
|
+
|
|
102
|
+
user_agent = self.extra_config.user_agent or self.extra_config.generate_user_agent(
|
|
103
|
+
self.app_version, fingerprint.build_number
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
config = self._build_config(
|
|
107
|
+
phone=self.phone,
|
|
108
|
+
user_agent=user_agent,
|
|
109
|
+
version=self.app_version,
|
|
110
|
+
fingerprint=fingerprint,
|
|
111
|
+
)
|
|
112
|
+
config.restore_user_agent_from_session = self.extra_config.user_agent is None
|
|
113
|
+
return config
|
|
114
|
+
|
|
86
115
|
def _build_connection(self) -> ConnectionManager:
|
|
87
116
|
logger.debug(
|
|
88
117
|
"building tcp connection host=%s port=%s ssl=%s",
|
pymax/client_web.py
CHANGED
|
@@ -5,12 +5,13 @@ from pymax.auth.providers import ConsoleQrHandler, QrHandler
|
|
|
5
5
|
from pymax.auth.qr import QrAuthFlow
|
|
6
6
|
from pymax.connection import ConnectionManager
|
|
7
7
|
from pymax.connection.readers import WSReader
|
|
8
|
+
from pymax.dispatch import Router
|
|
8
9
|
from pymax.logging import configure_logging, get_logger
|
|
9
|
-
from pymax.protocol.
|
|
10
|
+
from pymax.protocol.tcp import TcpProtocol
|
|
10
11
|
from pymax.transport.websocket import WebSocketTransport
|
|
11
12
|
|
|
12
13
|
from .base import BaseClient
|
|
13
|
-
from .config import ExtraConfig
|
|
14
|
+
from .config import ClientConfig, ExtraConfig
|
|
14
15
|
|
|
15
16
|
logger = get_logger(__name__)
|
|
16
17
|
|
|
@@ -38,9 +39,9 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
38
39
|
auth_flow: AuthFlow | None = None,
|
|
39
40
|
qr_provider: QrHandler | None = None,
|
|
40
41
|
) -> None:
|
|
41
|
-
self.extra_config = extra_config or ExtraConfig()
|
|
42
|
-
self.session_name = session_name
|
|
43
|
-
self.work_dir = work_dir
|
|
42
|
+
self.extra_config: ExtraConfig = extra_config or ExtraConfig()
|
|
43
|
+
self.session_name: str = session_name
|
|
44
|
+
self.work_dir: str = work_dir
|
|
44
45
|
|
|
45
46
|
configure_logging(self.extra_config.log_level)
|
|
46
47
|
logger.debug(
|
|
@@ -51,22 +52,24 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
51
52
|
self.extra_config.reconnect,
|
|
52
53
|
)
|
|
53
54
|
|
|
54
|
-
self._config = self._build_config(
|
|
55
|
-
phone=None,
|
|
56
|
-
user_agent=(
|
|
57
|
-
self.extra_config.user_agent or self.extra_config.generate_web_user_agent()
|
|
58
|
-
),
|
|
59
|
-
)
|
|
60
|
-
|
|
61
55
|
if auth_flow is None:
|
|
62
56
|
auth_flow = QrAuthFlow(qr_provider or ConsoleQrHandler())
|
|
63
|
-
self.
|
|
57
|
+
self._auth_flow = auth_flow
|
|
58
|
+
self._router = Router()
|
|
64
59
|
|
|
65
60
|
logger.debug(
|
|
66
61
|
"web client created transport=ws url=%s",
|
|
67
62
|
self.extra_config.url,
|
|
68
63
|
)
|
|
69
64
|
|
|
65
|
+
async def _prepare_config(self) -> ClientConfig:
|
|
66
|
+
user_agent = self.extra_config.user_agent or self.extra_config.generate_web_user_agent()
|
|
67
|
+
config = self._build_config(
|
|
68
|
+
phone=None, user_agent=user_agent, version=None, fingerprint=None
|
|
69
|
+
)
|
|
70
|
+
config.restore_user_agent_from_session = self.extra_config.user_agent is None
|
|
71
|
+
return config
|
|
72
|
+
|
|
70
73
|
def _build_connection(self) -> ConnectionManager:
|
|
71
74
|
logger.debug(
|
|
72
75
|
"building websocket connection url=%s",
|
|
@@ -80,5 +83,5 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
80
83
|
return ConnectionManager(
|
|
81
84
|
reader=reader,
|
|
82
85
|
transport=transport,
|
|
83
|
-
protocol=
|
|
86
|
+
protocol=TcpProtocol(),
|
|
84
87
|
)
|
pymax/config.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import secrets
|
|
1
2
|
from random import choice, randint
|
|
2
|
-
from uuid import uuid4
|
|
3
3
|
|
|
4
4
|
from pydantic import BaseModel, ConfigDict, Field
|
|
5
5
|
|
|
@@ -8,20 +8,53 @@ from pymax.api.session.payloads import (
|
|
|
8
8
|
DEFAULT_WEB_HEADER_USER_AGENT,
|
|
9
9
|
MobileUserAgentPayload,
|
|
10
10
|
)
|
|
11
|
+
from pymax.fingerprint.models import ApkBuildFingerprint
|
|
11
12
|
from pymax.session import StoreProtocol
|
|
12
13
|
from pymax.types.domain.sync import SyncOverrides
|
|
14
|
+
from pymax.versions.catalog import VersionCatalog
|
|
13
15
|
|
|
16
|
+
MIN_PREFERRED_BUILD = 6712
|
|
14
17
|
APP_VERSIONS: tuple[tuple[str, int], ...] = (
|
|
18
|
+
("26.25.0", 6790),
|
|
19
|
+
("26.24.0", 6784),
|
|
20
|
+
("26.23.2", 6779),
|
|
21
|
+
("26.23.1", 6778),
|
|
22
|
+
("26.23.0", 6777),
|
|
23
|
+
("26.22.2", 6773),
|
|
24
|
+
("26.22.1", 6772),
|
|
25
|
+
("26.22.0", 6770),
|
|
26
|
+
("26.21.1", 6763),
|
|
27
|
+
("26.20.2", 6758),
|
|
28
|
+
("26.20.1", 6740),
|
|
29
|
+
("26.19.3", 6734),
|
|
30
|
+
("26.19.2", 6732),
|
|
31
|
+
("26.19.1", 6729),
|
|
32
|
+
("26.19.0", 6727),
|
|
33
|
+
("26.18.4", 6724),
|
|
34
|
+
("26.18.2", 6720),
|
|
35
|
+
("26.18.1", 6716),
|
|
36
|
+
("26.18.0", 6715),
|
|
37
|
+
("26.17.1", 6712),
|
|
38
|
+
("26.16.4", 6704),
|
|
39
|
+
("26.16.3", 6702),
|
|
40
|
+
("26.16.2", 6701),
|
|
41
|
+
("26.16.1", 6700),
|
|
42
|
+
("26.16.0", 6698),
|
|
43
|
+
("26.15.3", 6695),
|
|
44
|
+
("26.15.1", 6690),
|
|
45
|
+
("26.15.0", 6689),
|
|
15
46
|
("26.14.1", 6686),
|
|
16
47
|
("26.14.0", 6685),
|
|
17
48
|
("26.13.0", 6683),
|
|
18
49
|
("26.12.2", 6681),
|
|
19
50
|
("26.12.1", 6679),
|
|
20
|
-
("26.12.0",
|
|
21
|
-
("26.11.3",
|
|
22
|
-
("26.11.2", 6669),
|
|
23
|
-
("26.11.1", 6665),
|
|
24
|
-
("26.
|
|
51
|
+
("26.12.0", 6676),
|
|
52
|
+
# ("26.11.3", 6670), # Not supported TODO: delete maybe
|
|
53
|
+
# ("26.11.2", 6669),
|
|
54
|
+
# ("26.11.1", 6665),
|
|
55
|
+
# ("26.10.1", 6653),
|
|
56
|
+
# ("26.10.0", 6648),
|
|
57
|
+
# ("26.9.1", 6643),
|
|
25
58
|
)
|
|
26
59
|
ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
|
|
27
60
|
("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
@@ -73,14 +106,21 @@ LOCALE_TIMEZONES: tuple[tuple[str, str], ...] = (
|
|
|
73
106
|
("ru", "Asia/Yakutsk"),
|
|
74
107
|
("ru", "Asia/Vladivostok"),
|
|
75
108
|
)
|
|
76
|
-
WEB_APP_VERSION = "26.
|
|
109
|
+
WEB_APP_VERSION = "26.8.4"
|
|
77
110
|
WEB_SCREEN = "1080x1920 1.0x"
|
|
78
111
|
|
|
112
|
+
PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD]
|
|
113
|
+
LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_device_id() -> str:
|
|
117
|
+
return secrets.token_hex(8)
|
|
118
|
+
|
|
79
119
|
|
|
80
120
|
class DeviceConfig(BaseModel):
|
|
81
121
|
mt_instance_id: str
|
|
82
122
|
user_agent: MobileUserAgentPayload
|
|
83
|
-
device_id: str = Field(default_factory=
|
|
123
|
+
device_id: str = Field(default_factory=generate_device_id)
|
|
84
124
|
client_session_id: int = Field(default_factory=lambda: randint(1, 70))
|
|
85
125
|
|
|
86
126
|
|
|
@@ -102,6 +142,9 @@ class RegistrationConfig(BaseModel):
|
|
|
102
142
|
class ClientConfig(BaseModel):
|
|
103
143
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
104
144
|
|
|
145
|
+
app_version: str | None = VersionCatalog.RECOMMENDED_APP_VERSION
|
|
146
|
+
fingering: ApkBuildFingerprint | None
|
|
147
|
+
|
|
105
148
|
phone: str | None = None
|
|
106
149
|
work_dir: str = "."
|
|
107
150
|
session_name: str = "session.db"
|
|
@@ -109,16 +152,22 @@ class ClientConfig(BaseModel):
|
|
|
109
152
|
token: str | None = None
|
|
110
153
|
proxy: str | None = None
|
|
111
154
|
registration_config: RegistrationConfig | None = None
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
upload_timeout: int = 900
|
|
156
|
+
relogin: bool = True
|
|
157
|
+
host: str = "api2.oneme.ru"
|
|
114
158
|
port: int = 443
|
|
115
159
|
use_ssl: bool = True
|
|
160
|
+
persist_session: bool = True
|
|
116
161
|
|
|
162
|
+
password_max_attempts: int | None = None
|
|
117
163
|
protocol_version: int = 10
|
|
118
164
|
request_timeout: float = 30.0
|
|
119
165
|
log_level: str = "INFO"
|
|
120
166
|
telemetry: bool = False
|
|
121
167
|
|
|
168
|
+
interactive: bool = True
|
|
169
|
+
restore_user_agent_from_session: bool = True
|
|
170
|
+
|
|
122
171
|
store: StoreProtocol | None = None
|
|
123
172
|
|
|
124
173
|
sync: SyncOverrides = Field(default_factory=SyncOverrides)
|
|
@@ -145,13 +194,35 @@ class ExtraConfig(BaseModel):
|
|
|
145
194
|
proxy: Proxy URL для TCP- или WebSocket-транспорта.
|
|
146
195
|
reconnect: Переподключаться после сетевых ошибок.
|
|
147
196
|
reconnect_delay: Пауза перед reconnect.
|
|
148
|
-
|
|
197
|
+
upload_timeout: Общий timeout HTTP-загрузки файлов, видео, голосовых и
|
|
198
|
+
видеосообщений в секундах. По умолчанию ``900``; не применяется к
|
|
199
|
+
фотографиям и 60-секундному ожиданию обработки вложения. ``0`` и
|
|
200
|
+
отрицательные значения отключают общий timeout, но не отдельный
|
|
201
|
+
60-секундный socket/read timeout.
|
|
202
|
+
relogin: Автоматически сбрасывать сессию и повторять авторизацию, если
|
|
203
|
+
Max отозвал login token. По умолчанию ``True``.
|
|
204
|
+
password_max_attempts: Максимальное число попыток ввода пароля 2FA.
|
|
205
|
+
``None`` означает отсутствие лимита; ``0`` и отрицательные значения
|
|
206
|
+
сразу завершают авторизацию с ``PasswordAttemptsExceededError``.
|
|
207
|
+
persist_session: Сохранять token, device/user-agent и sync-state между
|
|
208
|
+
runtime в SQLite или переданном ``store``. По умолчанию ``True``.
|
|
209
|
+
При ``False`` используется отдельный ``InMemoryStore`` и значение
|
|
210
|
+
``store`` игнорируется; после пересоздания runtime, в том числе при
|
|
211
|
+
reconnect, сохраненная сессия недоступна.
|
|
212
|
+
device_id: Явный device ID. Если не передан, генерируется строка из
|
|
213
|
+
16 шестнадцатеричных символов.
|
|
149
214
|
device_type: Тип устройства для mobile user-agent.
|
|
150
|
-
user_agent: Полностью заданный user-agent payload.
|
|
151
|
-
|
|
215
|
+
user_agent: Полностью заданный user-agent payload. Явное значение имеет
|
|
216
|
+
приоритет над user-agent из сохраненной сессии. Автоматически
|
|
217
|
+
сгенерированный user-agent восстанавливает характеристики устройства
|
|
218
|
+
из сессии, сохраняя версию приложения и build number текущего запуска.
|
|
219
|
+
mt_instance_id: Instance ID устройства. По умолчанию генерируется
|
|
220
|
+
строка из 16 шестнадцатеричных символов.
|
|
152
221
|
request_timeout: Timeout API-запросов в секундах.
|
|
153
222
|
log_level: Уровень логов ``pymax``.
|
|
154
223
|
telemetry: Отправлять telemetry-события Max.
|
|
224
|
+
store: Пользовательское хранилище сессии. Используется только при
|
|
225
|
+
``persist_session=True``.
|
|
155
226
|
sync: Переопределения sync-маркеров для login.
|
|
156
227
|
|
|
157
228
|
Example:
|
|
@@ -174,18 +245,22 @@ class ExtraConfig(BaseModel):
|
|
|
174
245
|
token: str | None = None
|
|
175
246
|
registration_config: RegistrationConfig | None = None
|
|
176
247
|
|
|
177
|
-
host: str = "
|
|
248
|
+
host: str = "api2.oneme.ru"
|
|
178
249
|
port: int = 443
|
|
179
|
-
url: str = "wss://
|
|
250
|
+
url: str = "wss://api.oneme.ru/websocket"
|
|
180
251
|
use_ssl: bool = True
|
|
181
252
|
proxy: str | None = None
|
|
182
253
|
reconnect: bool = True
|
|
183
254
|
reconnect_delay: float = 1.0
|
|
255
|
+
upload_timeout: int = 900
|
|
256
|
+
relogin: bool = True
|
|
257
|
+
password_max_attempts: int | None = None
|
|
258
|
+
persist_session: bool = True
|
|
184
259
|
|
|
185
260
|
device_id: str | None = None
|
|
186
261
|
device_type: DeviceType = DeviceType.ANDROID
|
|
187
262
|
user_agent: MobileUserAgentPayload | None = None
|
|
188
|
-
mt_instance_id: str = Field(default_factory=
|
|
263
|
+
mt_instance_id: str = Field(default_factory=generate_device_id)
|
|
189
264
|
|
|
190
265
|
request_timeout: float = 30.0
|
|
191
266
|
log_level: str = "INFO"
|
|
@@ -195,13 +270,17 @@ class ExtraConfig(BaseModel):
|
|
|
195
270
|
|
|
196
271
|
sync: SyncOverrides = Field(default_factory=SyncOverrides)
|
|
197
272
|
|
|
198
|
-
def generate_user_agent(self) -> MobileUserAgentPayload:
|
|
273
|
+
def generate_user_agent(self, app_version: str, build_number: int) -> MobileUserAgentPayload:
|
|
199
274
|
"""Создает mobile user-agent payload для TCP-клиента.
|
|
200
275
|
|
|
276
|
+
Args:
|
|
277
|
+
app_version: Версия Android-клиента Max.
|
|
278
|
+
build_number: Build number той же версии из ``VersionCatalog``.
|
|
279
|
+
|
|
201
280
|
Returns:
|
|
202
|
-
|
|
281
|
+
Случайная конфигурация Android-устройства с переданными версией и
|
|
282
|
+
build number.
|
|
203
283
|
"""
|
|
204
|
-
app_version, build_number = choice(APP_VERSIONS)
|
|
205
284
|
device_name, os_version, screen, arch = choice(ANDROID_DEVICES)
|
|
206
285
|
locale, timezone = choice(LOCALE_TIMEZONES)
|
|
207
286
|
|
pymax/connection/connection.py
CHANGED
|
@@ -37,6 +37,8 @@ class ConnectionManager:
|
|
|
37
37
|
|
|
38
38
|
self._recv_task: asyncio.Task[None] | None = None
|
|
39
39
|
self._event_tasks: set[asyncio.Task[None]] = set()
|
|
40
|
+
self._closed_event = asyncio.Event()
|
|
41
|
+
self._closed_event.set()
|
|
40
42
|
|
|
41
43
|
async def open(self) -> None:
|
|
42
44
|
if self._is_open:
|
|
@@ -48,6 +50,7 @@ class ConnectionManager:
|
|
|
48
50
|
self._is_open = True
|
|
49
51
|
self._connection_lost = False
|
|
50
52
|
self._close_reported = False
|
|
53
|
+
self._closed_event.clear()
|
|
51
54
|
|
|
52
55
|
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
53
56
|
logger.debug("receive loop started")
|
|
@@ -58,25 +61,28 @@ class ConnectionManager:
|
|
|
58
61
|
return
|
|
59
62
|
|
|
60
63
|
logger.info("closing connection")
|
|
61
|
-
if self._recv_task:
|
|
62
|
-
if not self._recv_task.done():
|
|
63
|
-
self._recv_task.cancel()
|
|
64
|
-
with suppress(asyncio.CancelledError, Exception):
|
|
65
|
-
await self._recv_task
|
|
66
|
-
logger.debug("receive loop stopped")
|
|
67
|
-
self._recv_task = None
|
|
68
|
-
|
|
69
|
-
for task in tuple(self._event_tasks):
|
|
70
|
-
if not task.done():
|
|
71
|
-
task.cancel()
|
|
72
|
-
for task in tuple(self._event_tasks):
|
|
73
|
-
with suppress(asyncio.CancelledError, Exception):
|
|
74
|
-
await task
|
|
75
|
-
self._event_tasks.clear()
|
|
76
|
-
|
|
77
|
-
self.requests.cancel_all()
|
|
78
|
-
await self.transport.close()
|
|
79
64
|
self._is_open = False
|
|
65
|
+
try:
|
|
66
|
+
if self._recv_task:
|
|
67
|
+
if not self._recv_task.done():
|
|
68
|
+
self._recv_task.cancel()
|
|
69
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
70
|
+
await self._recv_task
|
|
71
|
+
logger.debug("receive loop stopped")
|
|
72
|
+
self._recv_task = None
|
|
73
|
+
|
|
74
|
+
for task in tuple(self._event_tasks):
|
|
75
|
+
if not task.done():
|
|
76
|
+
task.cancel()
|
|
77
|
+
for task in tuple(self._event_tasks):
|
|
78
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
79
|
+
await task
|
|
80
|
+
self._event_tasks.clear()
|
|
81
|
+
|
|
82
|
+
self.requests.cancel_all()
|
|
83
|
+
await self.transport.close()
|
|
84
|
+
finally:
|
|
85
|
+
self._closed_event.set()
|
|
80
86
|
logger.info("connection closed")
|
|
81
87
|
|
|
82
88
|
async def fail(self, exc: Exception | None = None) -> None:
|
|
@@ -147,6 +153,11 @@ class ConnectionManager:
|
|
|
147
153
|
|
|
148
154
|
try:
|
|
149
155
|
await self._recv_task
|
|
156
|
+
except asyncio.CancelledError:
|
|
157
|
+
if self._is_open:
|
|
158
|
+
raise
|
|
159
|
+
await self._closed_event.wait()
|
|
160
|
+
return
|
|
150
161
|
except Exception as e:
|
|
151
162
|
if self._connection_lost:
|
|
152
163
|
raise ConnectionError("Connection lost") from e
|