maxapi-python 2.4.0__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.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
- pymax/__init__.py +4 -2
- pymax/_data/apk_fingerprints.json +102 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +14 -2
- pymax/api/chats/payloads.py +2 -1
- pymax/api/chats/service.py +7 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/payloads.py +9 -3
- pymax/api/messages/service.py +106 -12
- pymax/api/uploads/payloads.py +33 -1
- pymax/api/uploads/service.py +77 -62
- pymax/app.py +23 -4
- 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 +83 -9
- pymax/client.py +39 -10
- pymax/client_web.py +15 -12
- pymax/config.py +63 -21
- pymax/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- pymax/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -0
- pymax/infra/chat.py +9 -5
- pymax/infra/message.py +9 -3
- pymax/infra/protocol.py +5 -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 +11 -3
- 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 +16 -0
- pymax/types/domain/attachments/video.py +3 -3
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +87 -7
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
pymax/base.py
CHANGED
|
@@ -3,16 +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
|
|
10
9
|
from pymax.exceptions import ApiError
|
|
10
|
+
from pymax.fingerprint.models import ApkBuildFingerprint
|
|
11
11
|
from pymax.infra import BaseMixin
|
|
12
12
|
from pymax.logging import get_logger
|
|
13
13
|
|
|
14
14
|
from .app import App
|
|
15
|
-
from .config import ClientConfig, DeviceConfig, ExtraConfig
|
|
15
|
+
from .config import ClientConfig, DeviceConfig, ExtraConfig, generate_device_id
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
@@ -47,11 +47,21 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
47
47
|
_connection: ConnectionManager
|
|
48
48
|
_auth_flow: AuthFlow
|
|
49
49
|
_router: Router[ClientT]
|
|
50
|
-
|
|
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
|
|
51
61
|
|
|
52
62
|
@property
|
|
53
63
|
def me(self) -> Profile | None:
|
|
54
|
-
"""Профиль текущего аккаунта после успешного
|
|
64
|
+
"""Профиль текущего аккаунта после успешного login."""
|
|
55
65
|
return self._app.me
|
|
56
66
|
|
|
57
67
|
@property
|
|
@@ -69,11 +79,17 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
69
79
|
"""Сообщения, которые Max вернул на login/sync."""
|
|
70
80
|
return self._app.messages
|
|
71
81
|
|
|
82
|
+
@property
|
|
83
|
+
def is_connected(self) -> bool:
|
|
84
|
+
return self.__app is not None and self.__app.connection.is_open
|
|
85
|
+
|
|
72
86
|
def _build_config(
|
|
73
87
|
self,
|
|
74
88
|
*,
|
|
75
89
|
phone: str | None,
|
|
76
90
|
user_agent: MobileUserAgentPayload,
|
|
91
|
+
version: str | None,
|
|
92
|
+
fingerprint: ApkBuildFingerprint | None,
|
|
77
93
|
) -> ClientConfig:
|
|
78
94
|
logger.debug(
|
|
79
95
|
"building client config token_set=%s",
|
|
@@ -94,17 +110,38 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
94
110
|
store=self.extra_config.store,
|
|
95
111
|
proxy=self.extra_config.proxy,
|
|
96
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,
|
|
97
119
|
device=DeviceConfig(
|
|
98
120
|
mt_instance_id=self.extra_config.mt_instance_id,
|
|
99
|
-
device_id=self.extra_config.device_id or
|
|
121
|
+
device_id=self.extra_config.device_id or generate_device_id(),
|
|
100
122
|
user_agent=user_agent,
|
|
101
123
|
),
|
|
102
124
|
)
|
|
103
125
|
|
|
104
126
|
@abstractmethod
|
|
105
127
|
def _build_connection(self) -> ConnectionManager:
|
|
128
|
+
|
|
106
129
|
raise NotImplementedError
|
|
107
130
|
|
|
131
|
+
@abstractmethod
|
|
132
|
+
async def _prepare_config(self) -> ClientConfig:
|
|
133
|
+
raise NotImplementedError
|
|
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
|
+
|
|
108
145
|
def _init_runtime(self: ClientT, *, auth_flow: AuthFlow) -> None: # noqa: PYI019
|
|
109
146
|
self._connection = self._build_connection()
|
|
110
147
|
self._auth_flow = auth_flow
|
|
@@ -125,10 +162,43 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
125
162
|
self._connection = self._build_connection()
|
|
126
163
|
self._app = self._build_app()
|
|
127
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
|
+
|
|
128
193
|
async def start(self: ClientT) -> None: # noqa: PYI019
|
|
129
|
-
"""Запускает клиента и слушает события до закрытия соединения.
|
|
194
|
+
"""Запускает клиента и слушает события до закрытия соединения.
|
|
195
|
+
|
|
196
|
+
При сетевых ошибках повторяет подключение, если ``reconnect=True``.
|
|
197
|
+
Отозванный login token автоматически сбрасывается при ``relogin=True``.
|
|
198
|
+
"""
|
|
130
199
|
while True:
|
|
131
200
|
try:
|
|
201
|
+
await self._ensure_runtime()
|
|
132
202
|
await self._app.start()
|
|
133
203
|
if not self._app.started:
|
|
134
204
|
await self.close()
|
|
@@ -145,8 +215,9 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
145
215
|
raise
|
|
146
216
|
|
|
147
217
|
logger.warning("login token was revoked; starting authentication again")
|
|
148
|
-
|
|
149
|
-
|
|
218
|
+
if self.extra_config.relogin:
|
|
219
|
+
await self.relogin(start=False)
|
|
220
|
+
except (
|
|
150
221
|
ConnectionError,
|
|
151
222
|
EOFError,
|
|
152
223
|
OSError,
|
|
@@ -178,7 +249,10 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
178
249
|
|
|
179
250
|
async def close(self) -> None:
|
|
180
251
|
"""Закрывает соединение, фоновые задачи и файл сессии."""
|
|
181
|
-
|
|
252
|
+
if self.__app is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
await self.__app.close()
|
|
182
256
|
|
|
183
257
|
async def stop(self) -> None:
|
|
184
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
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",
|
pymax/config.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
from
|
|
1
|
+
import secrets
|
|
2
|
+
from random import choice, randint
|
|
3
3
|
|
|
4
4
|
from pydantic import BaseModel, ConfigDict, Field
|
|
5
5
|
|
|
@@ -8,8 +8,10 @@ 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
|
|
|
14
16
|
MIN_PREFERRED_BUILD = 6712
|
|
15
17
|
APP_VERSIONS: tuple[tuple[str, int], ...] = (
|
|
@@ -47,12 +49,12 @@ APP_VERSIONS: tuple[tuple[str, int], ...] = (
|
|
|
47
49
|
("26.12.2", 6681),
|
|
48
50
|
("26.12.1", 6679),
|
|
49
51
|
("26.12.0", 6676),
|
|
50
|
-
("26.11.3", 6670),
|
|
51
|
-
("26.11.2", 6669),
|
|
52
|
-
("26.11.1", 6665),
|
|
53
|
-
("26.10.1", 6653),
|
|
54
|
-
("26.10.0", 6648),
|
|
55
|
-
("26.9.1", 6643),
|
|
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),
|
|
56
58
|
)
|
|
57
59
|
ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
|
|
58
60
|
("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
@@ -104,17 +106,21 @@ LOCALE_TIMEZONES: tuple[tuple[str, str], ...] = (
|
|
|
104
106
|
("ru", "Asia/Yakutsk"),
|
|
105
107
|
("ru", "Asia/Vladivostok"),
|
|
106
108
|
)
|
|
107
|
-
WEB_APP_VERSION = "26.
|
|
109
|
+
WEB_APP_VERSION = "26.8.4"
|
|
108
110
|
WEB_SCREEN = "1080x1920 1.0x"
|
|
109
111
|
|
|
110
112
|
PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD]
|
|
111
113
|
LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD]
|
|
112
114
|
|
|
113
115
|
|
|
116
|
+
def generate_device_id() -> str:
|
|
117
|
+
return secrets.token_hex(8)
|
|
118
|
+
|
|
119
|
+
|
|
114
120
|
class DeviceConfig(BaseModel):
|
|
115
121
|
mt_instance_id: str
|
|
116
122
|
user_agent: MobileUserAgentPayload
|
|
117
|
-
device_id: str = Field(default_factory=
|
|
123
|
+
device_id: str = Field(default_factory=generate_device_id)
|
|
118
124
|
client_session_id: int = Field(default_factory=lambda: randint(1, 70))
|
|
119
125
|
|
|
120
126
|
|
|
@@ -136,6 +142,9 @@ class RegistrationConfig(BaseModel):
|
|
|
136
142
|
class ClientConfig(BaseModel):
|
|
137
143
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
138
144
|
|
|
145
|
+
app_version: str | None = VersionCatalog.RECOMMENDED_APP_VERSION
|
|
146
|
+
fingering: ApkBuildFingerprint | None
|
|
147
|
+
|
|
139
148
|
phone: str | None = None
|
|
140
149
|
work_dir: str = "."
|
|
141
150
|
session_name: str = "session.db"
|
|
@@ -143,17 +152,21 @@ class ClientConfig(BaseModel):
|
|
|
143
152
|
token: str | None = None
|
|
144
153
|
proxy: str | None = None
|
|
145
154
|
registration_config: RegistrationConfig | None = None
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
upload_timeout: int = 900
|
|
156
|
+
relogin: bool = True
|
|
157
|
+
host: str = "api2.oneme.ru"
|
|
148
158
|
port: int = 443
|
|
149
159
|
use_ssl: bool = True
|
|
160
|
+
persist_session: bool = True
|
|
150
161
|
|
|
162
|
+
password_max_attempts: int | None = None
|
|
151
163
|
protocol_version: int = 10
|
|
152
164
|
request_timeout: float = 30.0
|
|
153
165
|
log_level: str = "INFO"
|
|
154
166
|
telemetry: bool = False
|
|
155
167
|
|
|
156
168
|
interactive: bool = True
|
|
169
|
+
restore_user_agent_from_session: bool = True
|
|
157
170
|
|
|
158
171
|
store: StoreProtocol | None = None
|
|
159
172
|
|
|
@@ -181,13 +194,35 @@ class ExtraConfig(BaseModel):
|
|
|
181
194
|
proxy: Proxy URL для TCP- или WebSocket-транспорта.
|
|
182
195
|
reconnect: Переподключаться после сетевых ошибок.
|
|
183
196
|
reconnect_delay: Пауза перед reconnect.
|
|
184
|
-
|
|
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 шестнадцатеричных символов.
|
|
185
214
|
device_type: Тип устройства для mobile user-agent.
|
|
186
|
-
user_agent: Полностью заданный user-agent payload.
|
|
187
|
-
|
|
215
|
+
user_agent: Полностью заданный user-agent payload. Явное значение имеет
|
|
216
|
+
приоритет над user-agent из сохраненной сессии. Автоматически
|
|
217
|
+
сгенерированный user-agent восстанавливает характеристики устройства
|
|
218
|
+
из сессии, сохраняя версию приложения и build number текущего запуска.
|
|
219
|
+
mt_instance_id: Instance ID устройства. По умолчанию генерируется
|
|
220
|
+
строка из 16 шестнадцатеричных символов.
|
|
188
221
|
request_timeout: Timeout API-запросов в секундах.
|
|
189
222
|
log_level: Уровень логов ``pymax``.
|
|
190
223
|
telemetry: Отправлять telemetry-события Max.
|
|
224
|
+
store: Пользовательское хранилище сессии. Используется только при
|
|
225
|
+
``persist_session=True``.
|
|
191
226
|
sync: Переопределения sync-маркеров для login.
|
|
192
227
|
|
|
193
228
|
Example:
|
|
@@ -210,18 +245,22 @@ class ExtraConfig(BaseModel):
|
|
|
210
245
|
token: str | None = None
|
|
211
246
|
registration_config: RegistrationConfig | None = None
|
|
212
247
|
|
|
213
|
-
host: str = "
|
|
248
|
+
host: str = "api2.oneme.ru"
|
|
214
249
|
port: int = 443
|
|
215
250
|
url: str = "wss://api.oneme.ru/websocket"
|
|
216
251
|
use_ssl: bool = True
|
|
217
252
|
proxy: str | None = None
|
|
218
253
|
reconnect: bool = True
|
|
219
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
|
|
220
259
|
|
|
221
260
|
device_id: str | None = None
|
|
222
261
|
device_type: DeviceType = DeviceType.ANDROID
|
|
223
262
|
user_agent: MobileUserAgentPayload | None = None
|
|
224
|
-
mt_instance_id: str = Field(default_factory=
|
|
263
|
+
mt_instance_id: str = Field(default_factory=generate_device_id)
|
|
225
264
|
|
|
226
265
|
request_timeout: float = 30.0
|
|
227
266
|
log_level: str = "INFO"
|
|
@@ -231,14 +270,17 @@ class ExtraConfig(BaseModel):
|
|
|
231
270
|
|
|
232
271
|
sync: SyncOverrides = Field(default_factory=SyncOverrides)
|
|
233
272
|
|
|
234
|
-
def generate_user_agent(self) -> MobileUserAgentPayload:
|
|
273
|
+
def generate_user_agent(self, app_version: str, build_number: int) -> MobileUserAgentPayload:
|
|
235
274
|
"""Создает mobile user-agent payload для TCP-клиента.
|
|
236
275
|
|
|
276
|
+
Args:
|
|
277
|
+
app_version: Версия Android-клиента Max.
|
|
278
|
+
build_number: Build number той же версии из ``VersionCatalog``.
|
|
279
|
+
|
|
237
280
|
Returns:
|
|
238
|
-
|
|
281
|
+
Случайная конфигурация Android-устройства с переданными версией и
|
|
282
|
+
build number.
|
|
239
283
|
"""
|
|
240
|
-
versions = PREFERRED_VERSION if random() < 0.9 else LEGACY_VERSIONS
|
|
241
|
-
app_version, build_number = choice(versions)
|
|
242
284
|
device_name, os_version, screen, arch = choice(ANDROID_DEVICES)
|
|
243
285
|
locale, timezone = choice(LOCALE_TIMEZONES)
|
|
244
286
|
|
pymax/files/base.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
3
|
from collections.abc import AsyncGenerator
|
|
4
|
+
from io import BytesIO
|
|
4
5
|
|
|
5
6
|
import aiofiles
|
|
6
7
|
import aiohttp
|
|
7
8
|
|
|
9
|
+
try:
|
|
10
|
+
from tinytag import TinyTag
|
|
11
|
+
except ImportError:
|
|
12
|
+
TinyTag = None
|
|
13
|
+
|
|
8
14
|
|
|
9
15
|
class BaseFile(ABC):
|
|
10
16
|
def __init__(
|
|
@@ -56,7 +62,7 @@ class BaseFile(ABC):
|
|
|
56
62
|
|
|
57
63
|
if self.url:
|
|
58
64
|
async with aiohttp.ClientSession() as session: # noqa: SIM117
|
|
59
|
-
async with session.head(self.url) as resp:
|
|
65
|
+
async with session.head(self.url, allow_redirects=True) as resp:
|
|
60
66
|
return int(resp.headers["Content-Length"])
|
|
61
67
|
else:
|
|
62
68
|
raise ValueError("Path or Url must be provided")
|
|
@@ -85,3 +91,54 @@ class BaseFile(ABC):
|
|
|
85
91
|
resp.raise_for_status()
|
|
86
92
|
async for chunk in resp.content.iter_chunked(size):
|
|
87
93
|
yield chunk
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TimedMediaFile(BaseFile):
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
raw: bytes | None = None,
|
|
100
|
+
*,
|
|
101
|
+
path: str | None,
|
|
102
|
+
url: str | None,
|
|
103
|
+
name: str | None,
|
|
104
|
+
duration: int | None,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.duration = duration
|
|
107
|
+
super().__init__(raw, path=path, url=url, name=name)
|
|
108
|
+
|
|
109
|
+
async def get_duration(self) -> int:
|
|
110
|
+
if self.duration is not None:
|
|
111
|
+
return self.duration
|
|
112
|
+
|
|
113
|
+
if not TinyTag:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
"Automatic video duration detection requires the 'video' extra. "
|
|
116
|
+
"Install it with `uv add 'maxapi-python[video]'` "
|
|
117
|
+
"or pass duration manually."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if self.raw:
|
|
121
|
+
tag = TinyTag.get(
|
|
122
|
+
filename=self.name,
|
|
123
|
+
file_obj=BytesIO(self.raw),
|
|
124
|
+
tags=False,
|
|
125
|
+
duration=True,
|
|
126
|
+
)
|
|
127
|
+
elif self.path:
|
|
128
|
+
tag = TinyTag.get(
|
|
129
|
+
filename=self.path,
|
|
130
|
+
tags=False,
|
|
131
|
+
duration=True,
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
tag = TinyTag.get(
|
|
135
|
+
filename=self.name,
|
|
136
|
+
file_obj=BytesIO(await self.read()),
|
|
137
|
+
tags=False,
|
|
138
|
+
duration=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if tag.duration is None:
|
|
142
|
+
raise ValueError("Failed to determine video duration.")
|
|
143
|
+
|
|
144
|
+
return round(tag.duration * 1000)
|
pymax/files/video.py
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
from collections.abc import AsyncGenerator
|
|
2
|
-
from io import BytesIO
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
5
|
-
from .base import BaseFile
|
|
6
|
-
|
|
7
|
-
try:
|
|
8
|
-
from tinytag import TinyTag
|
|
9
|
-
except ImportError:
|
|
10
|
-
TinyTag = None
|
|
4
|
+
from .base import BaseFile, TimedMediaFile
|
|
11
5
|
|
|
12
6
|
|
|
13
7
|
class Video(BaseFile):
|
|
@@ -80,12 +74,23 @@ class Video(BaseFile):
|
|
|
80
74
|
return super().iter_chunks(size)
|
|
81
75
|
|
|
82
76
|
|
|
83
|
-
class VideoNote(
|
|
77
|
+
class VideoNote(TimedMediaFile):
|
|
84
78
|
"""Круглое видеосообщение для отправки.
|
|
85
79
|
|
|
86
80
|
Принимает те же источники, что и ``Video``. Длительность задается в
|
|
87
81
|
миллисекундах. Если она не передана, требуется extra ``video`` для
|
|
88
82
|
автоматического определения длительности.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
raw: Байты видеосообщения.
|
|
86
|
+
url: URL видеосообщения.
|
|
87
|
+
path: Локальный путь к видеосообщению.
|
|
88
|
+
name: Имя файла. Обязательно для ``raw``.
|
|
89
|
+
duration: Длительность в миллисекундах. ``None`` включает
|
|
90
|
+
автоматическое определение через extra ``video``.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: При определении длительности без extra ``video``.
|
|
89
94
|
"""
|
|
90
95
|
|
|
91
96
|
def __init__(
|
|
@@ -98,35 +103,22 @@ class VideoNote(Video):
|
|
|
98
103
|
duration: int | None = None,
|
|
99
104
|
) -> None:
|
|
100
105
|
self.duration = duration
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if not
|
|
108
|
-
raise
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
else:
|
|
122
|
-
tag = TinyTag.get(
|
|
123
|
-
filename=self.name,
|
|
124
|
-
file_obj=BytesIO(await self.read()),
|
|
125
|
-
tags=False,
|
|
126
|
-
duration=True,
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
if tag.duration is None:
|
|
130
|
-
raise ValueError("Failed to determine video duration.")
|
|
131
|
-
|
|
132
|
-
return round(tag.duration * 1000)
|
|
106
|
+
self.name: str = name or ""
|
|
107
|
+
if not self.name and path:
|
|
108
|
+
self.name = Path(path).name
|
|
109
|
+
elif not self.name and url:
|
|
110
|
+
self.name = Path(url).name
|
|
111
|
+
|
|
112
|
+
if not self.name:
|
|
113
|
+
raise ValueError("Either name, url or path must be provided.")
|
|
114
|
+
|
|
115
|
+
super().__init__(raw, url=url, path=path, name=self.name, duration=self.duration)
|
|
116
|
+
|
|
117
|
+
async def read(self) -> bytes:
|
|
118
|
+
return await super().read()
|
|
119
|
+
|
|
120
|
+
def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]:
|
|
121
|
+
return super().iter_chunks(size)
|
|
122
|
+
|
|
123
|
+
async def size(self) -> int:
|
|
124
|
+
return await super().size()
|