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.
Files changed (79) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
  3. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +9 -2
  5. pymax/_data/apk_fingerprints.json +767 -0
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/enums.py +1 -0
  8. pymax/api/auth/payloads.py +28 -2
  9. pymax/api/auth/service.py +100 -3
  10. pymax/api/chats/__init__.py +1 -0
  11. pymax/api/chats/enums.py +15 -0
  12. pymax/api/chats/payloads.py +18 -2
  13. pymax/api/chats/service.py +43 -1
  14. pymax/api/messages/__init__.py +1 -1
  15. pymax/api/messages/payloads.py +21 -7
  16. pymax/api/messages/service.py +161 -24
  17. pymax/api/self/__init__.py +2 -1
  18. pymax/api/self/enums.py +9 -0
  19. pymax/api/self/payloads.py +40 -1
  20. pymax/api/self/service.py +37 -0
  21. pymax/api/session/service.py +21 -8
  22. pymax/api/uploads/payloads.py +39 -1
  23. pymax/api/uploads/service.py +247 -55
  24. pymax/app.py +91 -18
  25. pymax/auth/__init__.py +2 -0
  26. pymax/auth/exceptions.py +9 -0
  27. pymax/auth/providers.py +6 -2
  28. pymax/auth/sms.py +11 -1
  29. pymax/base.py +90 -8
  30. pymax/client.py +39 -10
  31. pymax/client_web.py +17 -14
  32. pymax/config.py +98 -19
  33. pymax/connection/connection.py +29 -18
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +3 -1
  36. pymax/dispatch/resolvers.py +7 -1
  37. pymax/files/__init__.py +4 -1
  38. pymax/files/base.py +58 -1
  39. pymax/files/video.py +51 -1
  40. pymax/files/voice.py +53 -0
  41. pymax/fingerprint/__init__.py +1 -0
  42. pymax/fingerprint/fingerprint.py +30 -0
  43. pymax/fingerprint/models.py +12 -0
  44. pymax/infra/auth.py +4 -0
  45. pymax/infra/bots.py +3 -3
  46. pymax/infra/chat.py +47 -5
  47. pymax/infra/message.py +49 -14
  48. pymax/infra/protocol.py +5 -1
  49. pymax/infra/self.py +25 -1
  50. pymax/protocol/enums.py +13 -0
  51. pymax/protocol/tcp/payload.py +21 -1
  52. pymax/session/__init__.py +1 -1
  53. pymax/session/models.py +30 -0
  54. pymax/session/protocol.py +33 -7
  55. pymax/session/store.py +73 -5
  56. pymax/transport/tcp.py +17 -4
  57. pymax/transport/websocket.py +6 -3
  58. pymax/types/domain/__init__.py +2 -1
  59. pymax/types/domain/attachments/__init__.py +2 -1
  60. pymax/types/domain/attachments/call.py +7 -1
  61. pymax/types/domain/attachments/contact.py +3 -3
  62. pymax/types/domain/attachments/control.py +3 -0
  63. pymax/types/domain/attachments/enums.py +33 -1
  64. pymax/types/domain/attachments/poll.py +111 -0
  65. pymax/types/domain/attachments/sticker.py +1 -1
  66. pymax/types/domain/attachments/video.py +32 -11
  67. pymax/types/domain/chat.py +9 -9
  68. pymax/types/domain/enums.py +7 -0
  69. pymax/types/domain/handshake.py +16 -0
  70. pymax/types/domain/login.py +30 -1
  71. pymax/types/domain/message.py +108 -27
  72. pymax/types/domain/user.py +1 -3
  73. pymax/types/events/__init__.py +1 -0
  74. pymax/types/events/reaction.py +2 -2
  75. pymax/types/events/voice.py +5 -0
  76. pymax/versions/__init__.py +2 -0
  77. pymax/versions/catalog.py +104 -0
  78. pymax/versions/exceptions.py +10 -0
  79. {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
- while True:
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
- _app: App[ClientT]
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
- """Профиль текущего аккаунта после успешного ``start``."""
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 str(uuid4()),
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 ( # noqa: PERF203
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
- await self._app.close()
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._init_runtime(auth_flow=auth_flow)
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.ws import WsProtocol
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._init_runtime(auth_flow=auth_flow)
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=WsProtocol(),
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", 6678),
21
- ("26.11.3", 6680),
22
- ("26.11.2", 6669),
23
- ("26.11.1", 6665),
24
- ("26.11.0", 6661),
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.5.5"
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=lambda: str(uuid4()))
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
- host: str = "api.oneme.ru"
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
- device_id: Явный device ID. Если не передан, генерируется UUID.
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
- mt_instance_id: Instance ID устройства.
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 = "api.oneme.ru"
248
+ host: str = "api2.oneme.ru"
178
249
  port: int = 443
179
- url: str = "wss://ws-api.oneme.ru/websocket"
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=lambda: str(uuid4()))
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
- Случайная, но правдоподобная конфигурация Android-клиента Max.
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
 
@@ -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
pymax/dispatch/enums.py CHANGED
@@ -13,5 +13,6 @@ class EventType(str, Enum):
13
13
  USER_UPDATE = "user_update"
14
14
  VIDEO_READY = "video_ready"
15
15
  FILE_READY = "file_ready"
16
+ VOICE_READY = "voice_ready"
16
17
  RAW = "raw"
17
18
  ON_START = "on_start"