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.
Files changed (47) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
  2. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +4 -2
  5. pymax/_data/apk_fingerprints.json +102 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +14 -2
  8. pymax/api/chats/payloads.py +2 -1
  9. pymax/api/chats/service.py +7 -0
  10. pymax/api/messages/__init__.py +1 -1
  11. pymax/api/messages/payloads.py +9 -3
  12. pymax/api/messages/service.py +106 -12
  13. pymax/api/uploads/payloads.py +33 -1
  14. pymax/api/uploads/service.py +77 -62
  15. pymax/app.py +23 -4
  16. pymax/auth/__init__.py +2 -0
  17. pymax/auth/exceptions.py +9 -0
  18. pymax/auth/providers.py +6 -2
  19. pymax/auth/sms.py +11 -1
  20. pymax/base.py +83 -9
  21. pymax/client.py +39 -10
  22. pymax/client_web.py +15 -12
  23. pymax/config.py +63 -21
  24. pymax/files/base.py +58 -1
  25. pymax/files/video.py +32 -40
  26. pymax/files/voice.py +18 -4
  27. pymax/fingerprint/fingerprint.py +5 -22
  28. pymax/fingerprint/models.py +1 -0
  29. pymax/infra/chat.py +9 -5
  30. pymax/infra/message.py +9 -3
  31. pymax/infra/protocol.py +5 -1
  32. pymax/session/__init__.py +1 -1
  33. pymax/session/models.py +30 -0
  34. pymax/session/protocol.py +33 -7
  35. pymax/session/store.py +73 -5
  36. pymax/transport/tcp.py +11 -3
  37. pymax/types/domain/attachments/call.py +7 -1
  38. pymax/types/domain/attachments/contact.py +3 -3
  39. pymax/types/domain/attachments/control.py +3 -0
  40. pymax/types/domain/attachments/enums.py +16 -0
  41. pymax/types/domain/attachments/video.py +3 -3
  42. pymax/types/domain/enums.py +7 -0
  43. pymax/types/domain/message.py +87 -7
  44. pymax/versions/__init__.py +2 -0
  45. pymax/versions/catalog.py +104 -0
  46. pymax/versions/exceptions.py +10 -0
  47. {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
- _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
51
61
 
52
62
  @property
53
63
  def me(self) -> Profile | None:
54
- """Профиль текущего аккаунта после успешного ``start``."""
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 str(uuid4()),
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
- await self.relogin(start=False)
149
- except ( # noqa: PERF203
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
- await self._app.close()
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._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
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",
pymax/config.py CHANGED
@@ -1,5 +1,5 @@
1
- from random import choice, randint, random
2
- from uuid import uuid4
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.7.15"
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=lambda: str(uuid4()))
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
- host: str = "api.oneme.ru"
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
- 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 шестнадцатеричных символов.
185
214
  device_type: Тип устройства для mobile user-agent.
186
- user_agent: Полностью заданный user-agent payload.
187
- 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 шестнадцатеричных символов.
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 = "api.oneme.ru"
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=lambda: str(uuid4()))
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
- Случайная, но правдоподобная конфигурация Android-клиента Max.
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(Video):
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
- super().__init__(raw, url=url, path=path, name=name)
102
-
103
- async def get_duration(self) -> int:
104
- if self.duration is not None:
105
- return self.duration
106
-
107
- if not TinyTag:
108
- raise RuntimeError(
109
- "Automatic video duration detection requires the 'video' extra. "
110
- "Install it with `uv add 'maxapi-python[video]'` "
111
- "or pass duration manually."
112
- )
113
-
114
- if self.raw:
115
- tag = TinyTag.get(
116
- filename=self.name,
117
- file_obj=BytesIO(self.raw),
118
- tags=False,
119
- duration=True,
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()