From a45eb490a066a6d390bb09cfd49b672b54f3cab0 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:18:05 +0300 Subject: [PATCH 01/13] feat: support updating group photos --- src/pymax/api/chats/payloads.py | 3 ++- src/pymax/api/chats/service.py | 7 +++++++ src/pymax/infra/chat.py | 11 ++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 03de78a..1017c30 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -74,8 +74,9 @@ class ChangeGroupSettingsPayload(CamelModel): class ChangeGroupProfilePayload(CamelModel): chat_id: int - theme: str | None + theme: str | None = None description: str | None = None + photo_token: str | None = None class JoinChatPayload(CamelModel): diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 8d8b452..9b88b18 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -14,6 +14,7 @@ require_payload_model, ) from pymax.exceptions import PyMaxError +from pymax.files import Photo from pymax.logging import get_logger from pymax.protocol import Opcode from pymax.types.domain import Chat, Member, Message @@ -211,13 +212,19 @@ async def change_group_profile( chat_id: int, name: str | None, description: str | None = None, + photo: Photo | None = None, ) -> None: + frame = ChangeGroupProfilePayload( chat_id=chat_id, theme=name, description=description, ) + if photo: + photo_payload = await self.app.api.uploads.upload_photo(photo) + frame.photo_token = photo_payload.photo_token + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 3d11b93..669ee1b 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -1,4 +1,5 @@ from pymax.api.chats import ChannelPermissions +from pymax.files import Photo from pymax.types import Chat, Member, Message from .protocol import IClientProtocol @@ -131,20 +132,20 @@ async def change_group_settings( async def change_group_profile( self, chat_id: int, - name: str | None, + name: str | None = None, description: str | None = None, + photo: Photo | None = None, ) -> None: - """Обновляет название и описание группы. + """Обновляет название, описание или фотографию группы. Args: chat_id: ID группы. name: Новое название. description: Новое описание. + photo: Новая фотография группы. """ await self._app.api.chats.change_group_profile( - chat_id=chat_id, - name=name, - description=description, + chat_id=chat_id, name=name, description=description, photo=photo ) async def join_group(self, link: str) -> Chat: From 64f821b1195b5f0b9e06ed7f20fd123f3be6a0cb Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:26:38 +0300 Subject: [PATCH 02/13] feat: make upload timeout configurable --- src/pymax/api/uploads/service.py | 70 ++++++++++++++++---------------- src/pymax/base.py | 1 + src/pymax/config.py | 3 ++ 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index e79d504..bd1155f 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -243,7 +243,7 @@ async def upload_voice(self, voice: Voice) -> VideoAttachPayload: loop = asyncio.get_running_loop() future: asyncio.Future[AudioUploadSignal] = loop.create_future() - timeout = aiohttp.ClientTimeout(total=900, sock_read=60) + timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60) video_id = upload_info.video_id token = upload_info.token @@ -366,7 +366,7 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach file_size, ) - timeout = aiohttp.ClientTimeout(total=900, sock_read=60) + timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60) headers = { "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}", @@ -532,51 +532,51 @@ async def upload_file(self, file: File) -> AttachFilePayload: file_id = upload_info.file_id + timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60) + self.file_upload_waiters[file_id] = future logger.debug("File upload waiter registered file_id=%s", file_id) try: - async with aiohttp.ClientSession( - proxy=self.app.config.proxy, - ) as session: - async with session.post( + async with ( + aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session, + session.post( url=upload_info.url, headers=headers, data=file.iter_chunks(1024 * 1024), - ) as response: - logger.debug( - "File upload HTTP response status=%s file_id=%s", + ) as response, + ): + logger.debug( + "File upload HTTP response status=%s file_id=%s", + response.status, + file_id, + ) + + if response.status != HTTPStatus.OK: + logger.error( + "File upload failed with status %s file_id=%s", response.status, file_id, ) + raise UploadError( + f"File upload failed with status {response.status} file_id={file_id}" + ) - if response.status != HTTPStatus.OK: - logger.error( - "File upload failed with status %s file_id=%s", - response.status, - file_id, - ) - raise UploadError( - f"File upload failed with status {response.status} file_id={file_id}" - ) - - try: - logger.debug( - "Waiting for file processing notification file_id=%s", - file_id, - ) - await asyncio.wait_for(future, 60) - except asyncio.TimeoutError: - logger.warning( - "Timed out waiting for file processing notification file_id=%s", - file_id, - ) - raise UploadError( - f"Timed out waiting for file processing file_id={file_id}" - ) + try: + logger.debug( + "Waiting for file processing notification file_id=%s", + file_id, + ) + await asyncio.wait_for(future, 60) + except asyncio.TimeoutError: + logger.warning( + "Timed out waiting for file processing notification file_id=%s", + file_id, + ) + raise UploadError(f"Timed out waiting for file processing file_id={file_id}") - logger.debug("File upload complete file_id=%s", file_id) - return AttachFilePayload(file_id=file_id) + logger.debug("File upload complete file_id=%s", file_id) + return AttachFilePayload(file_id=file_id) except UploadError: raise diff --git a/src/pymax/base.py b/src/pymax/base.py index 64bfe11..450c646 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -94,6 +94,7 @@ def _build_config( store=self.extra_config.store, proxy=self.extra_config.proxy, registration_config=self.extra_config.registration_config, + upload_timeout=self.extra_config.upload_timeout, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, device_id=self.extra_config.device_id or str(uuid4()), diff --git a/src/pymax/config.py b/src/pymax/config.py index 6af6ed6..72b460d 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -143,6 +143,7 @@ class ClientConfig(BaseModel): token: str | None = None proxy: str | None = None registration_config: RegistrationConfig | None = None + upload_timeout: int = 900 host: str = "api.oneme.ru" port: int = 443 @@ -181,6 +182,7 @@ class ExtraConfig(BaseModel): proxy: Proxy URL для TCP- или WebSocket-транспорта. reconnect: Переподключаться после сетевых ошибок. reconnect_delay: Пауза перед reconnect. + upload_timeout: Timeout загрузки файлов в секундах. device_id: Явный device ID. Если не передан, генерируется UUID. device_type: Тип устройства для mobile user-agent. user_agent: Полностью заданный user-agent payload. @@ -217,6 +219,7 @@ class ExtraConfig(BaseModel): proxy: str | None = None reconnect: bool = True reconnect_delay: float = 1.0 + upload_timeout: int = 900 device_id: str | None = None device_type: DeviceType = DeviceType.ANDROID From 6b590caefd63c5fb457fc43dd9e09182bd2f07d2 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:49:32 +0300 Subject: [PATCH 03/13] feat: add one-shot client connection --- src/pymax/base.py | 26 ++++++++++++++++++++++++-- src/pymax/config.py | 2 ++ tests/app/test_app_runtime.py | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/pymax/base.py b/src/pymax/base.py index 450c646..3454f47 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -95,6 +95,7 @@ def _build_config( proxy=self.extra_config.proxy, registration_config=self.extra_config.registration_config, upload_timeout=self.extra_config.upload_timeout, + relogin=self.extra_config.relogin, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, device_id=self.extra_config.device_id or str(uuid4()), @@ -126,6 +127,26 @@ def _reset_runtime(self: ClientT) -> None: # noqa: PYI019 self._connection = self._build_connection() self._app = self._build_app() + async def connect(self: ClientT) -> None: # noqa: PYI019 + try: + await self._app.start() + if not self._app.started: + await self.close() + return + + await self._app.dispatcher.emit_start(self) + except asyncio.CancelledError: + await self.close() + raise + except (ConnectionError, EOFError, OSError, TimeoutError, ApiError) as e: + await self.close() + await self._app.dispatcher.emit_disconnect( + e, + self.extra_config.reconnect, + self.extra_config.reconnect_delay, + ) + raise + async def start(self: ClientT) -> None: # noqa: PYI019 """Запускает клиента и слушает события до закрытия соединения.""" while True: @@ -146,8 +167,9 @@ async def start(self: ClientT) -> None: # noqa: PYI019 raise logger.warning("login token was revoked; starting authentication again") - await self.relogin(start=False) - except ( # noqa: PERF203 + if self.extra_config.relogin: + await self.relogin(start=False) + except ( ConnectionError, EOFError, OSError, diff --git a/src/pymax/config.py b/src/pymax/config.py index 72b460d..512bcf3 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -144,6 +144,7 @@ class ClientConfig(BaseModel): proxy: str | None = None registration_config: RegistrationConfig | None = None upload_timeout: int = 900 + relogin: bool = True host: str = "api.oneme.ru" port: int = 443 @@ -220,6 +221,7 @@ class ExtraConfig(BaseModel): reconnect: bool = True reconnect_delay: float = 1.0 upload_timeout: int = 900 + relogin: bool = True device_id: str | None = None device_type: DeviceType = DeviceType.ANDROID diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 3e9dc8b..a765077 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -104,6 +104,7 @@ def __init__(self, app: App["RuntimeClient"], router: Router["RuntimeClient"]) - self.extra_config = SimpleNamespace( reconnect=False, reconnect_delay=0, + relogin=True, token=app.config.token, ) From 68e13c43cee85bb5b32a9677ca56f606a7b71d15 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:14:40 +0300 Subject: [PATCH 04/13] feat: add version-aware mobile runtime --- src/pymax/_data/rootca_ssl_rsa2022.crt | 33 ++++++++++++ src/pymax/api/auth/service.py | 16 +++++- src/pymax/app.py | 4 +- src/pymax/base.py | 21 ++++++++ src/pymax/client.py | 30 ++++++++--- src/pymax/client_web.py | 19 +++---- src/pymax/config.py | 30 ++++++----- src/pymax/fingerprint/fingerprint.py | 27 ++-------- src/pymax/fingerprint/models.py | 1 + src/pymax/transport/tcp.py | 14 +++-- src/pymax/versions/__init__.py | 2 + src/pymax/versions/catalog.py | 72 ++++++++++++++++++++++++++ src/pymax/versions/exceptions.py | 8 +++ tests/api/test_auth_service.py | 3 -- 14 files changed, 220 insertions(+), 60 deletions(-) create mode 100644 src/pymax/_data/rootca_ssl_rsa2022.crt create mode 100644 src/pymax/versions/__init__.py create mode 100644 src/pymax/versions/catalog.py create mode 100644 src/pymax/versions/exceptions.py diff --git a/src/pymax/_data/rootca_ssl_rsa2022.crt b/src/pymax/_data/rootca_ssl_rsa2022.crt new file mode 100644 index 0000000..fe7ad30 --- /dev/null +++ b/src/pymax/_data/rootca_ssl_rsa2022.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n +qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q +XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U +zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX +YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y +Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD +U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD +4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 +G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH +BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX +ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa +OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf +BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS +BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH +tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq +W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ +/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS +AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj +C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV +4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d +WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ +D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC +EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq +391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= +-----END CERTIFICATE----- diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 0ca8504..24f5996 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -76,8 +76,14 @@ async def request_code(self, phone: str) -> StartAuthResponse: + "in AuthService.request_code. Please report this issue to the developer." ) + if self.app.fingerprint_generator is None: + raise ValueError( + "Unexpected internal state: self.app.fingerprint_generator is missing and " + + "DeviceType != WEB" + + "in AuthService.request_code. Please report this issue to the developer." + ) + mode = self.app.fingerprint_generator.generate_fingerprint( - version=self.app.config.device.user_agent.app_version, device_id=device_id, calls_seed=self.app.handshake_response.calls_seed, arch=self.app.config.device.user_agent.arch or "arm64-v8a", @@ -163,8 +169,14 @@ async def mobile_login(self) -> LoginResponse: + "in AuthService.mobile_login. Please report this issue to the developer." ) + if self.app.fingerprint_generator is None: + raise ValueError( + "Unexpected internal state: self.app.fingerprint_generator is missing and " + + "DeviceType != WEB" + + "in AuthService.request_code. Please report this issue to the developer." + ) + ccf = self.app.fingerprint_generator.generate_fingerprint( - version=self.app.config.device.user_agent.app_version, device_id=device_id, calls_seed=self.app.handshake_response.calls_seed, arch=self.app.config.device.user_agent.arch or "arm64-v8a", diff --git a/src/pymax/app.py b/src/pymax/app.py index f7ccefa..2c729f8 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -40,7 +40,9 @@ def __init__( self.config = config self.store = self.config.store or SessionStore(config.work_dir, config.session_name) self.auth_flow = auth_flow - self.fingerprint_generator = FingerprintGenerator() + self.fingerprint_generator: FingerprintGenerator | None = ( + FingerprintGenerator(self.config.fingering) if self.config.fingering else None + ) self.me: Profile | None = None self.chats: list[Chat] | None = None diff --git a/src/pymax/base.py b/src/pymax/base.py index 3454f47..b33c095 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -8,6 +8,7 @@ from pymax.dispatch import ErrorScope, Router from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator from pymax.exceptions import ApiError +from pymax.fingerprint.models import ApkBuildFingerprint from pymax.infra import BaseMixin from pymax.logging import get_logger @@ -74,6 +75,8 @@ def _build_config( *, phone: str | None, user_agent: MobileUserAgentPayload, + version: str | None, + fingerprint: ApkBuildFingerprint | None, ) -> ClientConfig: logger.debug( "building client config token_set=%s", @@ -96,6 +99,8 @@ def _build_config( registration_config=self.extra_config.registration_config, upload_timeout=self.extra_config.upload_timeout, relogin=self.extra_config.relogin, + app_version=version, + fingering=fingerprint, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, device_id=self.extra_config.device_id or str(uuid4()), @@ -105,8 +110,19 @@ def _build_config( @abstractmethod def _build_connection(self) -> ConnectionManager: + + raise NotImplementedError + + @abstractmethod + async def _prepare_config(self) -> ClientConfig: raise NotImplementedError + async def _ensure_runtime(self: ClientT) -> None: # noqa: PYI019 + if getattr(self, "_config", None) is None: + self._config = await self._prepare_config() + self._connection = self._build_connection() + self._app = self._build_app() + def _init_runtime(self: ClientT, *, auth_flow: AuthFlow) -> None: # noqa: PYI019 self._connection = self._build_connection() self._auth_flow = auth_flow @@ -129,6 +145,7 @@ def _reset_runtime(self: ClientT) -> None: # noqa: PYI019 async def connect(self: ClientT) -> None: # noqa: PYI019 try: + await self._ensure_runtime() await self._app.start() if not self._app.started: await self.close() @@ -151,6 +168,7 @@ async def start(self: ClientT) -> None: # noqa: PYI019 """Запускает клиента и слушает события до закрытия соединения.""" while True: try: + await self._ensure_runtime() await self._app.start() if not self._app.started: await self.close() @@ -201,6 +219,9 @@ async def start(self: ClientT) -> None: # noqa: PYI019 async def close(self) -> None: """Закрывает соединение, фоновые задачи и файл сессии.""" + if getattr(self, "_app", None) is None: # TODO: maybe impl it better way + return + await self._app.close() async def stop(self) -> None: diff --git a/src/pymax/client.py b/src/pymax/client.py index 8ae277f..d60dc92 100644 --- a/src/pymax/client.py +++ b/src/pymax/client.py @@ -7,12 +7,15 @@ SmsAuthFlow, SmsCodeProvider, ) +from pymax.config import ClientConfig from pymax.connection import ConnectionManager from pymax.connection.readers import TCPReader +from pymax.dispatch import Router from pymax.logging import configure_logging, get_logger from pymax.protocol.tcp import TcpProtocol from pymax.protocol.tcp.framing import TcpPacketFramer from pymax.transport.tcp import TCPTransport +from pymax.versions.catalog import VersionCatalog from .base import BaseClient from .config import ExtraConfig @@ -48,12 +51,16 @@ def __init__( # noqa: PLR0913 auth_flow: AuthFlow | None = None, sms_code_provider: SmsCodeProvider | None = None, password_provider: PasswordProvider | None = None, + app_version: str = VersionCatalog.RECOMMENDED_APP_VERSION, + catalog: VersionCatalog | None = None, ) -> None: self.phone = phone self.extra_config = extra_config or ExtraConfig() self.session_name = session_name self.work_dir = work_dir + self.app_version = app_version + self.catalog = catalog or VersionCatalog() configure_logging(self.extra_config.log_level) logger.debug( @@ -65,17 +72,13 @@ def __init__( # noqa: PLR0913 self.extra_config.reconnect, ) - self._config = self._build_config( - phone=phone, - user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()), - ) - if auth_flow is None: auth_flow = SmsAuthFlow( sms_code_provider or ConsoleSmsCodeProvider(), password_provider, ) - self._init_runtime(auth_flow=auth_flow) + self._auth_flow = auth_flow + self._router = Router() logger.debug( "client created transport=tcp host=%s port=%s", @@ -83,6 +86,21 @@ def __init__( # noqa: PLR0913 self.extra_config.port, ) + async def _prepare_config(self) -> ClientConfig: + catalog = await self.catalog.load() + fingerprint = catalog.resolve(self.app_version) + + user_agent = self.extra_config.user_agent or self.extra_config.generate_user_agent( + self.app_version, fingerprint.build_number + ) + + return self._build_config( + phone=self.phone, + user_agent=user_agent, + version=self.app_version, + fingerprint=fingerprint, + ) + def _build_connection(self) -> ConnectionManager: logger.debug( "building tcp connection host=%s port=%s ssl=%s", diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index 77cc61e..fe21136 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -5,12 +5,13 @@ from pymax.auth.qr import QrAuthFlow from pymax.connection import ConnectionManager from pymax.connection.readers import WSReader +from pymax.dispatch import Router from pymax.logging import configure_logging, get_logger from pymax.protocol.tcp import TcpProtocol from pymax.transport.websocket import WebSocketTransport from .base import BaseClient -from .config import ExtraConfig +from .config import ClientConfig, ExtraConfig logger = get_logger(__name__) @@ -51,22 +52,22 @@ def __init__( self.extra_config.reconnect, ) - self._config = self._build_config( - phone=None, - user_agent=( - self.extra_config.user_agent or self.extra_config.generate_web_user_agent() - ), - ) - if auth_flow is None: auth_flow = QrAuthFlow(qr_provider or ConsoleQrHandler()) - self._init_runtime(auth_flow=auth_flow) + self._auth_flow = auth_flow + self._router = Router() logger.debug( "web client created transport=ws url=%s", self.extra_config.url, ) + async def _prepare_config(self) -> ClientConfig: + user_agent = self.extra_config.user_agent or self.extra_config.generate_web_user_agent() + return self._build_config( + phone=None, user_agent=user_agent, version=None, fingerprint=None + ) + def _build_connection(self) -> ConnectionManager: logger.debug( "building websocket connection url=%s", diff --git a/src/pymax/config.py b/src/pymax/config.py index 512bcf3..6557728 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -1,4 +1,4 @@ -from random import choice, randint, random +from random import choice, randint from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field @@ -8,8 +8,10 @@ DEFAULT_WEB_HEADER_USER_AGENT, MobileUserAgentPayload, ) +from pymax.fingerprint.models import ApkBuildFingerprint from pymax.session import StoreProtocol from pymax.types.domain.sync import SyncOverrides +from pymax.versions.catalog import VersionCatalog MIN_PREFERRED_BUILD = 6712 APP_VERSIONS: tuple[tuple[str, int], ...] = ( @@ -47,12 +49,12 @@ ("26.12.2", 6681), ("26.12.1", 6679), ("26.12.0", 6676), - ("26.11.3", 6670), - ("26.11.2", 6669), - ("26.11.1", 6665), - ("26.10.1", 6653), - ("26.10.0", 6648), - ("26.9.1", 6643), + # ("26.11.3", 6670), # Not supported TODO: delete maybe + # ("26.11.2", 6669), + # ("26.11.1", 6665), + # ("26.10.1", 6653), + # ("26.10.0", 6648), + # ("26.9.1", 6643), ) ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = ( ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"), @@ -104,7 +106,7 @@ ("ru", "Asia/Yakutsk"), ("ru", "Asia/Vladivostok"), ) -WEB_APP_VERSION = "26.7.15" +WEB_APP_VERSION = "26.8.4" WEB_SCREEN = "1080x1920 1.0x" PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD] @@ -136,6 +138,9 @@ class RegistrationConfig(BaseModel): class ClientConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) + app_version: str | None = VersionCatalog.RECOMMENDED_APP_VERSION + fingering: ApkBuildFingerprint | None + phone: str | None = None work_dir: str = "." session_name: str = "session.db" @@ -145,8 +150,7 @@ class ClientConfig(BaseModel): registration_config: RegistrationConfig | None = None upload_timeout: int = 900 relogin: bool = True - - host: str = "api.oneme.ru" + host: str = "api2.oneme.ru" port: int = 443 use_ssl: bool = True @@ -213,7 +217,7 @@ class ExtraConfig(BaseModel): token: str | None = None registration_config: RegistrationConfig | None = None - host: str = "api.oneme.ru" + host: str = "api2.oneme.ru" port: int = 443 url: str = "wss://api.oneme.ru/websocket" use_ssl: bool = True @@ -236,14 +240,12 @@ class ExtraConfig(BaseModel): sync: SyncOverrides = Field(default_factory=SyncOverrides) - def generate_user_agent(self) -> MobileUserAgentPayload: + def generate_user_agent(self, app_version: str, build_number: int) -> MobileUserAgentPayload: """Создает mobile user-agent payload для TCP-клиента. Returns: Случайная, но правдоподобная конфигурация Android-клиента Max. """ - versions = PREFERRED_VERSION if random() < 0.9 else LEGACY_VERSIONS - app_version, build_number = choice(versions) device_name, os_version, screen, arch = choice(ANDROID_DEVICES) locale, timezone = choice(LOCALE_TIMEZONES) diff --git a/src/pymax/fingerprint/fingerprint.py b/src/pymax/fingerprint/fingerprint.py index a277693..0f0ab75 100644 --- a/src/pymax/fingerprint/fingerprint.py +++ b/src/pymax/fingerprint/fingerprint.py @@ -1,47 +1,30 @@ import hashlib -import json import struct -from importlib import resources -from typing import Any from .models import ApkBuildFingerprint class FingerprintGenerator: - def __init__( - self, - ) -> None: - self.path = resources.files("pymax._data") / "apk_fingerprints.json" - self.data = self.load_fingerprints() - - def load_fingerprints(self) -> Any: - with self.path.open("r", encoding="utf-8") as f: - return json.load(f) + def __init__(self, version_data: ApkBuildFingerprint) -> None: + self.data = version_data def generate_fingerprint( self, - version: str, device_id: str, calls_seed: int, arch: str = "arm64-v8a", ) -> bytes | None: - data = self.data.get(version) - if not data: - return None - - model = ApkBuildFingerprint.model_validate(data) - seed_bytes = struct.pack(">q", calls_seed) device_bytes = device_id.encode("utf-8") h1 = hashlib.sha256( - bytes.fromhex(model.certificate_meta_sha256) + seed_bytes + device_bytes + bytes.fromhex(self.data.certificate_meta_sha256) + seed_bytes + device_bytes ).digest() h2 = hashlib.sha256( - bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes + bytes.fromhex(self.data.dex_meta_sha256) + seed_bytes + device_bytes ).digest() h3 = hashlib.sha256( - bytes.fromhex(model.so_meta_sha256[arch]) + seed_bytes + device_bytes + bytes.fromhex(self.data.so_meta_sha256[arch]) + seed_bytes + device_bytes ).digest() return h1 + h2 + h3 diff --git a/src/pymax/fingerprint/models.py b/src/pymax/fingerprint/models.py index 9bcb2da..54ca8a6 100644 --- a/src/pymax/fingerprint/models.py +++ b/src/pymax/fingerprint/models.py @@ -7,5 +7,6 @@ class ApkBuildFingerprint(BaseModel): certificate_meta_sha256: str certificate_sha256: list[str] dex_meta_sha256: str + so_meta_sha256_arm64_v8a: str so_meta_sha256: dict[str, str] build_number: int diff --git a/src/pymax/transport/tcp.py b/src/pymax/transport/tcp.py index 309766c..c5663af 100644 --- a/src/pymax/transport/tcp.py +++ b/src/pymax/transport/tcp.py @@ -1,4 +1,6 @@ import asyncio +import ssl +from importlib import resources from python_socks.async_.asyncio import Proxy @@ -16,6 +18,10 @@ def __init__(self, host: str, port: int, proxy: str | None, use_ssl: bool = True self._port = port self._proxy = proxy self._use_ssl = use_ssl + self._ssl_ctx = ssl.create_default_context() + self._ssl_ctx.load_verify_locations( + str(resources.files("pymax._data") / "rootca_ssl_rsa2022.crt") + ) self._reader = None self._writer = None @@ -26,6 +32,9 @@ async def connect(self) -> None: self._port, self._use_ssl, ) + ssl_ctx = self._ssl_ctx if self._use_ssl else None + server_hostname = self._host if self._use_ssl else None + if self._proxy: logger.debug("tcp connecting via proxy %s", self._proxy) proxy = Proxy.from_url(self._proxy) @@ -33,10 +42,9 @@ async def connect(self) -> None: dest_host=self._host, dest_port=self._port, ) - server_hostname = self._host if self._use_ssl else None self._reader, self._writer = await asyncio.open_connection( sock=sock, - ssl=self._use_ssl, + ssl=ssl_ctx, server_hostname=server_hostname, ) logger.info( @@ -50,7 +58,7 @@ async def connect(self) -> None: self._reader, self._writer = await asyncio.open_connection( self._host, self._port, - ssl=self._use_ssl, + ssl=ssl_ctx, ) logger.info( "tcp connected host=%s port=%s ssl=%s", diff --git a/src/pymax/versions/__init__.py b/src/pymax/versions/__init__.py new file mode 100644 index 0000000..5ca3caa --- /dev/null +++ b/src/pymax/versions/__init__.py @@ -0,0 +1,2 @@ +from .catalog import VersionCatalog +from .exceptions import VersionNotFoundError diff --git a/src/pymax/versions/catalog.py b/src/pymax/versions/catalog.py new file mode 100644 index 0000000..9d0e6ba --- /dev/null +++ b/src/pymax/versions/catalog.py @@ -0,0 +1,72 @@ +import json +from importlib import resources +from typing import Any + +import aiohttp + +from pymax.fingerprint.models import ApkBuildFingerprint + +from .exceptions import VersionAlreadyExistsError, VersionNotFoundError + + +class VersionCatalog: + RECOMMENDED_APP_VERSION: str = "26.25.0" + DEFAULT_REMOTE_URL: str = "https://hashes.pymax.org/versions.json" + + def __init__(self, remote: bool = False) -> None: + self.path = resources.files("pymax._data") / "apk_fingerprints.json" + self.remote = remote + self._data = self._load_fingerprints() + self.versions = self._convert_data() + + @staticmethod + def recommended() -> str: + return VersionCatalog.RECOMMENDED_APP_VERSION + + async def load(self) -> "VersionCatalog": + if not self.remote: + return self + + versions = await self._fetch() + self.versions.update(versions) + + return self + + def add(self, version: str, fingerprint: ApkBuildFingerprint, override: bool = False) -> None: + if self.versions.get(version) is not None and override is False: + raise VersionAlreadyExistsError(version) + + self.versions[version] = fingerprint + + def resolve(self, version: str) -> ApkBuildFingerprint: + try: + return self.versions[version] + except KeyError as e: + raise VersionNotFoundError(version) from e + + async def _fetch(self) -> dict[str, ApkBuildFingerprint]: + async with ( + aiohttp.ClientSession() as session, + session.get(self.DEFAULT_REMOTE_URL) as response, + ): + data = await response.json() + response.raise_for_status() + + if not isinstance(data, dict): + raise ValueError # TODO: msg + + versions: dict[str, ApkBuildFingerprint] = {} + for k, v in data.items(): + versions[k] = ApkBuildFingerprint.model_validate(v) + + return versions + + def _load_fingerprints(self) -> dict[str, Any]: + with self.path.open("r", encoding="utf-8") as f: + return json.load(f) + + def _convert_data(self) -> dict[str, ApkBuildFingerprint]: + versions: dict[str, ApkBuildFingerprint] = {} + for k, v in self._data.items(): + versions[k] = ApkBuildFingerprint.model_validate(v) + return versions diff --git a/src/pymax/versions/exceptions.py b/src/pymax/versions/exceptions.py new file mode 100644 index 0000000..d4bcb32 --- /dev/null +++ b/src/pymax/versions/exceptions.py @@ -0,0 +1,8 @@ +class VersionNotFoundError(ValueError): + def __init__(self, version: str) -> None: + super().__init__(f"Could not found version {version} in registry") + + +class VersionAlreadyExistsError(ValueError): + def __init__(self, version: str) -> None: + super().__init__(f"Version {version} already exists in registry") diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index 67bdb18..61f2c42 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -84,7 +84,6 @@ async def test_request_and_send_code_parse_auth_responses() -> None: ] assert app.calls[0].payload["phone"] == "+79990000000" assert app.calls[0].payload["mode"] == app.fingerprint_generator.generate_fingerprint( - version=app.config.device.user_agent.app_version, device_id=app.config.device.device_id, calls_seed=123, ) @@ -146,7 +145,6 @@ async def test_mobile_request_code_accepts_zero_calls_seed() -> None: await app.api.auth.request_code("+79990000000") assert app.calls[0].payload["mode"] == app.fingerprint_generator.generate_fingerprint( - version=app.config.device.user_agent.app_version, device_id=app.config.device.device_id, calls_seed=0, ) @@ -190,7 +188,6 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert app.calls[0].payload[ "chatCacheFingerprint" ] == app.fingerprint_generator.generate_fingerprint( - version=app.config.device.user_agent.app_version, device_id=app.config.device.device_id, calls_seed=123, ) From 185436ef7771fba56611d56a6474215ad8c2e170 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:15:04 +0300 Subject: [PATCH 05/13] feat: extend message scheduling and reactions --- src/pymax/api/messages/__init__.py | 2 +- src/pymax/api/messages/payloads.py | 12 +++++++++--- src/pymax/api/messages/service.py | 25 ++++++++++++++++++++++--- src/pymax/infra/message.py | 9 ++++++--- src/pymax/types/domain/message.py | 19 +++++++++++++++---- 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/pymax/api/messages/__init__.py b/src/pymax/api/messages/__init__.py index af87efc..66616af 100644 --- a/src/pymax/api/messages/__init__.py +++ b/src/pymax/api/messages/__init__.py @@ -1 +1 @@ -from .service import MessageService +from .service import DateTimeUnion, MessageService diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 840857d..3a1c2fe 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -33,12 +33,18 @@ class ReplyLink(CamelModel): message_id: int +class DelayedAttributes(CamelModel): + time_to_fire: int + notify_sender: bool + + class SendMessagePayloadMessage(CamelModel): text: str | None = None cid: int elements: list[Any] attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] link: ReplyLink | None = None + delayed_attributes: DelayedAttributes | None = None class SendMessagePayload(CamelModel): @@ -111,18 +117,18 @@ class ReactionInfoPayload(CamelModel): class AddReactionPayload(CamelModel): chat_id: int - message_id: str + message_id: int reaction: ReactionInfoPayload class GetReactionsPayload(CamelModel): chat_id: int - message_ids: list[str] + message_ids: list[int] class RemoveReactionPayload(CamelModel): chat_id: int - message_id: str + message_id: int class ReadMessagesPayload(CamelModel): diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index da6c23d..255031a 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -2,6 +2,7 @@ import time from collections.abc import Sequence +from datetime import datetime, timedelta from typing import TYPE_CHECKING, TypeAlias from pymax.api.binding import bind_api_model, bind_api_models @@ -36,6 +37,7 @@ from .payloads import ( AddReactionPayload, ChatHistoryPayload, + DelayedAttributes, DeleteMessagePayload, EditMessagePayload, ForwardLink, @@ -60,6 +62,7 @@ SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None +DateTimeUnion: TypeAlias = datetime | timedelta | int logger = get_logger(__name__) @@ -122,6 +125,15 @@ async def _upload_attachments( return result + def _convert_time(self, time: DateTimeUnion) -> int: + if isinstance(time, datetime): + return int(time.timestamp() * 1000) + + if isinstance(time, timedelta): + return int((datetime.now() + time).timestamp() * 1000) + + return time * 1000 + async def send_message( self, chat_id: int, @@ -130,6 +142,7 @@ async def send_message( attachments: SendAttachments = None, *, notify: bool = True, + send_at: DateTimeUnion | None = None, ) -> Message: logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0) @@ -150,6 +163,12 @@ async def send_message( elements=elements, attaches=await self._upload_attachments(attachments), link=ReplyLink(message_id=reply_to) if reply_to else None, + delayed_attributes=DelayedAttributes( + time_to_fire=self._convert_time(send_at), + notify_sender=notify, + ) + if send_at + else None, ), notify=notify, ) @@ -385,7 +404,7 @@ async def get_file_by_id( async def add_reaction( self, chat_id: int, - message_id: str, + message_id: int, reaction: str, ) -> ReactionInfo | None: logger.info( @@ -410,7 +429,7 @@ async def add_reaction( async def get_reactions( self, chat_id: int, - message_ids: list[str], + message_ids: list[int], ) -> dict[str, ReactionInfo] | None: logger.info( "getting reactions chat_id=%s message_ids=%s", @@ -438,7 +457,7 @@ async def get_reactions( async def remove_reaction( self, chat_id: int, - message_id: str, + message_id: int, ) -> ReactionInfo | None: logger.info( "removing reaction chat_id=%s message_id=%s", diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 92bd614..8b7fc1d 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -1,3 +1,4 @@ +from pymax.api.messages import DateTimeUnion from pymax.api.messages.enums import ItemType from pymax.api.messages.service import SendAttachments from pymax.types import ( @@ -23,6 +24,7 @@ async def send_message( attachments: SendAttachments = None, *, notify: bool = True, + send_at: DateTimeUnion | None = None, ) -> Message: """Отправляет сообщение в чат. @@ -45,6 +47,7 @@ async def send_message( reply_to, attachments, notify=notify, + send_at=send_at, ) async def get_message( @@ -272,7 +275,7 @@ async def get_file_by_id( async def add_reaction( self, chat_id: int, - message_id: str, + message_id: int, reaction: str, ) -> ReactionInfo | None: """Добавляет реакцию к сообщению. @@ -294,7 +297,7 @@ async def add_reaction( async def get_reactions( self, chat_id: int, - message_ids: list[str], + message_ids: list[int], ) -> dict[str, ReactionInfo] | None: """Возвращает реакции для нескольких сообщений. @@ -314,7 +317,7 @@ async def get_reactions( async def remove_reaction( self, chat_id: int, - message_id: str, + message_id: int, ) -> ReactionInfo | None: """Удаляет свою реакцию с сообщения. diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index ca636fc..13d6830 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -27,7 +27,7 @@ from .enums import MessageStatus if TYPE_CHECKING: - from pymax.api.messages.service import MessageService + from pymax.api.messages import DateTimeUnion, MessageService KnownAttachment: TypeAlias = Annotated[ @@ -91,6 +91,12 @@ class ReadState(CamelModel): mark: int +class DelayedAttributes(CamelModel): + time_to_fire: int + notify_sender: bool + notify_opponents: bool + + class Message(CamelModel): """Сообщение Max с методами для действий над ним. @@ -169,6 +175,7 @@ async def on_message(message: Message, client: Client) -> None: unread: int | None = None mark: int | None = None elements: list[Element] = Field(default_factory=list) + delayed_attributes: DelayedAttributes | None = None _actions: MessageService | None = PrivateAttr(default=None) @@ -189,6 +196,7 @@ async def reply( attachments: SendAttachments = None, *, notify: bool = True, + send_at: DateTimeUnion | None = None, ) -> Message: """Отправляет ответ на это сообщение в тот же чат. @@ -211,6 +219,7 @@ async def reply( reply_to=self.id, attachments=attachments, notify=notify, + send_at=send_at, ) async def answer( @@ -220,6 +229,7 @@ async def answer( attachments: SendAttachments = None, *, notify: bool = True, + send_at: DateTimeUnion | None = None, ) -> Message: """Отправляет сообщение в тот же чат. @@ -244,6 +254,7 @@ async def answer( reply_to=reply_to, attachments=attachments, notify=notify, + send_at=send_at, ) async def forward( @@ -362,7 +373,7 @@ async def react(self, reaction: str) -> ReactionInfo | None: return await actions.add_reaction( chat_id=chat_id, - message_id=str(self.id), # :C + message_id=self.id, reaction=reaction, ) @@ -378,7 +389,7 @@ async def unreact(self) -> ReactionInfo | None: return await actions.remove_reaction( chat_id=chat_id, - message_id=str(self.id), # :C x2 + message_id=self.id, ) async def get_reactions(self) -> dict[str, ReactionInfo] | None: @@ -394,7 +405,7 @@ async def get_reactions(self) -> dict[str, ReactionInfo] | None: return await actions.get_reactions( chat_id=chat_id, - message_ids=[str(self.id)], # :C x3 + message_ids=[self.id], ) def _bound(self) -> tuple[MessageService, int]: From f2a497eae6709d03a2ab37a3e75e70801063dba6 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:15:50 +0300 Subject: [PATCH 06/13] chore: add TODO tracker --- TODO.md | 42 ++++++++++++++++++++++++++++++++++++++++++ scripts/todo.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 TODO.md create mode 100644 scripts/todo.py diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a9f60e4 --- /dev/null +++ b/TODO.md @@ -0,0 +1,42 @@ +# PyMax TODO :3 + +> [!NOTE] +> This file is maintained by the project owner. Please do not edit it in pull requests. + +Format: +- Refactoring / Bugs: `TEXT. PATH. PRIORITY` +- Features: `TEXT. TARGET. PRIORITY` + +## Refactoring + +- [ ] Перевести все аргументы, связанные со временем, на `DateTimeUnion`. `api/messages/service.py:65`. P: 50 +- [ ] Перенести часть общих типов в `common`, например `SendAttachments`. -. P: 40 +- [ ] Пересмотреть систему типов, возможно разбить её на более мелкие части. -. P: 70 + +## Bugs + +- [ ] Исправить голосовые/кружки. `api/upload/service.py`. P: 100 +- [ ] Исправить генерацию device_id. `src/pymax/base.py:106`. P: 80 + +## Features + +- [ ] FSM. Почти готово. `2.5.0`. P: 80 +- [ ] DI. `2.5.0`. P: 75 +- [ ] Улучшенная система фильтров. Почти готово. `2.5.0`. P: 90 + + +# Autogenerated TODO. Please, dont touch it +- [ ] src/pymax/api/chats/payloads.py:100: ENUMM!!! +- [ ] src/pymax/api/chats/payloads.py:123: ENUMM!!! +- [ ] src/pymax/api/messages/payloads.py:32: enum? +- [ ] src/pymax/api/uploads/service.py:66: ENUM!!!! +- [ ] src/pymax/api/users/service.py:133: maybe also return phone mapping? +- [ ] src/pymax/base.py:222: maybe impl it better way +- [ ] src/pymax/config.py:52: delete maybe +- [ ] src/pymax/dispatch/dispatcher.py:190: create iter_on_start_handlers +- [ ] src/pymax/protocol/tcp/payload.py:34: deprecate? idk +- [ ] src/pymax/transport/websocket.py:28: origin should be configurable +- [ ] src/pymax/types/domain/attachments/video.py:70: idk maybe | None = None better +- [ ] src/pymax/types/domain/user.py:14: move to another file +- [ ] src/pymax/types/events/message.py:49: impl it in the better way maybe +- [ ] src/pymax/versions/catalog.py:56: msg diff --git a/scripts/todo.py b/scripts/todo.py new file mode 100644 index 0000000..6b4243e --- /dev/null +++ b/scripts/todo.py @@ -0,0 +1,48 @@ +import os +import subprocess +from pathlib import Path + +TODO_PATH = Path("TODO.md") +AUTOGENERATED_TODO_HEADER = "# Autogenerated TODO. Please, dont touch it\n" + +result = subprocess.run( + [ + "git", + "--no-pager", + "grep", + "-nE", + "TODO|FIXME|HACK|XXX", + "--", + "src/", + ], + capture_output=True, + text=True, +) + +output = result.stdout +data = output.split("\n") + + +with TODO_PATH.open("r+") as f: + f.seek(0) + content = f.read() + + index = content.find(AUTOGENERATED_TODO_HEADER) + + if index == -1: + f.seek(0, os.SEEK_END) + f.write("\n\n") + f.write(AUTOGENERATED_TODO_HEADER + "\n") + else: + next_line = content.find("\n", index) + 1 + new_content = content[:next_line] + + f.seek(0) + f.write(new_content) + f.truncate() + + for line in data: + if not line: + continue + path, line_number, text = line.split(":", maxsplit=2) + f.write(f"- [ ] {path}:{line_number}: {text.split('TODO:')[1].strip()}\n") From a5e2954ad0f8053d52449d3eaaddff9814ce9836 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:09 +0300 Subject: [PATCH 07/13] fix: tests --- tests/api/test_auth_service.py | 24 +++++++------------ tests/api/test_message_service.py | 9 ++++--- tests/app/test_app_runtime.py | 7 ++++++ tests/conftest.py | 13 ++++++++-- .../connection/test_readers_and_transports.py | 2 +- tests/domain/test_bound_models.py | 2 +- 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index 61f2c42..2d0082d 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -2,16 +2,15 @@ import pytest -import pymax.config as config_module from pymax import ExtraConfig from pymax.api.auth.enums import AuthType, ProfileOptions, TwoFactorAction from pymax.api.session.enums import DeviceType -from pymax.config import APP_VERSIONS, MIN_PREFERRED_BUILD -from pymax.fingerprint import FingerprintGenerator +from pymax.config import APP_VERSIONS from pymax.protocol import Opcode from pymax.session.models import SessionInfo from pymax.types.domain import HandshakeResponse, Login2Flags from pymax.types.domain.sync import SyncState +from pymax.versions import VersionCatalog from tests.conftest import ( FakeApp, chat_payload, @@ -34,26 +33,19 @@ async def get_code(self, email: str) -> str: def test_supported_app_versions_match_packaged_fingerprints() -> None: - fingerprints = FingerprintGenerator().data + catalog = VersionCatalog() - assert set(fingerprints) == {version for version, _ in APP_VERSIONS} assert all( - fingerprints[version]["build_number"] == build_number + catalog.resolve(version).build_number == build_number for version, build_number in APP_VERSIONS ) -@pytest.mark.parametrize(("roll", "preferred"), [(0.0, True), (0.9, False)]) -def test_generate_user_agent_selects_preferred_and_legacy_versions( - monkeypatch: pytest.MonkeyPatch, - roll: float, - preferred: bool, -) -> None: - monkeypatch.setattr(config_module, "random", lambda: roll) +def test_generate_user_agent_uses_resolved_version() -> None: + user_agent = ExtraConfig().generate_user_agent("26.14.1", 6686) - user_agent = ExtraConfig().generate_user_agent() - - assert (user_agent.build_number >= MIN_PREFERRED_BUILD) is preferred + assert user_agent.app_version == "26.14.1" + assert user_agent.build_number == 6686 @pytest.mark.asyncio diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 56992e9..7611889 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -361,9 +361,9 @@ async def test_reaction_methods_parse_optional_reaction_info() -> None: ] ) - added = await app.api.messages.add_reaction(100, "10", "👍") - reactions = await app.api.messages.get_reactions(100, ["10", "11"]) - removed = await app.api.messages.remove_reaction(100, "10") + added = await app.api.messages.add_reaction(100, 10, "👍") + reactions = await app.api.messages.get_reactions(100, [10, 11]) + removed = await app.api.messages.remove_reaction(100, 10) assert added is not None assert added.total_count == 1 @@ -375,6 +375,9 @@ async def test_reaction_methods_parse_optional_reaction_info() -> None: Opcode.MSG_GET_REACTIONS, Opcode.MSG_CANCEL_REACTION, ] + assert app.calls[0].payload["messageId"] == 10 + assert app.calls[1].payload["messageIds"] == [10, 11] + assert app.calls[2].payload["messageId"] == 10 @pytest.mark.asyncio diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index a765077..15a7e0c 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -111,6 +111,13 @@ def __init__(self, app: App["RuntimeClient"], router: Router["RuntimeClient"]) - def _build_connection(self) -> RuntimeConnection: return self._connection + async def _prepare_config(self): + return self._config + + async def _ensure_runtime(self) -> None: + """Keep the explicitly assembled runtime used by these lifecycle tests.""" + return None + @pytest.mark.asyncio async def test_app_start_with_config_token_handshakes_logs_in_and_saves_session( diff --git a/tests/conftest.py b/tests/conftest.py index cbf6c5e..1769c67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ from pymax.session.models import SessionInfo from pymax.types.domain import AttachmentType, HandshakeResponse from pymax.types.domain.sync import SyncOverrides +from pymax.versions import VersionCatalog @dataclass @@ -127,7 +128,13 @@ def mobile_user_agent( def make_config(device_type: DeviceType = DeviceType.ANDROID) -> ClientConfig: + user_agent = mobile_user_agent(device_type) + fingerprint = ( + VersionCatalog().resolve(user_agent.app_version) if device_type != DeviceType.WEB else None + ) return ClientConfig( + app_version=user_agent.app_version if fingerprint else None, + fingering=fingerprint, phone="+79990000000", work_dir=".", session_name="session.db", @@ -138,7 +145,7 @@ def make_config(device_type: DeviceType = DeviceType.ANDROID) -> ClientConfig: mt_instance_id="mt-test", device_id="device-test", client_session_id=7, - user_agent=mobile_user_agent(device_type), + user_agent=user_agent, ), ) @@ -165,7 +172,9 @@ def __init__( self.session: SessionInfo | None = None self.handshake_response: HandshakeResponse | None = HandshakeResponse(calls_seed=123) self.started = True - self.fingerprint_generator = FingerprintGenerator() + self.fingerprint_generator = ( + FingerprintGenerator(self.config.fingering) if self.config.fingering else None + ) self.api = SimpleNamespace() self.api.uploads = FakeUploads() diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 6f558a0..e8b8605 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -168,7 +168,7 @@ async def open_connection(*args, **kwargs): } assert open_connection_kwargs == { "sock": sock, - "ssl": True, + "ssl": transport._ssl_ctx, "server_hostname": "example.test", } diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 83a626b..99ff50d 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -136,7 +136,7 @@ async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> Non assert actions.calls[3][2]["message_id"] == 10 assert actions.calls[3][2]["attachments"] == ["file"] assert actions.calls[5][2]["message_ids"] == [10] - assert actions.calls[7][2]["message_id"] == "10" + assert actions.calls[7][2]["message_id"] == 10 @pytest.mark.asyncio From 358bf8f91dd2ff82cbacaa33b1dcb60b6a193373 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:05:26 +0300 Subject: [PATCH 08/13] fix: finalize 2.4.1 runtime behavior --- src/pymax/__init__.py | 6 +- src/pymax/_data/apk_fingerprints.json | 119 +++++++++++++++--- src/pymax/api/messages/service.py | 95 ++++++++++++-- src/pymax/api/uploads/payloads.py | 34 ++++- src/pymax/api/uploads/service.py | 64 ++++++---- src/pymax/app.py | 23 +++- src/pymax/auth/__init__.py | 2 + src/pymax/auth/exceptions.py | 9 ++ src/pymax/auth/providers.py | 8 +- src/pymax/auth/sms.py | 12 +- src/pymax/base.py | 42 +++++-- src/pymax/client.py | 25 ++-- src/pymax/client_web.py | 10 +- src/pymax/config.py | 51 ++++++-- src/pymax/files/base.py | 59 ++++++++- src/pymax/files/video.py | 73 +++++------ src/pymax/files/voice.py | 22 +++- src/pymax/infra/chat.py | 3 + src/pymax/infra/message.py | 3 + src/pymax/infra/protocol.py | 6 +- src/pymax/session/__init__.py | 2 +- src/pymax/session/models.py | 30 +++++ src/pymax/session/protocol.py | 40 ++++-- src/pymax/session/store.py | 78 +++++++++++- src/pymax/types/domain/attachments/call.py | 8 +- src/pymax/types/domain/attachments/contact.py | 6 +- src/pymax/types/domain/attachments/control.py | 3 + src/pymax/types/domain/attachments/enums.py | 16 +++ src/pymax/types/domain/attachments/video.py | 6 +- src/pymax/types/domain/enums.py | 7 ++ src/pymax/types/domain/message.py | 75 ++++++++++- src/pymax/versions/catalog.py | 32 +++++ src/pymax/versions/exceptions.py | 2 + tests/app/test_app_runtime.py | 115 ++++++++++++++++- tests/app/test_client_user_agent_config.py | 37 ++++++ tests/session/test_store.py | 65 ++++++++++ 36 files changed, 1028 insertions(+), 160 deletions(-) create mode 100644 src/pymax/auth/exceptions.py create mode 100644 tests/app/test_client_user_agent_config.py diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index bebc9b8..2cc50dd 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.4.0" +__version__ = "2.4.1" from .api.self import PrivacyAccess, PrivacySettingsUpdate @@ -7,6 +7,7 @@ ConsolePasswordProvider, ConsoleQrHandler, ConsoleSmsCodeProvider, + PasswordAttemptsExceededError, PasswordProvider, QrAuthFlow, QrHandler, @@ -49,12 +50,13 @@ "Message", "MessageDeleteEvent", "MessageReadEvent", + "PasswordAttemptsExceededError", "PasswordProvider", "Photo", "PresenceEvent", - "Profile", "PrivacyAccess", "PrivacySettingsUpdate", + "Profile", "PyMaxError", "QrAuthFlow", "QrHandler", diff --git a/src/pymax/_data/apk_fingerprints.json b/src/pymax/_data/apk_fingerprints.json index 8456b7b..0c63fc9 100644 --- a/src/pymax/_data/apk_fingerprints.json +++ b/src/pymax/_data/apk_fingerprints.json @@ -1,21 +1,4 @@ { - "26.9.1": { - "signature_scheme": "v3", - "certificate_count": 1, - "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", - "certificate_sha256": [ - "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" - ], - "dex_meta_sha256": "6ce0d31a723db1a03d0523236b37b7a2000428ce60bd0ff34e45d082dd7c1590", - "so_meta_sha256_arm64_v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", - "so_meta_sha256": { - "arm64-v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", - "armeabi-v7a": "6fe430d8d622243d8d7aba3394f40c4df59474b6291e9ed78c023fc2f7dc5281", - "x86": "0dc31a603ecbd77cb0067440a99283444613053755a3f823ef1a9e249e25da4c", - "x86_64": "7bf4bc1e18412814ccc62dedce0419f1444504d28c634cb394cb13d381e39820" - }, - "build_number": 6643 - }, "26.10.0": { "signature_scheme": "v3", "certificate_count": 1, @@ -678,5 +661,107 @@ "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" }, "build_number": 6790 + }, + "26.26.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "2538a819fdcf2cf8afdcb1ab1476d0c08cc0e4d4bad86f1bf381408d081ec410", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6797 + }, + "26.26.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "dfd7c9656330664b7e2a2c2c12f2b795631105359ee79e3a241dff0aad91ecda", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6798 + }, + "26.27.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "a68af06b78977b960d30ec6ed5fdffe9266ffbe351541b03c59978739fb7b2b9", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6800 + }, + "26.27.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "443d2e2121ebe59c235dfce0537f5aeb560906c4c9c131cbade3db507074083d", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6802 + }, + "26.28.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "20248d9c06745abe01c19cd019f7ac11e0a43dc02e49f87919f01b720c28b30f", + "so_meta_sha256_arm64_v8a": "9a7d6c3f2d3d01ca5e8692f254be92306d37fadb074692479bdf961943675f7a", + "so_meta_sha256": { + "arm64-v8a": "9a7d6c3f2d3d01ca5e8692f254be92306d37fadb074692479bdf961943675f7a", + "armeabi-v7a": "bfc5ac5df34357fa817c4a8ce4696cd95b2c34f903d91ef15ea86463c600befa", + "x86": "918eacab4fa4e04d90a7fa666bc39633ae47fbba70b6817b5dfd1f62233fab8e", + "x86_64": "ae38786d2c0541c4d2c3050265e30bde68df16302aebc5302432813ff20f858c" + }, + "build_number": 6804 + }, + "26.9.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "6ce0d31a723db1a03d0523236b37b7a2000428ce60bd0ff34e45d082dd7c1590", + "so_meta_sha256_arm64_v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", + "so_meta_sha256": { + "arm64-v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", + "armeabi-v7a": "6fe430d8d622243d8d7aba3394f40c4df59474b6291e9ed78c023fc2f7dc5281", + "x86": "0dc31a603ecbd77cb0067440a99283444613053755a3f823ef1a9e249e25da4c", + "x86_64": "7bf4bc1e18412814ccc62dedce0419f1444504d28c634cb394cb13d381e39820" + }, + "build_number": 6643 } } diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 255031a..dcbe7d6 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -1,9 +1,10 @@ from __future__ import annotations +import asyncio import time from collections.abc import Sequence from datetime import datetime, timedelta -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, TypeAlias, TypeVar from pymax.api.binding import bind_api_model, bind_api_models from pymax.api.response import ( @@ -17,8 +18,10 @@ AttachFilePayload, AttachPhotoPayload, VideoAttachPayload, + VideoNoteAttachPayload, + VoiceAttachPayload, ) -from pymax.exceptions import UploadError +from pymax.exceptions import ApiError, UploadError from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.formatting.markdown import Formatter from pymax.logging import get_logger @@ -32,6 +35,8 @@ ReadState, VideoRequest, ) +from pymax.types.events.video import VideoUploadSignal +from pymax.types.events.voice import AudioUploadSignal from .enums import ItemType, MessagePayloadKey, ReadAction from .payloads import ( @@ -64,6 +69,8 @@ SendAttachments: TypeAlias = Sequence[SendAttachment] | None DateTimeUnion: TypeAlias = datetime | timedelta | int +T = TypeVar("T") + logger = get_logger(__name__) @@ -82,8 +89,12 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments - ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll]: - result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = [] + ) -> list[ + AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll + ]: # TODO: TypeAlias + result: list[ + AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll + ] = [] if not attachments: return result @@ -104,7 +115,7 @@ async def _upload_attachments( result.append(upload_result) - elif isinstance(attachment, Video): + elif isinstance(attachment, (Video, VideoNote)): upload_result = await self.app.api.uploads.upload_video(attachment) if not upload_result: logger.error("Video uploading failed") @@ -134,6 +145,53 @@ def _convert_time(self, time: DateTimeUnion) -> int: return time * 1000 + async def _wait_for_upload_signal( + self, + waiters: dict[int, asyncio.Future[T]], + video_id: int, + ) -> None: + loop = asyncio.get_running_loop() + future: asyncio.Future[T] = loop.create_future() + + waiters[video_id] = future + try: + await asyncio.wait_for(future, timeout=60) + except TimeoutError: + logger.warning( + "Timed out waiting for video processing notification video_id=%s", + video_id, + ) + raise UploadError(f"Timed out waiting for video processing video_id={video_id}") + finally: + waiters.pop(video_id, None) + + async def _process_attachment_error( + self, + attaches: list[ + AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll + ], + ): + attachment: VoiceAttachPayload | VideoNoteAttachPayload | None = None + for attach in attaches: + if isinstance(attach, (VoiceAttachPayload, VideoNoteAttachPayload)): + attachment = attach + break + + if attachment is None: + raise ValueError( + "Unexpected internal state: Non voice/video_note attach is not ready. " + + "Please, report that issue" + ) + + if isinstance(attachment, VoiceAttachPayload): + await self._wait_for_upload_signal( + self.app.api.uploads.voice_upload_waiters, attachment.video_id + ) + else: + await self._wait_for_upload_signal( + self.app.api.uploads.video_upload_waiters, attachment.video_id + ) + async def send_message( self, chat_id: int, @@ -155,13 +213,15 @@ async def send_message( else: clean_text, elements = None, [] + attaches = await self._upload_attachments(attachments) + frame = SendMessagePayload( chat_id=chat_id, message=SendMessagePayloadMessage( text=clean_text, cid=self._next_cid(), elements=elements, - attaches=await self._upload_attachments(attachments), + attaches=attaches, link=ReplyLink(message_id=reply_to) if reply_to else None, delayed_attributes=DelayedAttributes( time_to_fire=self._convert_time(send_at), @@ -173,7 +233,14 @@ async def send_message( notify=notify, ) - response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload()) + try: + response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload()) + except ApiError as e: + if e.error == "attachment.not.ready": + await self._process_attachment_error(attaches) + response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload()) + else: + raise message = bind_api_model( self.app, @@ -260,15 +327,25 @@ async def edit_message( else: clean_text, elements = None, [] + attaches = await self._upload_attachments(attachments) + frame = EditMessagePayload( chat_id=chat_id, message_id=message_id, text=clean_text, elements=elements, - attachments=await self._upload_attachments(attachments), + attachments=attaches, ) + try: + response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload()) + except ApiError as e: + if e.error == "attachment.not.ready": + await self._process_attachment_error(attaches) + + response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload()) + else: + raise - response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload()) message = require_payload_item_model( response, MessagePayloadKey.MESSAGE, diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 0448431..216288d 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -1,4 +1,6 @@ -from pydantic import Field +from typing import Any + +from pydantic import Field, SerializerFunctionWrapHandler, model_serializer from pymax.api.models import CamelModel from pymax.types import AttachmentType @@ -18,6 +20,36 @@ class VideoAttachPayload(CamelModel): duration: int | None = None wave: bytes | None = None + @model_serializer(mode="wrap") + def serialize_attachment(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + payload = handler(self) + + if self.type == AttachmentType.AUDIO: + payload.pop("videoId", None) + payload.pop("videoType", None) + if not self.token: + payload.pop("token", None) + payload["audioId"] = self.video_id + elif self.video_type == 1: + if self.token: + payload.pop("videoId", None) + else: + payload.pop("token", None) + payload["videoId"] = self.video_id + + return payload + + +class VoiceAttachPayload(VideoAttachPayload): + type: AttachmentType = Field(default=AttachmentType.AUDIO, serialization_alias="_type") + video_id: int = Field(exclude=True) + video_type: int = Field(default=0, exclude=True) + + +class VideoNoteAttachPayload(VideoAttachPayload): + video_id: int = Field(exclude=True) + video_type: int = 1 + class AttachFilePayload(CamelModel): type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type") diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index bd1155f..573320c 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -15,7 +15,7 @@ from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.logging import get_logger from pymax.protocol import Opcode -from pymax.types import AttachmentType, AudioUploadSignal +from pymax.types import AudioUploadSignal from .models import ( FileUploadResponse, @@ -27,6 +27,8 @@ AttachPhotoPayload, UploadPayload, VideoAttachPayload, + VideoNoteAttachPayload, + VoiceAttachPayload, ) if TYPE_CHECKING: @@ -182,7 +184,7 @@ async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhoto logger.debug("Photo upload complete photo_id=%s", photo_id) return AttachPhotoPayload(photo_token=token) - async def upload_voice(self, voice: Voice) -> VideoAttachPayload: + async def upload_voice(self, voice: Voice) -> VoiceAttachPayload: logger.info("Uploading voice") payload = UploadPayload( @@ -227,12 +229,18 @@ async def upload_voice(self, voice: Voice) -> VideoAttachPayload: logger.exception("Failed to get voice size") raise UploadError("Failed to get voice size") from e + # ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"), + # OKMessages/{app_version} ({os_version}; {device_name}; {screen}) + + user_agent = f"OKMessages/{self.app.config.app_version} ({self.app.config.device.user_agent.os_version}; {self.app.config.device.user_agent.device_name}; {self.app.config.device.user_agent.screen})" + headers = { "Content-Disposition": f"attachment; filename={quote(voice.name)}", - "Content-Range": f"0-{file_size - 1}/{file_size}", + "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", "Content-Length": str(file_size), "Connection": "keep-alive", "Content-Type": "application/octet-stream", + "User-Agent": quote(user_agent), } logger.debug( @@ -240,15 +248,11 @@ async def upload_voice(self, voice: Voice) -> VideoAttachPayload: headers["Content-Range"], ) - loop = asyncio.get_running_loop() - future: asyncio.Future[AudioUploadSignal] = loop.create_future() - timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60) video_id = upload_info.video_id token = upload_info.token - self.voice_upload_waiters[video_id] = future logger.debug("Voice upload waiter registered voice_id=%s", video_id) try: @@ -279,24 +283,29 @@ async def upload_voice(self, voice: Voice) -> VideoAttachPayload: f"{response.status} voice_id={video_id}" ) - try: - logger.debug( - "Waiting for voice processing notification voice_id=%s", - video_id, - ) - await asyncio.wait_for(future, 60) - except asyncio.TimeoutError: - logger.warning( - "Timed out waiting for voice processing notification voice_id=%s", - video_id, - ) - raise UploadError( - f"Timed out waiting for voice processing voice_id={video_id}" - ) + # print(await response.read()) # TODO: Error handing (yes, with 200 status 💔) + + # try: + # logger.debug( + # "Waiting for voice processing notification voice_id=%s", + # video_id, + # ) + # await asyncio.wait_for(future, 60) + # except asyncio.TimeoutError: + # logger.warning( + # "Timed out waiting for voice processing notification voice_id=%s", + # video_id, + # ) + # raise UploadError( + # f"Timed out waiting for voice processing voice_id={video_id}" + # ) logger.debug("Voice upload complete voice_id=%s", video_id) - return VideoAttachPayload( - type=AttachmentType.AUDIO, video_id=video_id, token=token + return VoiceAttachPayload( + video_id=video_id, + token=token, + duration=await voice.get_duration(), + wave=b"\x00" * 80, ) except UploadError: @@ -314,7 +323,9 @@ async def upload_voice(self, voice: Voice) -> VideoAttachPayload: self.voice_upload_waiters.pop(video_id, None) logger.debug("Voice upload waiter removed voice_id=%s", video_id) - async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload: + async def upload_video( + self, uploadable_video: Video | VideoNote + ) -> VideoAttachPayload | VideoNoteAttachPayload: logger.info("Uploading video") logger.debug("Preparing video upload payload") @@ -369,7 +380,7 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60) headers = { - "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}", + "Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}", "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", "Content-Length": str(file_size), "Connection": "keep-alive", @@ -427,10 +438,9 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach thumbhash += "=" * (-len(thumbhash) % 4) thumbhash = base64.b64decode(thumbhash) - return VideoAttachPayload( + return VideoNoteAttachPayload( video_id=video_id, token=token, - video_type=1, thumbhash=thumbhash, duration=await uploadable_video.get_duration(), ) diff --git a/src/pymax/app.py b/src/pymax/app.py index 2c729f8..b30cf44 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -12,8 +12,8 @@ from pymax.logging import get_logger from pymax.protocol import Command, InboundFrame, OutboundFrame from pymax.protocol.enums import Opcode -from pymax.session import SessionStore -from pymax.session.models import SessionInfo +from pymax.session import InMemoryStore, SessionStore +from pymax.session.models import SessionInfo, resolve_session_user_agent from pymax.telemetry import TelemetryService from pymax.types import MaxApiError, Message from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User @@ -38,7 +38,11 @@ def __init__( self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router) self.api = ApiFacade(self) self.config = config - self.store = self.config.store or SessionStore(config.work_dir, config.session_name) + self.store = ( + (self.config.store or SessionStore(config.work_dir, config.session_name)) + if self.config.persist_session + else InMemoryStore() + ) self.auth_flow = auth_flow self.fingerprint_generator: FingerprintGenerator | None = ( FingerprintGenerator(self.config.fingering) if self.config.fingering else None @@ -79,6 +83,17 @@ async def start(self) -> None: else: session_data.mt_instance_id = self.config.device.mt_instance_id + if self.config.restore_user_agent_from_session: + user_agent = resolve_session_user_agent( + self.config.device.user_agent, + session_data.user_agent, + ) + else: + user_agent = self.config.device.user_agent + self.config.device.user_agent = user_agent + if session_data.user_agent != user_agent: + session_data = session_data.model_copy(update={"user_agent": user_agent}) + try: logger.debug("opening connection") await self.connection.open() @@ -107,6 +122,7 @@ async def start(self) -> None: device_id=self.config.device.device_id, phone=self.config.phone or "", mt_instance_id=self.config.device.mt_instance_id, + user_agent=self.config.device.user_agent, ) ) else: @@ -122,6 +138,7 @@ async def start(self) -> None: device_id=self.config.device.device_id, phone=self.config.phone or "", mt_instance_id=self.config.device.mt_instance_id, + user_agent=self.config.device.user_agent, ) ) logger.info("new session saved") diff --git a/src/pymax/auth/__init__.py b/src/pymax/auth/__init__.py index 4bae706..38baf04 100644 --- a/src/pymax/auth/__init__.py +++ b/src/pymax/auth/__init__.py @@ -1,4 +1,5 @@ from .base import AuthFlow +from .exceptions import PasswordAttemptsExceededError from .providers import ( ConsolePasswordProvider, ConsoleQrHandler, @@ -17,6 +18,7 @@ "ConsoleQrHandler", "ConsoleSmsCodeProvider", "EmailCodeProvider", + "PasswordAttemptsExceededError", "PasswordProvider", "QrAuthFlow", "QrHandler", diff --git a/src/pymax/auth/exceptions.py b/src/pymax/auth/exceptions.py new file mode 100644 index 0000000..1dc4eab --- /dev/null +++ b/src/pymax/auth/exceptions.py @@ -0,0 +1,9 @@ +class PasswordAttemptsExceededError(Exception): + """Исчерпан лимит попыток ввода пароля 2FA. + + Наследуется напрямую от ``Exception`` и не перехватывается как + ``PyMaxError``. + """ + + def __init__(self) -> None: + super().__init__("2FA password attempts exhausted") diff --git a/src/pymax/auth/providers.py b/src/pymax/auth/providers.py index 394b94f..a3e5db9 100644 --- a/src/pymax/auth/providers.py +++ b/src/pymax/auth/providers.py @@ -113,7 +113,11 @@ async def get_code(self, email: str) -> str: class ConsoleEmailCodeProvider: - """Консольный provider кода подтверждения email для 2FA.""" + """Консольный provider кода подтверждения email для 2FA. + + Блокирующий ``input()`` выполняется в worker thread и не останавливает + asyncio event loop. + """ async def get_code(self, email: str) -> str: """Запрашивает email-код в консоли. @@ -124,4 +128,4 @@ async def get_code(self, email: str) -> str: Returns: Введенный пользователем код. """ - return input(f"Enter 2FA email code for {email}: ").strip() + return (await asyncio.to_thread(input, f"Enter 2FA email code for {email}: ")).strip() diff --git a/src/pymax/auth/sms.py b/src/pymax/auth/sms.py index 0244ac4..954f601 100644 --- a/src/pymax/auth/sms.py +++ b/src/pymax/auth/sms.py @@ -6,6 +6,7 @@ from pymax.exceptions import ApiError from pymax.logging import get_logger +from .exceptions import PasswordAttemptsExceededError from .models import AuthResult from .providers import ( ConsolePasswordProvider, @@ -116,7 +117,10 @@ async def _authenticate_with_password( hint: str | None, ) -> str: logger.info("starting 2fa password authentication") - while True: + attempt = 0 + while ( + app.config.password_max_attempts is None or app.config.password_max_attempts > attempt + ): password = await self.password_provider.get_password(hint) logger.debug( "2fa password provider returned password_set=%s", @@ -124,16 +128,19 @@ async def _authenticate_with_password( ) if not password: logger.warning("2fa password is empty; retrying") + attempt += 1 continue try: response = await app.api.auth.check_password(track_id, password) except ApiError as e: logger.error("2fa password check failed: %s", e) + attempt += 1 continue if response.error: logger.error("2fa password check failed error=%s", response.error) + attempt += 1 continue if response.login_token: @@ -141,3 +148,6 @@ async def _authenticate_with_password( return response.login_token logger.error("2fa password response did not contain login token; retrying") + attempt += 1 + + raise PasswordAttemptsExceededError diff --git a/src/pymax/base.py b/src/pymax/base.py index b33c095..ddcf3b8 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -3,7 +3,6 @@ import asyncio from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Generic, TypeVar -from uuid import uuid4 from pymax.dispatch import ErrorScope, Router from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator @@ -13,7 +12,7 @@ from pymax.logging import get_logger from .app import App -from .config import ClientConfig, DeviceConfig, ExtraConfig +from .config import ClientConfig, DeviceConfig, ExtraConfig, generate_device_id if TYPE_CHECKING: from pymax.api.session.payloads import MobileUserAgentPayload @@ -48,11 +47,21 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]): _connection: ConnectionManager _auth_flow: AuthFlow _router: Router[ClientT] - _app: App[ClientT] + __app: App[ClientT] | None = None + + @property + def _app(self) -> App[ClientT]: + if self.__app is None: + raise RuntimeError("Client runtime is not initialized") + return self.__app + + @_app.setter + def _app(self, value: App[ClientT]) -> None: + self.__app = value @property def me(self) -> Profile | None: - """Профиль текущего аккаунта после успешного ``start``.""" + """Профиль текущего аккаунта после успешного login.""" return self._app.me @property @@ -70,6 +79,11 @@ def messages(self) -> dict[int, list[Message]] | None: """Сообщения, которые Max вернул на login/sync.""" return self._app.messages + @property + def is_connected(self) -> bool: + """Показывает, открыто ли соединение запущенного runtime.""" + return self._app.connection.is_open + def _build_config( self, *, @@ -98,12 +112,14 @@ def _build_config( proxy=self.extra_config.proxy, registration_config=self.extra_config.registration_config, upload_timeout=self.extra_config.upload_timeout, + password_max_attempts=self.extra_config.password_max_attempts, relogin=self.extra_config.relogin, app_version=version, fingering=fingerprint, + persist_session=self.extra_config.persist_session, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, - device_id=self.extra_config.device_id or str(uuid4()), + device_id=self.extra_config.device_id or generate_device_id(), user_agent=user_agent, ), ) @@ -144,6 +160,12 @@ def _reset_runtime(self: ClientT) -> None: # noqa: PYI019 self._app = self._build_app() async def connect(self: ClientT) -> None: # noqa: PYI019 + """Подключает клиента один раз и возвращается после ``on_start``. + + После возврата соединение остается открытым. В отличие от + :meth:`start`, метод не ожидает закрытия соединения и не запускает + reconnect loop; завершите работу через :meth:`stop` или :meth:`close`. + """ try: await self._ensure_runtime() await self._app.start() @@ -157,6 +179,7 @@ async def connect(self: ClientT) -> None: # noqa: PYI019 raise except (ConnectionError, EOFError, OSError, TimeoutError, ApiError) as e: await self.close() + await self._app.dispatcher.emit_disconnect( e, self.extra_config.reconnect, @@ -165,7 +188,11 @@ async def connect(self: ClientT) -> None: # noqa: PYI019 raise async def start(self: ClientT) -> None: # noqa: PYI019 - """Запускает клиента и слушает события до закрытия соединения.""" + """Запускает клиента и слушает события до закрытия соединения. + + При сетевых ошибках повторяет подключение, если ``reconnect=True``. + Отозванный login token автоматически сбрасывается при ``relogin=True``. + """ while True: try: await self._ensure_runtime() @@ -219,9 +246,6 @@ async def start(self: ClientT) -> None: # noqa: PYI019 async def close(self) -> None: """Закрывает соединение, фоновые задачи и файл сессии.""" - if getattr(self, "_app", None) is None: # TODO: maybe impl it better way - return - await self._app.close() async def stop(self) -> None: diff --git a/src/pymax/client.py b/src/pymax/client.py index d60dc92..80a641d 100644 --- a/src/pymax/client.py +++ b/src/pymax/client.py @@ -40,6 +40,15 @@ class Client(BaseClient["Client"]): auth_flow: Полностью пользовательский сценарий авторизации. sms_code_provider: Провайдер SMS-кода для стандартного ``SmsAuthFlow``. password_provider: Провайдер пароля 2FA, если аккаунт его требует. + app_version: Версия Android-клиента для user-agent и fingerprint. + По умолчанию используется ``VersionCatalog.recommended()``. + catalog: Каталог fingerprints. Если не передан, используется + встроенный каталог. Каталог и версия разрешаются при + :meth:`connect` или :meth:`start`. + + Raises: + VersionNotFoundError: При запуске, если ``app_version`` отсутствует в + итоговом каталоге. """ def __init__( # noqa: PLR0913 @@ -55,12 +64,12 @@ def __init__( # noqa: PLR0913 catalog: VersionCatalog | None = None, ) -> None: - self.phone = phone - self.extra_config = extra_config or ExtraConfig() - self.session_name = session_name - self.work_dir = work_dir - self.app_version = app_version - self.catalog = catalog or VersionCatalog() + self.phone: str = phone + self.extra_config: ExtraConfig = extra_config or ExtraConfig() + self.session_name: str = session_name + self.work_dir: str = work_dir + self.app_version: str = app_version + self.catalog: VersionCatalog = catalog or VersionCatalog() configure_logging(self.extra_config.log_level) logger.debug( @@ -94,12 +103,14 @@ async def _prepare_config(self) -> ClientConfig: self.app_version, fingerprint.build_number ) - return self._build_config( + config = self._build_config( phone=self.phone, user_agent=user_agent, version=self.app_version, fingerprint=fingerprint, ) + config.restore_user_agent_from_session = self.extra_config.user_agent is None + return config def _build_connection(self) -> ConnectionManager: logger.debug( diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index fe21136..90a01e3 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -39,9 +39,9 @@ def __init__( auth_flow: AuthFlow | None = None, qr_provider: QrHandler | None = None, ) -> None: - self.extra_config = extra_config or ExtraConfig() - self.session_name = session_name - self.work_dir = work_dir + self.extra_config: ExtraConfig = extra_config or ExtraConfig() + self.session_name: str = session_name + self.work_dir: str = work_dir configure_logging(self.extra_config.log_level) logger.debug( @@ -64,9 +64,11 @@ def __init__( async def _prepare_config(self) -> ClientConfig: user_agent = self.extra_config.user_agent or self.extra_config.generate_web_user_agent() - return self._build_config( + config = self._build_config( phone=None, user_agent=user_agent, version=None, fingerprint=None ) + config.restore_user_agent_from_session = self.extra_config.user_agent is None + return config def _build_connection(self) -> ConnectionManager: logger.debug( diff --git a/src/pymax/config.py b/src/pymax/config.py index 6557728..458e69b 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -1,5 +1,5 @@ +import secrets from random import choice, randint -from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field @@ -113,10 +113,14 @@ LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD] +def generate_device_id() -> str: + return secrets.token_hex(8) + + class DeviceConfig(BaseModel): mt_instance_id: str user_agent: MobileUserAgentPayload - device_id: str = Field(default_factory=lambda: str(uuid4())) + device_id: str = Field(default_factory=generate_device_id) client_session_id: int = Field(default_factory=lambda: randint(1, 70)) @@ -153,13 +157,16 @@ class ClientConfig(BaseModel): host: str = "api2.oneme.ru" port: int = 443 use_ssl: bool = True + persist_session: bool = True + password_max_attempts: int | None = None protocol_version: int = 10 request_timeout: float = 30.0 log_level: str = "INFO" telemetry: bool = False interactive: bool = True + restore_user_agent_from_session: bool = True store: StoreProtocol | None = None @@ -187,14 +194,35 @@ class ExtraConfig(BaseModel): proxy: Proxy URL для TCP- или WebSocket-транспорта. reconnect: Переподключаться после сетевых ошибок. reconnect_delay: Пауза перед reconnect. - upload_timeout: Timeout загрузки файлов в секундах. - device_id: Явный device ID. Если не передан, генерируется UUID. + upload_timeout: Общий timeout HTTP-загрузки файлов, видео, голосовых и + видеосообщений в секундах. По умолчанию ``900``; не применяется к + фотографиям и 60-секундному ожиданию обработки вложения. ``0`` и + отрицательные значения отключают общий timeout, но не отдельный + 60-секундный socket/read timeout. + relogin: Автоматически сбрасывать сессию и повторять авторизацию, если + Max отозвал login token. По умолчанию ``True``. + password_max_attempts: Максимальное число попыток ввода пароля 2FA. + ``None`` означает отсутствие лимита; ``0`` и отрицательные значения + сразу завершают авторизацию с ``PasswordAttemptsExceededError``. + persist_session: Сохранять token, device/user-agent и sync-state между + runtime в SQLite или переданном ``store``. По умолчанию ``True``. + При ``False`` используется отдельный ``InMemoryStore`` и значение + ``store`` игнорируется; после пересоздания runtime, в том числе при + reconnect, сохраненная сессия недоступна. + device_id: Явный device ID. Если не передан, генерируется строка из + 16 шестнадцатеричных символов. device_type: Тип устройства для mobile user-agent. - user_agent: Полностью заданный user-agent payload. - mt_instance_id: Instance ID устройства. + user_agent: Полностью заданный user-agent payload. Явное значение имеет + приоритет над user-agent из сохраненной сессии. Автоматически + сгенерированный user-agent восстанавливает характеристики устройства + из сессии, сохраняя версию приложения и build number текущего запуска. + mt_instance_id: Instance ID устройства. По умолчанию генерируется + строка из 16 шестнадцатеричных символов. request_timeout: Timeout API-запросов в секундах. log_level: Уровень логов ``pymax``. telemetry: Отправлять telemetry-события Max. + store: Пользовательское хранилище сессии. Используется только при + ``persist_session=True``. sync: Переопределения sync-маркеров для login. Example: @@ -226,11 +254,13 @@ class ExtraConfig(BaseModel): reconnect_delay: float = 1.0 upload_timeout: int = 900 relogin: bool = True + password_max_attempts: int | None = None + persist_session: bool = True device_id: str | None = None device_type: DeviceType = DeviceType.ANDROID user_agent: MobileUserAgentPayload | None = None - mt_instance_id: str = Field(default_factory=lambda: str(uuid4())) + mt_instance_id: str = Field(default_factory=generate_device_id) request_timeout: float = 30.0 log_level: str = "INFO" @@ -243,8 +273,13 @@ class ExtraConfig(BaseModel): def generate_user_agent(self, app_version: str, build_number: int) -> MobileUserAgentPayload: """Создает mobile user-agent payload для TCP-клиента. + Args: + app_version: Версия Android-клиента Max. + build_number: Build number той же версии из ``VersionCatalog``. + Returns: - Случайная, но правдоподобная конфигурация Android-клиента Max. + Случайная конфигурация Android-устройства с переданными версией и + build number. """ device_name, os_version, screen, arch = choice(ANDROID_DEVICES) locale, timezone = choice(LOCALE_TIMEZONES) diff --git a/src/pymax/files/base.py b/src/pymax/files/base.py index b34396b..9b8bdcd 100644 --- a/src/pymax/files/base.py +++ b/src/pymax/files/base.py @@ -1,10 +1,16 @@ import os from abc import ABC, abstractmethod from collections.abc import AsyncGenerator +from io import BytesIO import aiofiles import aiohttp +try: + from tinytag import TinyTag +except ImportError: + TinyTag = None + class BaseFile(ABC): def __init__( @@ -56,7 +62,7 @@ async def size(self) -> int: if self.url: async with aiohttp.ClientSession() as session: # noqa: SIM117 - async with session.head(self.url) as resp: + async with session.head(self.url, allow_redirects=True) as resp: return int(resp.headers["Content-Length"]) else: raise ValueError("Path or Url must be provided") @@ -85,3 +91,54 @@ async def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: resp.raise_for_status() async for chunk in resp.content.iter_chunked(size): yield chunk + + +class TimedMediaFile(BaseFile): + def __init__( + self, + raw: bytes | None = None, + *, + path: str | None, + url: str | None, + name: str | None, + duration: int | None, + ) -> None: + self.duration = duration + super().__init__(raw, path=path, url=url, name=name) + + async def get_duration(self) -> int: + if self.duration is not None: + return self.duration + + if not TinyTag: + raise RuntimeError( + "Automatic video duration detection requires the 'video' extra. " + "Install it with `uv add 'maxapi-python[video]'` " + "or pass duration manually." + ) + + if self.raw: + tag = TinyTag.get( + filename=self.name, + file_obj=BytesIO(self.raw), + tags=False, + duration=True, + ) + elif self.path: + tag = TinyTag.get( + filename=self.path, + tags=False, + duration=True, + ) + else: + tag = TinyTag.get( + filename=self.name, + file_obj=BytesIO(await self.read()), + tags=False, + duration=True, + ) + + if tag.duration is None: + raise ValueError("Failed to determine video duration.") + + return round(tag.duration * 1000) diff --git a/src/pymax/files/video.py b/src/pymax/files/video.py index 5474658..78c207e 100644 --- a/src/pymax/files/video.py +++ b/src/pymax/files/video.py @@ -1,13 +1,8 @@ from collections.abc import AsyncGenerator -from io import BytesIO from pathlib import Path +from typing import Any -from .base import BaseFile - -try: - from tinytag import TinyTag -except ImportError: - TinyTag = None +from .base import BaseFile, TimedMediaFile class Video(BaseFile): @@ -80,12 +75,23 @@ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: return super().iter_chunks(size) -class VideoNote(Video): +class VideoNote(TimedMediaFile): """Круглое видеосообщение для отправки. Принимает те же источники, что и ``Video``. Длительность задается в миллисекундах. Если она не передана, требуется extra ``video`` для автоматического определения длительности. + + Args: + raw: Байты видеосообщения. + url: URL видеосообщения. + path: Локальный путь к видеосообщению. + name: Имя файла. Обязательно для ``raw``. + duration: Длительность в миллисекундах. ``None`` включает + автоматическое определение через extra ``video``. + + Raises: + RuntimeError: При определении длительности без extra ``video``. """ def __init__( @@ -98,35 +104,22 @@ def __init__( duration: int | None = None, ) -> None: self.duration = duration - super().__init__(raw, url=url, path=path, name=name) - - async def get_duration(self) -> int: - if self.duration is not None: - return self.duration - - if not TinyTag: - raise RuntimeError( - "Automatic video duration detection requires the 'video' extra. " - "Install it with `uv add 'maxapi-python[video]'` " - "or pass duration manually." - ) - - if self.raw: - tag = TinyTag.get( - filename=self.name, - file_obj=BytesIO(self.raw), - tags=False, - duration=True, - ) - else: - tag = TinyTag.get( - filename=self.name, - file_obj=BytesIO(await self.read()), - tags=False, - duration=True, - ) - - if tag.duration is None: - raise ValueError("Failed to determine video duration.") - - return round(tag.duration * 1000) + self.name: str = name or "" + if not self.name and path: + self.name = Path(path).name + elif not self.name and url: + self.name = Path(url).name + + if not self.name: + raise ValueError("Either name, url or path must be provided.") + + super().__init__(raw, url=url, path=path, name=self.name, duration=self.duration) + + async def read(self) -> bytes: + return await super().read() + + def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: + return super().iter_chunks(size) + + async def size(self) -> int: + return await super().size() diff --git a/src/pymax/files/voice.py b/src/pymax/files/voice.py index 2196b39..f1df79c 100644 --- a/src/pymax/files/voice.py +++ b/src/pymax/files/voice.py @@ -1,14 +1,26 @@ from collections.abc import AsyncGenerator from pathlib import Path -from .base import BaseFile +from .base import TimedMediaFile -class Voice(BaseFile): +class Voice(TimedMediaFile): """Голосовое сообщение в формате OGG для отправки. Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно - передать имя файла. + передать имя файла. Длительность задается в миллисекундах; если она не + передана, требуется extra ``video`` для автоматического определения. + + Args: + raw: Байты голосового сообщения. + path: Локальный путь к OGG-файлу. + url: URL OGG-файла. + name: Имя файла. Обязательно для ``raw``. + duration: Длительность в миллисекундах. ``None`` включает + автоматическое определение через extra ``video``. + + Raises: + RuntimeError: При определении длительности без extra ``video``. """ def __init__( @@ -18,8 +30,10 @@ def __init__( path: str | None = None, url: str | None = None, name: str | None = None, + duration: int | None = None, ) -> None: self.name: str = name or "" + self.duration = duration if not self.name and path: self.name = Path(path).name elif not self.name and url: @@ -27,7 +41,7 @@ def __init__( if not self.name: raise ValueError("Either name, url or path must be provided.") - super().__init__(raw=raw, url=url, path=path, name=self.name) + super().__init__(raw=raw, url=url, path=path, name=self.name, duration=self.duration) async def read(self) -> bytes: return await super().read() diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 669ee1b..06363e1 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -138,6 +138,9 @@ async def change_group_profile( ) -> None: """Обновляет название, описание или фотографию группы. + ``None`` исключает соответствующее поле из запроса. Фотография + автоматически загружается перед обновлением профиля. + Args: chat_id: ID группы. name: Новое название. diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 8b7fc1d..366c171 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -34,6 +34,9 @@ async def send_message( reply_to: ID сообщения для ответа. attachments: Файлы, фотографии, видео или опросы для отправки. notify: Отправить ли получателям push-уведомление. + send_at: Время отложенной отправки: ``datetime`` для абсолютного + времени, ``timedelta`` для задержки или Unix time в секундах + как ``int``. ``None`` и ``0`` отправляют сообщение сразу. Returns: Отправленное сообщение. diff --git a/src/pymax/infra/protocol.py b/src/pymax/infra/protocol.py index f28dc45..9e59e5b 100644 --- a/src/pymax/infra/protocol.py +++ b/src/pymax/infra/protocol.py @@ -6,4 +6,8 @@ class IClientProtocol(Protocol): """Описывает минимальный клиент, нужный infra-миксинам.""" - _app: App + @property + def _app(self) -> App: ... + + @_app.setter + def _app(self, value: App) -> None: ... diff --git a/src/pymax/session/__init__.py b/src/pymax/session/__init__.py index 6732206..328e52a 100644 --- a/src/pymax/session/__init__.py +++ b/src/pymax/session/__init__.py @@ -1,3 +1,3 @@ from .models import SessionInfo from .protocol import StoreProtocol -from .store import SessionStore +from .store import InMemoryStore, SessionStore diff --git a/src/pymax/session/models.py b/src/pymax/session/models.py index 2eb00d1..f04aa4b 100644 --- a/src/pymax/session/models.py +++ b/src/pymax/session/models.py @@ -1,11 +1,41 @@ from pydantic import BaseModel, Field +from pymax.api.session.payloads import MobileUserAgentPayload from pymax.types.domain.sync import SyncState class SessionInfo(BaseModel): + """Состояние авторизованной сессии, сохраняемое через ``StoreProtocol``. + + Args: + token: Login token Max. + device_id: ID устройства, связанный с сессией. + phone: Номер телефона сессии; для web-сессии может быть пустой строкой. + mt_instance_id: Instance ID устройства. Пустая строка поддерживается для + сессий, созданных предыдущими версиями PyMax. + user_agent: Device/user-agent сессии. ``None`` поддерживается для старых + или пользовательских store, которые его не сохраняют. + sync: Последние sync-маркеры login. + """ + token: str device_id: str phone: str mt_instance_id: str = "" + user_agent: MobileUserAgentPayload | None = None sync: SyncState = Field(default_factory=SyncState) + + +def resolve_session_user_agent( + current: MobileUserAgentPayload, + stored: MobileUserAgentPayload | None, +) -> MobileUserAgentPayload: + if stored is None or stored.device_type != current.device_type: + return current + + return stored.model_copy( + update={ + "app_version": current.app_version, + "build_number": current.build_number, + } + ) diff --git a/src/pymax/session/protocol.py b/src/pymax/session/protocol.py index c529608..249f7ec 100644 --- a/src/pymax/session/protocol.py +++ b/src/pymax/session/protocol.py @@ -5,10 +5,36 @@ @runtime_checkable class StoreProtocol(Protocol): - async def save_session(self, session_info: SessionInfo) -> None: ... - async def update_token(self, old_token: str, new_token: str) -> None: ... - async def load_session(self) -> SessionInfo | None: ... - async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ... - async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ... - async def delete_session(self, token: str) -> None: ... - async def close(self) -> None: ... + """Асинхронный контракт пользовательского хранилища сессии. + + PyMax использует переданный ``ExtraConfig.store`` только при + ``persist_session=True`` и вызывает :meth:`close` при закрытии runtime. + """ + + async def save_session(self, session_info: SessionInfo) -> None: + """Сохраняет или полностью заменяет данные сессии.""" + ... + + async def update_token(self, old_token: str, new_token: str, /) -> None: + """Заменяет ``old_token`` у соответствующей сессии.""" + ... + + async def load_session(self) -> SessionInfo | None: + """Возвращает сессию для запуска клиента или ``None``.""" + ... + + async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: + """Ищет сессию по device ID.""" + ... + + async def load_session_by_phone(self, phone: str) -> SessionInfo | None: + """Ищет сессию по номеру телефона.""" + ... + + async def delete_session(self, token: str, /) -> None: + """Удаляет сессию с переданным token, если она существует.""" + ... + + async def close(self) -> None: + """Освобождает ресурсы хранилища при закрытии runtime.""" + ... diff --git a/src/pymax/session/store.py b/src/pymax/session/store.py index de7dded..7aa1ee5 100644 --- a/src/pymax/session/store.py +++ b/src/pymax/session/store.py @@ -2,10 +2,12 @@ import aiosqlite +from pymax.api.session.payloads import MobileUserAgentPayload from pymax.logging import get_logger from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, SyncState from .models import SessionInfo +from .protocol import StoreProtocol logger = get_logger(__name__) @@ -18,11 +20,64 @@ contacts_sync, drafts_sync, presence_sync, - config_hash + config_hash, + user_agent """ -class SessionStore: +class InMemoryStore(StoreProtocol): + """Хранилище одной сессии в памяти текущего runtime. + + Данные не записываются на диск и становятся недоступны после пересоздания + ``App``. Обычно этот store выбирается автоматически при + ``ExtraConfig(persist_session=False)``. + """ + + def __init__( + self, + ) -> None: + self._session: SessionInfo | None = None + + async def save_session(self, session_info: SessionInfo) -> None: + self._session = session_info + + async def update_token(self, _: str, new_token: str) -> None: + if not self._session: + return + self._session.token = new_token + + async def load_session(self) -> SessionInfo | None: + return self._session + + async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: + if self._session and self._session.device_id != device_id: + return None + + return self._session + + async def load_session_by_phone(self, phone: str) -> SessionInfo | None: + if self._session and self._session.phone != phone: + return None + + return self._session + + async def delete_session(self, _: str) -> None: + self._session = None + + async def close(self) -> None: + return None + + +class SessionStore(StoreProtocol): + """SQLite-хранилище сессий, используемое по умолчанию. + + Args: + work_dir: Директория файла базы данных; создается автоматически. + db_name: Имя файла базы данных. По умолчанию ``session.db``. + + Схема существующего файла автоматически дополняется недостающими колонками. + """ + def __init__(self, work_dir: str, db_name: str = "session.db") -> None: self.work_dir = Path(work_dir) self.work_dir.mkdir(parents=True, exist_ok=True) @@ -51,7 +106,8 @@ async def _initialize_db(self, conn: aiosqlite.Connection) -> None: contacts_sync INTEGER NOT NULL DEFAULT -1, drafts_sync INTEGER NOT NULL DEFAULT -1, presence_sync INTEGER NOT NULL DEFAULT -1, - config_hash TEXT NOT NULL DEFAULT '' + config_hash TEXT NOT NULL DEFAULT '', + user_agent TEXT ) """ ) @@ -61,6 +117,7 @@ async def _initialize_db(self, conn: aiosqlite.Connection) -> None: await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1") await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1") await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''") + await self._ensure_column(conn, "user_agent", "TEXT") await conn.execute( """ UPDATE sessions @@ -102,9 +159,10 @@ async def save_session(self, session_info: SessionInfo) -> None: contacts_sync, drafts_sync, presence_sync, - config_hash + config_hash, + user_agent ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session_info.token, @@ -116,6 +174,11 @@ async def save_session(self, session_info: SessionInfo) -> None: session_info.sync.drafts_sync, session_info.sync.presence_sync, session_info.sync.config_hash, + ( + session_info.user_agent.model_dump_json(by_alias=True, exclude_none=True) + if session_info.user_agent is not None + else None + ), ), ) await conn.commit() @@ -233,6 +296,11 @@ def _row_to_session(self, row: aiosqlite.Row) -> SessionInfo: device_id=row["device_id"], phone=row["phone"], mt_instance_id=row["mt_instance_id"] or "", + user_agent=( + MobileUserAgentPayload.model_validate_json(row["user_agent"]) + if row["user_agent"] is not None + else None + ), sync=SyncState( chats_sync=row["chats_sync"], contacts_sync=row["contacts_sync"], diff --git a/src/pymax/types/domain/attachments/call.py b/src/pymax/types/domain/attachments/call.py index 61152ed..4d58bb8 100644 --- a/src/pymax/types/domain/attachments/call.py +++ b/src/pymax/types/domain/attachments/call.py @@ -4,7 +4,7 @@ from pymax.types.domain.base import CamelModel -from .enums import AttachmentType +from .enums import AttachmentType, CallType, HangupType class CallAttachment(CamelModel): @@ -18,9 +18,15 @@ class CallAttachment(CamelModel): :vartype conversation_id: str | int | None :ivar contact_ids: ID участников звонка. :vartype contact_ids: list[int] + :ivar call_type: Тип звонка, если Max его прислал. + :vartype call_type: CallType | None + :ivar hangup_type: Причина завершения звонка, если она есть в payload-е. + :vartype hangup_type: HangupType | None """ type: Literal[AttachmentType.CALL] = Field(alias="_type") duration: int | None = None conversation_id: str | int | None = None contact_ids: list[int] = Field(default_factory=list) + call_type: CallType | None = None + hangup_type: HangupType | None = None diff --git a/src/pymax/types/domain/attachments/contact.py b/src/pymax/types/domain/attachments/contact.py index ca3a12e..e62d88c 100644 --- a/src/pymax/types/domain/attachments/contact.py +++ b/src/pymax/types/domain/attachments/contact.py @@ -10,8 +10,8 @@ class ContactAttachment(CamelModel): """Контактное вложение сообщения. - :ivar contact_id: ID контакта. - :vartype contact_id: int + :ivar contact_id: ID контакта, если Max его прислал. + :vartype contact_id: int | None :ivar first_name: Имя контакта. :vartype first_name: str | None :ivar last_name: Фамилия контакта. @@ -24,7 +24,7 @@ class ContactAttachment(CamelModel): :vartype type: Literal[AttachmentType.CONTACT] """ - contact_id: int + contact_id: int | None = None first_name: str | None = None last_name: str | None = None name: str | None = None diff --git a/src/pymax/types/domain/attachments/control.py b/src/pymax/types/domain/attachments/control.py index 4237a1a..a2bbd38 100644 --- a/src/pymax/types/domain/attachments/control.py +++ b/src/pymax/types/domain/attachments/control.py @@ -14,7 +14,10 @@ class ControlAttachment(CamelModel): :vartype type: Literal[AttachmentType.CONTROL] :ivar event: Событие управления. :vartype event: str + :ivar title: Заголовок служебного события, если он есть в payload-е. + :vartype title: str | None """ type: Literal[AttachmentType.CONTROL] = Field(alias="_type") event: str + title: str | None = None diff --git a/src/pymax/types/domain/attachments/enums.py b/src/pymax/types/domain/attachments/enums.py index 583f821..8bda772 100644 --- a/src/pymax/types/domain/attachments/enums.py +++ b/src/pymax/types/domain/attachments/enums.py @@ -42,3 +42,19 @@ class PollFlags(IntFlag): FLAG_SETTINGS_CLOSED = 8 FLAG_SETTINGS_QUIZ = 16 FLAG_SETTINGS_CAN_FORWARD = 32 + + +class CallType(str, Enum): + """Тип звонка во входящем ``CallAttachment``.""" + + AUDIO = "AUDIO" + VIDEO = "VIDEO" + + +class HangupType(str, Enum): + """Причина завершения звонка во входящем ``CallAttachment``.""" + + MISSED = "MISSED" + REJECTED = "REJECTED" + CANCELED = "CANCELED" + HUNGUP = "HUNGUP" diff --git a/src/pymax/types/domain/attachments/video.py b/src/pymax/types/domain/attachments/video.py index f1f583a..711efd0 100644 --- a/src/pymax/types/domain/attachments/video.py +++ b/src/pymax/types/domain/attachments/video.py @@ -32,8 +32,8 @@ class VideoAttachment(CamelModel): :vartype video_id: int :ivar duration: Длительность видео. :vartype duration: int | None - :ivar preview_data: Данные превью. - :vartype preview_data: bytes + :ivar preview_data: Данные превью, если Max их прислал. + :vartype preview_data: bytes | None :ivar type: Тип вложения. :vartype type: Literal[AttachmentType.VIDEO] :ivar thumbnail: URL миниатюры. @@ -48,7 +48,7 @@ class VideoAttachment(CamelModel): width: int video_id: int duration: int | None = None - preview_data: bytes + preview_data: bytes | None = None type: Literal[AttachmentType.VIDEO] = Field(alias="_type") thumbnail: str token: str diff --git a/src/pymax/types/domain/enums.py b/src/pymax/types/domain/enums.py index 741bb31..fa6d2d8 100644 --- a/src/pymax/types/domain/enums.py +++ b/src/pymax/types/domain/enums.py @@ -22,3 +22,10 @@ class MessageStatus(str, Enum): EDITED = "EDITED" REMOVED = "REMOVED" + + +class LinkType(str, Enum): + """Тип связи входящего сообщения с исходным сообщением.""" + + REPLY = "REPLY" + FORWARD = "FORWARD" diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 13d6830..23bc362 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any, TypeAlias +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias from pydantic import Field, PrivateAttr, model_validator @@ -24,7 +24,7 @@ from .base import CamelModel from .element import Element -from .enums import MessageStatus +from .enums import AccessType, LinkType, MessageStatus if TYPE_CHECKING: from pymax.api.messages import DateTimeUnion, MessageService @@ -47,6 +47,7 @@ Attachment: TypeAlias = KnownAttachment | UnknownAttachment SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None +Link: TypeAlias = Annotated["ReplyLink | ForwardLink", Field(discriminator="type")] class ReactionCounter(CamelModel): @@ -97,6 +98,60 @@ class DelayedAttributes(CamelModel): notify_opponents: bool +class ReplyLink(CamelModel): + """Источник сообщения, отправленного как reply. + + :ivar message: Сообщение, на которое ответили. + :vartype message: Message + :ivar chat_id: ID чата исходного сообщения. + :vartype chat_id: int + """ + + type: Literal[LinkType.REPLY] = LinkType.REPLY + message: Message + chat_id: int + + +class ForwardLink(CamelModel): + """Источник пересланного сообщения. + + Помимо исходного сообщения содержит доступные в payload-е имя, ссылку, + тип доступа и иконку исходного чата. Для скрытого источника эти метаданные + могут быть ``None``. + + :ivar message: Исходное сообщение. + :vartype message: Message + :ivar chat_id: ID исходного чата. + :vartype chat_id: int + """ + + type: Literal[LinkType.FORWARD] = LinkType.FORWARD + message: Message + chat_id: int + chat_name: str | None = None + chat_link: str | None = None + chat_access_type: AccessType | None = None + chat_icon_url: str | None = None + + +""" + "link": { + "type": "FORWARD", + "message": { + "id": "116641336752745888", + "time": 1779805553478, + "type": "CHANNEL", + "text": "Добро пожаловать на наше шоу!", + "attaches": [] + }, + "chatId": -75183661482741, + "chatName": "Max Bounty Test", + "chatLink": "https://max.ru/join/htlT5aTtlpAgcno_hc93lLM44Dxe45HvOEBqx22lKms", + "chatAccessType": "PRIVATE" + } +""" + + class Message(CamelModel): """Сообщение Max с методами для действий над ним. @@ -136,7 +191,8 @@ async def on_message(message: Message, client: Client) -> None: :vartype type: str :ivar cid: Клиентский ID сообщения, если он есть в payload-е. :vartype cid: int | None - :ivar attaches: Вложения сообщения. + :ivar attaches: Вложения сообщения. Неизвестный ``_type`` представлен как + ``UnknownAttachment`` с сохраненными дополнительными полями. :vartype attaches: list[Attachment] :ivar stats: Дополнительная статистика сообщения от Max. :vartype stats: dict[str, Any] | None @@ -156,6 +212,12 @@ async def on_message(message: Message, client: Client) -> None: :vartype mark: int | None :ivar elements: Форматированные элементы текста сообщения. :vartype elements: list[Element] + :ivar delayed_attributes: Время срабатывания в миллисекундах и настройки + уведомлений для отложенного сообщения; ``None`` для обычного. + :vartype delayed_attributes: DelayedAttributes | None + :ivar link: Исходное сообщение и чат для reply или forward; ``None`` для + обычного сообщения без связи. + :vartype link: ReplyLink | ForwardLink | None """ id: int @@ -176,6 +238,7 @@ async def on_message(message: Message, client: Client) -> None: mark: int | None = None elements: list[Element] = Field(default_factory=list) delayed_attributes: DelayedAttributes | None = None + link: Link | None = None _actions: MessageService | None = PrivateAttr(default=None) @@ -206,6 +269,9 @@ async def reply( :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool + :param send_at: Абсолютный ``datetime``, относительный ``timedelta`` + или Unix time в секундах. ``None`` и ``0`` отправляют сразу. + :type send_at: DateTimeUnion | None :returns: Отправленное сообщение. :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не @@ -241,6 +307,9 @@ async def answer( :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool + :param send_at: Абсолютный ``datetime``, относительный ``timedelta`` + или Unix time в секундах. ``None`` и ``0`` отправляют сразу. + :type send_at: DateTimeUnion | None :returns: Отправленное сообщение. :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не diff --git a/src/pymax/versions/catalog.py b/src/pymax/versions/catalog.py index 9d0e6ba..2dd6857 100644 --- a/src/pymax/versions/catalog.py +++ b/src/pymax/versions/catalog.py @@ -10,6 +10,17 @@ class VersionCatalog: + """Каталог fingerprints Android-клиента Max. + + Встроенные версии загружаются при создании объекта. При ``remote=True`` + вызов :meth:`load` дополнительно получает каталог с + ``DEFAULT_REMOTE_URL`` и объединяет его со встроенным. Ошибки сети, HTTP и + валидации удаленного ответа не подавляются. + + Args: + remote: Загружать ли удаленные версии при :meth:`load`. + """ + RECOMMENDED_APP_VERSION: str = "26.25.0" DEFAULT_REMOTE_URL: str = "https://hashes.pymax.org/versions.json" @@ -21,9 +32,15 @@ def __init__(self, remote: bool = False) -> None: @staticmethod def recommended() -> str: + """Возвращает версию, используемую ``Client`` по умолчанию.""" return VersionCatalog.RECOMMENDED_APP_VERSION async def load(self) -> "VersionCatalog": + """Загружает удаленные версии, если включен ``remote``. + + Returns: + Этот же каталог с добавленными удаленными версиями. + """ if not self.remote: return self @@ -33,12 +50,27 @@ async def load(self) -> "VersionCatalog": return self def add(self, version: str, fingerprint: ApkBuildFingerprint, override: bool = False) -> None: + """Добавляет fingerprint версии в текущий каталог. + + Args: + version: Строка версии Android-клиента. + fingerprint: Fingerprint и build number этой версии. + override: Заменить существующую запись. По умолчанию ``False``. + + Raises: + ValueError: Если версия уже существует и ``override=False``. + """ if self.versions.get(version) is not None and override is False: raise VersionAlreadyExistsError(version) self.versions[version] = fingerprint def resolve(self, version: str) -> ApkBuildFingerprint: + """Возвращает fingerprint версии. + + Raises: + VersionNotFoundError: Если версии нет в каталоге. + """ try: return self.versions[version] except KeyError as e: diff --git a/src/pymax/versions/exceptions.py b/src/pymax/versions/exceptions.py index d4bcb32..d728add 100644 --- a/src/pymax/versions/exceptions.py +++ b/src/pymax/versions/exceptions.py @@ -1,4 +1,6 @@ class VersionNotFoundError(ValueError): + """Выбранная версия приложения отсутствует в каталоге fingerprints.""" + def __init__(self, version: str) -> None: super().__init__(f"Could not found version {version} in registry") diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 15a7e0c..60bce6b 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -13,7 +13,7 @@ from pymax.exceptions import ApiError from pymax.protocol import Command, InboundFrame, Opcode from pymax.session.models import SessionInfo -from tests.conftest import frame, make_config, profile_payload, user_payload +from tests.conftest import frame, make_config, mobile_user_agent, profile_payload, user_payload class RuntimeStore: @@ -151,6 +151,7 @@ async def idle_ping_loop(self): assert app.me is not None assert app.me.contact.id == 77 assert store.saved[0].token == "config-token" + assert store.saved[0].user_agent == config.device.user_agent assert [sent[0].opcode for sent in connection.sent] == [ Opcode.SESSION_INIT, Opcode.LOGIN, @@ -161,6 +162,118 @@ async def idle_ping_loop(self): assert store.closed is True +@pytest.mark.asyncio +async def test_app_start_restores_session_user_agent_before_handshake( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + stored_user_agent = mobile_user_agent().model_copy( + update={ + "app_version": "26.13.0", + "build_number": 6683, + "device_name": "Stored Pixel", + "os_version": "Android 13", + "screen": "1440x3120", + "timezone": "Asia/Yekaterinburg", + } + ) + store = RuntimeStore( + SessionInfo( + token="stored-token", + device_id="stored-device", + phone="+79990000000", + mt_instance_id="stored-mt", + user_agent=stored_user_agent, + ) + ) + config = make_config().model_copy(update={"store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "profile": profile_payload(77), + "contacts": [], + "chats": [], + "messages": {}, + } + ), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + restored = app.config.device.user_agent + assert restored.device_name == "Stored Pixel" + assert restored.os_version == "Android 13" + assert restored.screen == "1440x3120" + assert restored.timezone == "Asia/Yekaterinburg" + assert restored.arch == "arm64-v8a" + assert restored.app_version == "26.14.1" + assert restored.build_number == 6686 + assert connection.sent[0][0].payload["userAgent"] == restored.to_payload() + assert app.session is not None + assert app.session.user_agent == restored + assert store.saved[-1].user_agent == restored + + await app.close() + + +@pytest.mark.asyncio +async def test_app_start_keeps_explicit_user_agent_over_session_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + stored_user_agent = mobile_user_agent().model_copy(update={"device_name": "Stored Pixel"}) + explicit_user_agent = mobile_user_agent().model_copy(update={"device_name": "Explicit Pixel"}) + store = RuntimeStore( + SessionInfo( + token="stored-token", + device_id="stored-device", + phone="+79990000000", + user_agent=stored_user_agent, + ) + ) + config = make_config().model_copy( + update={ + "store": store, + "restore_user_agent_from_session": False, + } + ) + config.device.user_agent = explicit_user_agent + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "profile": profile_payload(77), + "contacts": [], + "chats": [], + "messages": {}, + } + ), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.config.device.user_agent == explicit_user_agent + assert connection.sent[0][0].payload["userAgent"] == explicit_user_agent.to_payload() + assert app.session is not None + assert app.session.user_agent == explicit_user_agent + assert store.saved[-1].user_agent == explicit_user_agent + + await app.close() + + @pytest.mark.asyncio async def test_app_start_completes_login2_and_uses_deferred_profile_and_contacts( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/app/test_client_user_agent_config.py b/tests/app/test_client_user_agent_config.py new file mode 100644 index 0000000..44f9c37 --- /dev/null +++ b/tests/app/test_client_user_agent_config.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest + +from pymax import Client, ExtraConfig, WebClient +from pymax.api.session.enums import DeviceType +from tests.conftest import mobile_user_agent + + +@pytest.mark.asyncio +async def test_client_config_restores_only_generated_user_agent_from_session() -> None: + generated_client = Client(phone="+79990000000", app_version="26.14.1") + explicit_user_agent = mobile_user_agent() + explicit_client = Client( + phone="+79990000000", + app_version="26.14.1", + extra_config=ExtraConfig(user_agent=explicit_user_agent), + ) + + generated_config = await generated_client._prepare_config() + explicit_config = await explicit_client._prepare_config() + + assert generated_config.restore_user_agent_from_session is True + assert explicit_config.restore_user_agent_from_session is False + + +@pytest.mark.asyncio +async def test_web_client_config_restores_only_generated_user_agent_from_session() -> None: + generated_client = WebClient() + explicit_user_agent = mobile_user_agent(DeviceType.WEB) + explicit_client = WebClient(extra_config=ExtraConfig(user_agent=explicit_user_agent)) + + generated_config = await generated_client._prepare_config() + explicit_config = await explicit_client._prepare_config() + + assert generated_config.restore_user_agent_from_session is True + assert explicit_config.restore_user_agent_from_session is False diff --git a/tests/session/test_store.py b/tests/session/test_store.py index d6492a7..91a0da3 100644 --- a/tests/session/test_store.py +++ b/tests/session/test_store.py @@ -1,10 +1,13 @@ from __future__ import annotations +import sqlite3 + import pytest from pymax.session.models import SessionInfo from pymax.session.store import SessionStore from pymax.types.domain.sync import SyncState +from tests.conftest import mobile_user_agent @pytest.mark.asyncio @@ -12,11 +15,13 @@ async def test_session_store_saves_loads_updates_and_deletes_session( tmp_path, ) -> None: store = SessionStore(str(tmp_path), "test-session.db") + user_agent = mobile_user_agent() session = SessionInfo( token="token-1", device_id="device-1", phone="+79990000000", mt_instance_id="mt-1", + user_agent=user_agent, sync=SyncState( chats_sync=1, contacts_sync=2, @@ -33,6 +38,7 @@ async def test_session_store_saves_loads_updates_and_deletes_session( by_phone = await store.load_session_by_phone("+79990000000") assert loaded == session + assert loaded.user_agent == user_agent assert by_device == session assert by_phone == session @@ -66,3 +72,62 @@ async def test_session_store_deletes_all_sessions(tmp_path) -> None: assert await store.load_session_by_phone("") is None await store.close() + + +@pytest.mark.asyncio +async def test_session_store_loads_database_created_before_user_agent(tmp_path) -> None: + db_path = tmp_path / "legacy-session.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + token TEXT NOT NULL PRIMARY KEY, + device_id TEXT NOT NULL, + phone TEXT NOT NULL, + mt_instance_id TEXT NOT NULL DEFAULT '', + chats_sync INTEGER NOT NULL DEFAULT -1, + contacts_sync INTEGER NOT NULL DEFAULT -1, + drafts_sync INTEGER NOT NULL DEFAULT -1, + presence_sync INTEGER NOT NULL DEFAULT -1, + config_hash TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.execute( + """ + INSERT INTO sessions ( + token, + device_id, + phone, + mt_instance_id, + chats_sync, + contacts_sync, + drafts_sync, + presence_sync, + config_hash + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("legacy-token", "legacy-device", "+79990000000", "legacy-mt", 1, 2, 3, 4, "hash"), + ) + + store = SessionStore(str(tmp_path), db_path.name) + + loaded = await store.load_session() + + assert loaded == SessionInfo( + token="legacy-token", + device_id="legacy-device", + phone="+79990000000", + mt_instance_id="legacy-mt", + sync=SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="hash", + ), + ) + assert loaded.user_agent is None + + await store.close() From 8d01d6b36ce5008d5af77bc41923aa00c37c1a7b Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:05:51 +0300 Subject: [PATCH 09/13] docs: prepare 2.4.1 release --- README.md | 5 +- docs/account.rst | 5 +- docs/api/auth.rst | 2 + docs/api/client-session.rst | 20 ++++++ docs/api/client-versions.rst | 12 ++++ docs/api/client.rst | 2 + docs/auth.rst | 24 ++++++++ docs/chats.rst | 8 +++ docs/client.rst | 115 +++++++++++++++++++++++++++++++++-- docs/faq.rst | 8 ++- docs/files.rst | 25 +++++--- docs/index.rst | 1 + docs/messages.rst | 74 ++++++++++++++++++++-- docs/release-2-4-1.rst | 103 +++++++++++++++++++++++++++++++ docs/troubleshooting.rst | 7 ++- docs/types/enums.rst | 9 +++ docs/types/index.rst | 7 ++- pyproject.toml | 2 +- uv.lock | 2 +- 19 files changed, 397 insertions(+), 34 deletions(-) create mode 100644 docs/api/client-session.rst create mode 100644 docs/api/client-versions.rst create mode 100644 docs/release-2-4-1.rst diff --git a/README.md b/README.md index 8984fec..ffeec0c 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,11 @@ WebSocket. - Авторизация по телефону и SMS-коду через `Client`. - QR-авторизация web-клиента через `WebClient`. - Роутеры, фильтры, `on_start`, raw-события и typed events. -- Сообщения: отправка, ответы, reply, реакции, pin, read, delete и история. +- Сообщения: отправка и планирование, ответы, реакции, pin, read, delete и история. - Чаты, группы, участники, invite-ссылки и настройки групп. - Пользователи, контакты, профиль, папки, активные сессии и 2FA. -- Вложения: `Photo`, `File`, `Video`. +- Вложения: `Photo`, `File`, `Video`, `Voice`, `VideoNote`. +- Выбор версии mobile-клиента с локальным или обновляемым каталогом fingerprints. - SQLite-сессии, sync-state, reconnect и debug-логи. - Pydantic-модели и удобные domain-объекты. diff --git a/docs/account.rst b/docs/account.rst index 9a1e8da..d306c13 100644 --- a/docs/account.rst +++ b/docs/account.rst @@ -129,9 +129,8 @@ login или ping: ------------- ``client.me`` равен ``None`` - Login еще не завершился или клиент не запущен. Читайте профиль внутри - ``on_start`` или после успешного ``await client.start()`` в собственном - lifecycle. + Login еще не завершился. Читайте профиль внутри ``on_start`` или после + успешного ``await client.connect()``. Фото профиля не обновилось Если переданы и ``photo``, и ``photo_token``, PyMax загрузит ``photo`` и diff --git a/docs/api/auth.rst b/docs/api/auth.rst index 2e2d276..7bf28c3 100644 --- a/docs/api/auth.rst +++ b/docs/api/auth.rst @@ -33,3 +33,5 @@ Auth API .. autoclass:: pymax.ConsoleQrHandler :members: + +.. autoclass:: pymax.PasswordAttemptsExceededError diff --git a/docs/api/client-session.rst b/docs/api/client-session.rst new file mode 100644 index 0000000..caae378 --- /dev/null +++ b/docs/api/client-session.rst @@ -0,0 +1,20 @@ +Session storage +=============== + +.. currentmodule:: pymax.session + +``Client`` и ``WebClient`` используют этот контракт для token, +device/user-agent и sync-state. Выбор и lifecycle хранилища описаны в разделе +:ref:`client-session-guide`. + +.. autoclass:: SessionInfo + :members: + +.. autoclass:: StoreProtocol + :members: + +.. autoclass:: SessionStore + :members: + +.. autoclass:: InMemoryStore + :members: diff --git a/docs/api/client-versions.rst b/docs/api/client-versions.rst new file mode 100644 index 0000000..d9fc060 --- /dev/null +++ b/docs/api/client-versions.rst @@ -0,0 +1,12 @@ +Version Catalog +=============== + +.. currentmodule:: pymax.versions + +``VersionCatalog`` связывает версию Android-клиента с build number и +fingerprint, которые использует ``Client``. + +.. autoclass:: VersionCatalog + :members: + +.. autoclass:: VersionNotFoundError diff --git a/docs/api/client.rst b/docs/api/client.rst index 5713d44..e35c51e 100644 --- a/docs/api/client.rst +++ b/docs/api/client.rst @@ -23,3 +23,5 @@ Clients API client-client client-web client-config + client-session + client-versions diff --git a/docs/auth.rst b/docs/auth.rst index cd5443a..3aa2a06 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -88,6 +88,30 @@ Provider нужен, когда код приходит не из консоли Если provider вернет пустую строку или Max отклонит пароль, ``SmsAuthFlow`` попросит пароль снова. +Количество попыток можно ограничить через ``ExtraConfig``: + +.. code-block:: python + + from pymax import Client, ExtraConfig, PasswordAttemptsExceededError + + client = Client( + phone="+79990000000", + password_provider=EnvPasswordProvider(), + extra_config=ExtraConfig(password_max_attempts=3), + ) + + try: + await client.connect() + except PasswordAttemptsExceededError: + print("Пароль 2FA не принят") + +``password_max_attempts=None`` означает неограниченное число попыток и +сохраняет прежнее поведение. Пустой пароль, ``ApiError``, ошибка в ответе Max +и ответ без login token расходуют одну попытку. При ``0`` или отрицательном +значении provider не вызывается, а ``PasswordAttemptsExceededError`` +возникает сразу. Исключение наследуется напрямую от ``Exception``, поэтому +его нужно перехватывать отдельно от ``PyMaxError``. + Кастомный QR handler -------------------- diff --git a/docs/chats.rst b/docs/chats.rst index 26e353d..fa0373a 100644 --- a/docs/chats.rst +++ b/docs/chats.rst @@ -129,10 +129,13 @@ login/sync, а также методы для загрузки, создания .. code-block:: python + from pymax import Photo + await client.change_group_profile( chat_id=123456, name="Новый заголовок", description="Описание группы", + photo=Photo(path="group.jpg"), ) await client.change_group_settings( @@ -141,6 +144,11 @@ login/sync, а также методы для загрузки, создания only_admin_can_call=True, ) +``name``, ``description`` и ``photo`` независимы: передавайте только поля, +которые нужно изменить. Значение ``None`` исключает поле из запроса. Перед +обновлением профиля PyMax автоматически загружает ``Photo`` и отправляет Max +полученный photo token. + Через объект ``Chat`` можно менять настройки и перевыпускать invite-ссылку: .. code-block:: python diff --git a/docs/client.rst b/docs/client.rst index 0fa4e3e..d91ab51 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -7,9 +7,9 @@ Client ``Client`` и ``WebClient`` - главные объекты библиотеки. Они держат соединение, авторизацию, локальную сессию, кеш профиля/чатов и методы для API Max. -``Client`` не является обычным HTTP-wrapper-ом на один запрос. После -``await client.start()`` он остается подключенным, слушает события Max, -отвечает на ping и вызывает ваши handler-ы. +``Client`` не является обычным HTTP-wrapper-ом на один запрос. После запуска +он остается подключенным, слушает события Max, отвечает на ping и вызывает +ваши handler-ы. Как выбрать клиента ------------------- @@ -27,12 +27,31 @@ Client 1. Вы создаете ``Client`` или ``WebClient``. 2. Регистрируете handler-ы и подключаете роутеры. -3. Вызываете ``await client.start()``. +3. Вызываете ``await client.start()`` или ``await client.connect()``. 4. PyMax открывает соединение, делает handshake и login. 5. После login доступны ``client.me`` и ``client.chats``. 6. Вызывается ``on_start``. 7. Клиент слушает события до закрытия соединения или отмены задачи. +``start()`` - long-running режим с автоматическим reconnect. Метод не +возвращается, пока соединение не закрыто штатно или задача не отменена. + +``connect()`` выполняет один цикл подключения, login и ``on_start``, после +чего возвращается, оставляя соединение открытым. Он не запускает reconnect +loop: приложение само выполняет работу и затем вызывает ``stop()`` или +``close()``. + +.. code-block:: python + + await client.connect() + try: + await client.send_message(chat_id=123456, text="Готово") + finally: + await client.close() + +После успешного запуска состояние соединения доступно через +``client.is_connected``. + Если ``start()`` запущен отдельной задачей, ``stop()`` штатно закрывает соединение и завершает эту задачу без ``CancelledError``: @@ -51,7 +70,7 @@ Client После успешного login клиент хранит несколько кешей, которые пришли от Max: ``client.me`` - Профиль текущего аккаунта или ``None``, если login еще не завершился. + Профиль текущего аккаунта. ``client.chats`` Чаты из login/sync. Это не гарантированно полный список всех чатов. @@ -106,7 +125,46 @@ Client Имя файла сессии внутри ``work_dir``. ``extra_config`` - Настройки соединения, логов, reconnect, token, device/user-agent и sync. + Настройки соединения, логов, reconnect, token, хранения сессии, + device/user-agent и sync. + +``app_version`` + Версия Android-клиента, которую ``Client`` использует для user-agent и + fingerprint. По умолчанию - версия, возвращаемая + ``VersionCatalog.recommended()``. + +``catalog`` + Каталог fingerprints для ``app_version``. По умолчанию используется + встроенный каталог. + +Версия mobile-клиента +--------------------- + +``Client`` связывает выбранную ``app_version`` с соответствующими build number +и fingerprint. Разрешение версии происходит при ``connect()`` или ``start()``, +а не при создании объекта клиента. + +.. code-block:: python + + from pymax import Client + from pymax.versions import VersionCatalog + + client = Client( + phone="+79990000000", + app_version="26.28.0", + catalog=VersionCatalog(remote=True), + ) + +Обычный ``VersionCatalog()`` использует только данные, поставляемые вместе с +PyMax. При ``remote=True`` метод ``load()`` загружает свежий каталог с +``hashes.pymax.org`` и добавляет его записи поверх встроенных. Сетевые, +HTTP- и validation-ошибки удаленного каталога не скрываются и возникают во +время ``connect()``/``start()``. + +Если выбранной версии нет в итоговом каталоге, PyMax выбрасывает +``VersionNotFoundError``. Пользовательский ``ExtraConfig.user_agent`` не +отменяет проверку ``app_version``: fingerprint для версии всё равно должен +существовать. Тип устройства --------------- @@ -209,6 +267,8 @@ Client qr_provider=ConfirmQrWithClient(mobile_client), ) +.. _client-session-guide: + Сессия и sync-state ------------------- @@ -218,6 +278,7 @@ Client * ``device_id``; * телефон; * ``mt_instance_id``; +* device/user-agent; * sync-маркеры ``chats_sync``, ``contacts_sync``, ``drafts_sync``, ``presence_sync`` и ``config_hash``. @@ -225,11 +286,49 @@ Client ``session_name="account.db"`` это будет ``cache/account.db``. Если файл удалить, PyMax потеряет token и попросит авторизацию снова. +Для автоматически создаваемого user-agent PyMax восстанавливает из сессии +модель устройства, версию ОС, экран, locale и timezone. ``app_version`` и build +number всегда берутся из конфигурации текущего запуска, поэтому смена версии +клиента не сбрасывает сохраненный профиль устройства. Явный +``ExtraConfig.user_agent`` имеет приоритет и заменяет значение из сессии. + +Старый SQLite-файл обновляется автоматически: PyMax добавляет nullable-колонку +``user_agent`` и при первом успешном login сохраняет в нее текущий payload. + При login PyMax отправляет сохраненные маркеры серверу. Если маркер актуален, сервер может вернуть только изменения. Поэтому повторный запуск отличается от первого: первый запуск обычно использует ``-1`` и получает начальный sync, а последующие запуски продолжают с сохраненного состояния. +Пользовательский ``StoreProtocol`` может возвращать +``SessionInfo.user_agent=None`` для совместимости, но тогда профиль устройства +не сохраняется между запусками. Чтобы получить стандартное поведение, храните и +возвращайте это необязательное поле вместе с остальными данными сессии. + +Сессия только в памяти +---------------------- + +Чтобы не создавать и не обновлять SQLite-файл, передайте +``persist_session=False``: + +.. code-block:: python + + from pymax import Client, ExtraConfig + + client = Client( + phone="+79990000000", + extra_config=ExtraConfig( + persist_session=False, + reconnect=False, + ), + ) + +В этом режиме PyMax использует отдельный ``InMemoryStore``. Переданный +``ExtraConfig.store`` игнорируется. Сессия доступна только текущему runtime и +теряется при его пересоздании; reconnect без ``ExtraConfig.token`` поэтому снова +запускает SMS- или QR-авторизацию. Если автоматический reconnect в таком режиме +не нужен, отключите его как в примере. + Принудительный sync ------------------- @@ -278,6 +377,10 @@ Reconnect а новый ``App`` снова получает тот же root router. ``on_start`` вызывается после каждого успешного reconnect. +Если сервер отозвал login token, ``ExtraConfig(relogin=True)`` по умолчанию +сбрасывает текущую сессию и запускает авторизацию заново. Значение ``False`` +отключает этот автоматический сброс. + Перед повторным подключением можно зарегистрировать ``on_disconnect``: .. code-block:: python diff --git a/docs/faq.rst b/docs/faq.rst index 7739cbd..d56a16d 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -93,6 +93,8 @@ Max не всегда присылает полный объект сообще Что делать при Pydantic validation error? ----------------------------------------- -Max мог прислать новый или неполный формат payload. Включите ``DEBUG``, снимите -raw payload через ``on_raw`` и обновите PyMax. Если ошибка повторяется, заведите -issue с очищенным payload без token, телефонов и приватных URL. +Неизвестный тип вложения сохраняется как ``UnknownAttachment`` и сам по себе не +должен ломать сообщение. Validation error обычно означает неполный или +измененный payload уже известной модели. Включите ``DEBUG``, снимите raw payload +через ``on_raw`` и обновите PyMax. Если ошибка повторяется, заведите issue с +очищенным payload без token, телефонов и приватных URL. diff --git a/docs/files.rst b/docs/files.rst index 989f5cb..ac83e7a 100644 --- a/docs/files.rst +++ b/docs/files.rst @@ -17,7 +17,8 @@ Files ``Voice`` Голосовое сообщение. Поддерживается только формат OGG; PyMax не - конвертирует другие аудиоформаты. + конвертирует другие аудиоформаты. Длительность можно передать вручную или + определить автоматически через extra ``video``. ``VideoNote`` Круглое видеосообщение. Можно передать длительность вручную или установить @@ -79,8 +80,9 @@ Files ``Voice`` и ``VideoNote`` имя берется из ``path`` или ``url``, если не передано явно. -Если длительность ``VideoNote`` не передана, установите дополнительную -зависимость: +``duration`` для ``Voice`` и ``VideoNote`` задается в миллисекундах. Если +длительность не передана, установите дополнительную зависимость для её +автоматического определения: .. code-block:: console @@ -121,11 +123,20 @@ orientation hint. H.264 profile и level специально фиксирова 1. PyMax запрашивает у Max временный upload URL. 2. Читает файл из ``path``, ``url`` или ``raw``. 3. Загружает данные HTTP-запросом. -4. Для ``File``, ``Video``, ``Voice`` и ``VideoNote`` ждет служебное событие - готовности до 60 секунд. +4. Для ``File`` и обычного ``Video`` ждет служебное событие готовности до + 60 секунд. 5. Подставляет token/file_id/video_id в отправляемое сообщение. Фото проходит проще: после HTTP-upload PyMax сразу достает token из ответа. +``Voice`` и ``VideoNote`` отправляются сразу после HTTP-upload. Если Max +ответил ``attachment.not.ready``, PyMax ждет событие готовности до 60 секунд и +повторяет отправку сообщения один раз. + +Общий timeout HTTP-загрузки ``File``, ``Video``, ``Voice`` и ``VideoNote`` +задается через ``ExtraConfig(upload_timeout=...)`` в секундах; значение по +умолчанию - ``900``. Он не применяется к ``Photo`` и не меняет отдельный +60-секундный лимит ожидания обработки. ``0`` и отрицательные значения +отключают общий timeout, но не отдельный 60-секундный socket/read timeout. Скачать входящий файл --------------------- @@ -170,5 +181,5 @@ orientation hint. H.264 profile и level специально фиксирова что событие готовности файла не пришло за 60 секунд. ``Automatic video duration detection requires the 'video' extra`` - Передайте ``duration`` в миллисекундах или установите - ``maxapi-python[video]``. + Для ``Voice`` или ``VideoNote`` передайте ``duration`` в миллисекундах либо + установите ``maxapi-python[video]``. diff --git a/docs/index.rst b/docs/index.rst index a9885ba..67df008 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-4-1 release-2-4-0 release-2-3-1 release-2-3-0 diff --git a/docs/messages.rst b/docs/messages.rst index 0c56f47..febbded 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -83,6 +83,57 @@ Messages source_chat_id=123456, ) +Reply и forward во входящем сообщении +------------------------------------- + +Если сообщение является reply или forward, поле ``message.link`` содержит +исходное ``message`` и его ``chat_id``. Для forward Max также может прислать +``chat_name``, ``chat_link``, ``chat_access_type`` и ``chat_icon_url``; для +скрытого источника эти поля равны ``None``. У обычного сообщения ``link`` равен +``None``. + +.. code-block:: python + + @client.on_message() + async def on_message(message: Message, client: Client) -> None: + if message.link is not None: + print("source:", message.link.chat_id, message.link.message.id) + +Отложенная отправка +------------------- + +``client.send_message()``, ``message.answer()`` и ``message.reply()`` принимают +``send_at`` трех видов: + +* ``datetime`` - абсолютное время отправки; +* ``timedelta`` - задержка относительно текущего локального времени; +* ``int`` - Unix time в секундах. + +.. code-block:: python + + from datetime import datetime, timedelta, timezone + + await client.send_message( + chat_id=123456, + text="Через пять минут", + send_at=timedelta(minutes=5), + ) + + await client.send_message( + chat_id=123456, + text="В назначенное время", + send_at=datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc), + notify=False, + ) + +``None`` и ``0`` означают немедленную отправку. Значение ``notify`` также +используется сервером при срабатывании отложенного сообщения. + +У полученного отложенного сообщения поле ``delayed_attributes`` содержит +``time_to_fire`` в миллисекундах и флаги уведомлений. Для обычного сообщения +оно равно ``None``. Получить отложенные сообщения можно через +``fetch_history(item_type=ItemType.DELAYED)``. + Опросы ------ @@ -135,7 +186,7 @@ Messages print(state.total) Ответ, реакции, удаление и прочтение ----------------------------------------- +------------------------------------ .. code-block:: python @@ -160,6 +211,11 @@ Messages WebSocket-клиент - как ``str``. Если вызываете метод напрямую, выбирайте тип по клиенту. +Методы ``add_reaction()``, ``get_reactions()`` и ``remove_reaction()`` +принимают ID сообщений только как ``int``. При этом ``get_reactions()`` +возвращает словарь со строковыми ключами, потому что именно так ID приходят в +ответе Max. + Служебные события ----------------- @@ -232,10 +288,15 @@ Max присылает разные формы событий. Некоторы полю ``type``: фото, видео, файл, стикер, аудио, опрос, контакт, звонок, share или inline-клавиатура. +Неизвестный ``_type`` не отклоняет все сообщение: PyMax возвращает +``UnknownAttachment``, сохраняет исходный тип в ``type``, а остальные поля - в +``model_extra``. Это позволяет принять payload нового типа до добавления его +отдельной модели в библиотеку. + .. code-block:: python from pymax import Client, Message - from pymax.types.domain import FileAttachment, PhotoAttachment + from pymax.types.domain import FileAttachment, PhotoAttachment, UnknownAttachment @client.on_message() async def on_message(message: Message, client: Client) -> None: @@ -252,6 +313,8 @@ Max присылает разные формы событий. Некоторы file_id=attach.file_id, ) print(file_info.url if file_info else "no url") + elif isinstance(attach, UnknownAttachment): + print("unknown:", attach.type, attach.model_extra) Частые ошибки ------------- @@ -266,6 +329,7 @@ Max присылает разные формы событий. Некоторы знаете ``chat_id`` из другого источника. Pydantic validation error на attachments - Max мог прислать новый или неполный тип вложения. Включите debug-логи, - посмотрите raw payload через ``on_raw`` и обновите PyMax или добавьте - обработку нового формата. + Неизвестный ``_type`` обрабатывается через ``UnknownAttachment``, но + поврежденный или неполный payload уже известного типа все еще может не пройти + валидацию. Включите debug-логи, посмотрите raw payload через ``on_raw`` и + обновите PyMax или добавьте обработку нового формата. diff --git a/docs/release-2-4-1.rst b/docs/release-2-4-1.rst new file mode 100644 index 0000000..fb30ae6 --- /dev/null +++ b/docs/release-2-4-1.rst @@ -0,0 +1,103 @@ +PyMax 2.4.1 +=========== + +Версия 2.4.1 добавляет управляемую версию mobile runtime, одноразовое +подключение, отложенные сообщения, непостоянные сессии, обновление фотографии +группы и новые лимиты для upload и 2FA. Также расширены модели входящих сообщений +и исправлена отправка голосовых сообщений и кружков. + +Ниже перечислены пользовательские изменения относительно ``2.4.0``. + +Новый публичный API +------------------- + +* ``Client`` принимает ``app_version`` и ``VersionCatalog``. Встроенный + каталог содержит fingerprints до ``26.28.0``, рекомендуемая версия по + умолчанию - ``26.25.0``. ``VersionCatalog(remote=True)`` дополняет встроенные + данные с ``hashes.pymax.org``; неизвестная версия вызывает + ``VersionNotFoundError`` при запуске клиента. +* ``connect()`` выполняет один login-цикл, вызывает ``on_start`` и возвращает + управление с открытым соединением без reconnect loop. Состояние запущенного + соединения доступно через ``is_connected``. +* ``send_message()``, ``Message.answer()`` и ``Message.reply()`` принимают + ``send_at`` как ``datetime``, ``timedelta`` или Unix time в секундах. + ``None`` и ``0`` означают немедленную отправку; входящие сообщения содержат + необязательное ``delayed_attributes``. Соответствующий ``DateTimeUnion`` + экспортируется из ``pymax.api.messages``. +* ``change_group_profile()`` принимает необязательный ``Photo`` и автоматически + загружает его перед обновлением группы. ``name`` также стал необязательным; + поля со значением ``None`` не отправляются. +* ``ExtraConfig.upload_timeout`` задает общий timeout HTTP-загрузки ``File``, + ``Video``, ``Voice`` и ``VideoNote``; значение по умолчанию - 900 секунд, + а неположительное значение отключает общий timeout. +* ``ExtraConfig.password_max_attempts`` ограничивает попытки ввода пароля 2FA. + ``None`` означает отсутствие лимита, а исчерпание попыток вызывает + экспортируемый ``PasswordAttemptsExceededError``. +* ``ExtraConfig.relogin`` позволяет отключить автоматический сброс сессии и + повторную авторизацию после отзыва login token; по умолчанию поведение + включено. +* ``ExtraConfig.persist_session=False`` выбирает экспортируемый + ``InMemoryStore`` вместо SQLite или пользовательского ``store``. Такая сессия + теряется при пересоздании runtime, поэтому reconnect без заданного token снова + запускает авторизацию. +* ``Message.link`` содержит ``ReplyLink`` или ``ForwardLink`` с исходным + сообщением и чатом; forward также может содержать имя, ссылку, тип доступа и + иконку исходного чата. +* ``CallAttachment`` получил ``call_type`` и ``hangup_type``, а + ``ControlAttachment`` - необязательный ``title``. + +Изменения поведения +------------------- + +* Конфигурация и fingerprint ``Client`` теперь подготавливаются при + ``connect()``/``start()``. Выбранная версия определяет согласованные + app version, build number и fingerprint; подготовленная конфигурация и + сгенерированный device ID сохраняются между reconnect. +* TCP-клиент по умолчанию использует ``api2.oneme.ru`` и добавляет сертификат + Max в стандартный trust store как для прямого, так и для proxy TLS. + User-agent ``WebClient`` обновлен до web-версии ``26.8.4``. +* Автоматически создаваемые ``device_id`` и ``mt_instance_id`` теперь являются + строками из 16 шестнадцатеричных символов. Значения из существующей сессии + продолжают использоваться без замены. +* Сгенерированный device/user-agent теперь сохраняется в сессии и + восстанавливается до handshake. Характеристики устройства остаются + стабильными, но app version и build number берутся из текущей конфигурации; + явный ``ExtraConfig.user_agent`` имеет приоритет. Старые SQLite-сессии + мигрируют автоматически. + Пользовательским store рекомендуется сохранять новое необязательное поле + ``SessionInfo.user_agent``; значение ``None`` остается совместимым. +* ``Voice`` получил аргумент ``duration`` в миллисекундах. При ``None`` классы + ``Voice`` и ``VideoNote`` определяют длительность через необязательный extra + ``video``. +* ``File.size()``, ``Video.size()``, ``Voice.size()`` и ``VideoNote.size()`` + теперь следуют redirect при HEAD-запросе к URL-источнику. + +Исправления +----------- + +* Исправлены wire payload голосовых сообщений и кружков: длительность, + waveform и thumbhash передаются в ожидаемом Max формате. +* Если отправка или редактирование с ``Voice``/``VideoNote`` получает + ``attachment.not.ready``, PyMax ждет событие готовности до 60 секунд и + повторяет запрос один раз. +* ``VideoAttachment.preview_data`` теперь необязателен, поэтому входящее видео + без preview больше не вызывает ошибку валидации. +* ``ContactAttachment`` принимает payload без ``contact_id`` и возвращает для + этого поля ``None``. +* Консольный provider email-кода 2FA больше не блокирует asyncio event loop во + время ``input()``. + +Breaking changes и migration notes +------------------------------------ + +* ``add_reaction()``, ``get_reactions()`` и ``remove_reaction()`` теперь + принимают ID сообщений как ``int`` вместо ``str``. Ключи результата + ``get_reactions()`` остаются строками, как в ответе Max. +* ``ExtraConfig.generate_user_agent()`` теперь требует согласованные + ``app_version`` и ``build_number``. Коду, который вызывал метод напрямую, + нужно передать оба значения. +* ``VideoNote`` больше не является подклассом ``Video``. Если код использовал + ``isinstance(value, Video)``, проверяйте ``(Video, VideoNote)`` явно. +* Отправка ``Voice`` без ``duration`` теперь требует extra ``video`` для + автоматического определения длительности. Без extra передавайте длительность + в миллисекундах вручную. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 28d2e25..aac3a7b 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -83,9 +83,10 @@ Sync и чаты Pydantic validation error ------------------------- -Такое бывает, когда Max меняет payload или присылает вложение, которое текущая -версия PyMax не знает. Снимите raw payload через ``on_raw`` и не публикуйте -token, телефоны, приватные URL и ID без необходимости. +Неизвестный тип вложения сохраняется как ``UnknownAttachment``. Validation error +обычно означает, что Max изменил или прислал неполный payload уже известной +модели. Снимите raw payload через ``on_raw`` и не публикуйте token, телефоны, +приватные URL и ID без необходимости. UploadError ----------- diff --git a/docs/types/enums.rst b/docs/types/enums.rst index 03b480c..a99c4a4 100644 --- a/docs/types/enums.rst +++ b/docs/types/enums.rst @@ -13,6 +13,9 @@ Domain enums .. autoclass:: pymax.types.domain.enums.MessageStatus :members: +.. autoclass:: pymax.types.domain.enums.LinkType + :members: + .. autoclass:: pymax.types.domain.attachments.enums.AttachmentType :members: @@ -22,6 +25,12 @@ Domain enums .. autoclass:: pymax.types.domain.attachments.enums.PollFlags :members: +.. autoclass:: pymax.types.domain.attachments.enums.CallType + :members: + +.. autoclass:: pymax.types.domain.attachments.enums.HangupType + :members: + Chat API enums -------------- diff --git a/docs/types/index.rst b/docs/types/index.rst index b82f594..5352eb7 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -75,9 +75,10 @@ Bound и unbound объекты Pydantic validation error ------------------------- -Вложения распознаются по discriminator-полю ``_type``. Если Max прислал новый -тип или неполный payload, Pydantic может выбросить validation error. Для -диагностики включите ``DEBUG`` и посмотрите ``on_raw``. +Вложения распознаются по discriminator-полю ``_type``. Неизвестный тип +преобразуется в ``UnknownAttachment`` с исходными дополнительными полями. +Validation error по-прежнему возможен для поврежденного или неполного payload-а +уже известного типа; для диагностики включите ``DEBUG`` и посмотрите ``on_raw``. API reference ------------- diff --git a/pyproject.toml b/pyproject.toml index 3fec737..3f6de6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.4.0" +version = "2.4.1" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index a5a4395..7492240 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.4.0" +version = "2.4.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From eff532a9db9aba1f6741db7d9c6cf9d5277b6f28 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:06:12 +0300 Subject: [PATCH 10/13] chore: update release TODO tracker --- TODO.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index a9f60e4..40202d3 100644 --- a/TODO.md +++ b/TODO.md @@ -15,8 +15,8 @@ Format: ## Bugs -- [ ] Исправить голосовые/кружки. `api/upload/service.py`. P: 100 -- [ ] Исправить генерацию device_id. `src/pymax/base.py:106`. P: 80 +- [X] Исправить голосовые/кружки. `api/upload/service.py`. P: 100 +- [X] Исправить генерацию device_id. `src/pymax/base.py:111`. P: 80 ## Features @@ -29,9 +29,11 @@ Format: - [ ] src/pymax/api/chats/payloads.py:100: ENUMM!!! - [ ] src/pymax/api/chats/payloads.py:123: ENUMM!!! - [ ] src/pymax/api/messages/payloads.py:32: enum? -- [ ] src/pymax/api/uploads/service.py:66: ENUM!!!! +- [ ] src/pymax/api/messages/service.py:94: TypeAlias +- [ ] src/pymax/api/uploads/service.py:68: ENUM!!!! +- [ ] src/pymax/api/uploads/service.py:286: Error handing (yes, with 200 status 💔) - [ ] src/pymax/api/users/service.py:133: maybe also return phone mapping? -- [ ] src/pymax/base.py:222: maybe impl it better way +- [ ] src/pymax/base.py:226: maybe impl it better way - [ ] src/pymax/config.py:52: delete maybe - [ ] src/pymax/dispatch/dispatcher.py:190: create iter_on_start_handlers - [ ] src/pymax/protocol/tcp/payload.py:34: deprecate? idk From fde4f4efbf96f5fbd582efb3714b7211ae72b356 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:09:40 +0300 Subject: [PATCH 11/13] test: update fixtures for 2.4.1 runtime --- tests/api/test_chat_user_self_session_services.py | 12 ++++++++++-- tests/api/test_upload_service.py | 13 ++++++------- tests/auth/test_auth_flows.py | 5 ++++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 6d47a2b..ebf1b32 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -131,7 +131,11 @@ async def test_chat_mixin_delete_chat_uses_last_event_time_and_removes_cached_ch class Client(ChatMixin): def __init__(self, app: FakeApp) -> None: - self._app = app + self.__app = app + + @property + def _app(self) -> FakeApp: + return self.__app app = FakeApp([frame({})]) chat = Chat.model_validate( @@ -314,7 +318,11 @@ async def test_user_mixin_import_contacts_delegates_to_user_service() -> None: class Client(UserMixin): def __init__(self, app: FakeApp) -> None: - self._app = app + self.__app = app + + @property + def _app(self) -> FakeApp: + return self.__app app = FakeApp([frame({"contacts": [user_payload(7)]})]) client = Client(app) diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py index 827863e..1c1afdf 100644 --- a/tests/api/test_upload_service.py +++ b/tests/api/test_upload_service.py @@ -145,7 +145,7 @@ def resolve_processing() -> None: @pytest.mark.asyncio -async def test_upload_voice_posts_chunks_waits_for_processing_and_cleans_waiter( +async def test_upload_voice_posts_chunks_and_builds_attachment_payload( monkeypatch: pytest.MonkeyPatch, ) -> None: app = FakeApp( @@ -165,21 +165,20 @@ async def test_upload_voice_posts_chunks_waits_for_processing_and_cleans_waiter( ) service = UploadService(app) - def resolve_processing() -> None: - service.voice_upload_waiters[12].set_result(AudioUploadSignal(audio_id=12)) - FakeHttpSession.posts = [] - FakeHttpSession.response = FakeHttpResponse(200, on_enter=resolve_processing) + FakeHttpSession.response = FakeHttpResponse(200) monkeypatch.setattr( "pymax.api.uploads.service.aiohttp.ClientSession", FakeHttpSession, ) - result = await service.upload_voice(Voice(raw=b"voice", name="voice.ogg")) + result = await service.upload_voice(Voice(raw=b"voice", name="voice.ogg", duration=1_000)) assert result.type == AttachmentType.AUDIO assert result.video_id == 12 assert result.token == "voice-token" + assert result.duration == 1_000 + assert result.wave == b"\x00" * 80 assert app.calls[0].payload == { "count": 1, "type": 2, @@ -187,7 +186,7 @@ def resolve_processing() -> None: "profile": False, } assert service.voice_upload_waiters == {} - assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-4/5" + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "bytes 0-4/5" assert FakeHttpSession.posts[0]["url"] == "https://upload.test/voice" diff --git a/tests/auth/test_auth_flows.py b/tests/auth/test_auth_flows.py index 96f29ce..f39017c 100644 --- a/tests/auth/test_auth_flows.py +++ b/tests/auth/test_auth_flows.py @@ -70,7 +70,10 @@ async def check_password(self, track_id: str, password: str): async def test_sms_auth_flow_requests_code_and_completes_2fa() -> None: auth_api = SmsAuthApi() app = SimpleNamespace( - config=SimpleNamespace(phone="+79990000000"), + config=SimpleNamespace( + phone="+79990000000", + password_max_attempts=None, + ), api=SimpleNamespace(auth=auth_api), ) code_provider = StaticCodeProvider() From 74daf9456268c1dfe511a3f32bd5505fbe15caa4 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:04:27 +0300 Subject: [PATCH 12/13] fix: lint --- src/pymax/api/messages/service.py | 2 -- src/pymax/api/uploads/service.py | 7 ++++++- src/pymax/files/video.py | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index dcbe7d6..1543fa2 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -35,8 +35,6 @@ ReadState, VideoRequest, ) -from pymax.types.events.video import VideoUploadSignal -from pymax.types.events.voice import AudioUploadSignal from .enums import ItemType, MessagePayloadKey, ReadAction from .payloads import ( diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index 573320c..24cdb54 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -232,7 +232,12 @@ async def upload_voice(self, voice: Voice) -> VoiceAttachPayload: # ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"), # OKMessages/{app_version} ({os_version}; {device_name}; {screen}) - user_agent = f"OKMessages/{self.app.config.app_version} ({self.app.config.device.user_agent.os_version}; {self.app.config.device.user_agent.device_name}; {self.app.config.device.user_agent.screen})" + user_agent = ( + f"OKMessages/{self.app.config.app_version}" + + f" ({self.app.config.device.user_agent.os_version};" + + f" {self.app.config.device.user_agent.device_name};" + + f" {self.app.config.device.user_agent.screen})" + ) headers = { "Content-Disposition": f"attachment; filename={quote(voice.name)}", diff --git a/src/pymax/files/video.py b/src/pymax/files/video.py index 78c207e..252a5b4 100644 --- a/src/pymax/files/video.py +++ b/src/pymax/files/video.py @@ -1,6 +1,5 @@ from collections.abc import AsyncGenerator from pathlib import Path -from typing import Any from .base import BaseFile, TimedMediaFile From defb939a4f861145e5bc7aa02176ab08baeeb7d5 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:41:48 +0300 Subject: [PATCH 13/13] fix: some minor fixes --- src/pymax/base.py | 12 +++++++++--- src/pymax/versions/catalog.py | 2 +- src/pymax/versions/exceptions.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pymax/base.py b/src/pymax/base.py index ddcf3b8..cb7393f 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -81,8 +81,7 @@ def messages(self) -> dict[int, list[Message]] | None: @property def is_connected(self) -> bool: - """Показывает, открыто ли соединение запущенного runtime.""" - return self._app.connection.is_open + return self.__app is not None and self.__app.connection.is_open def _build_config( self, @@ -136,6 +135,10 @@ async def _prepare_config(self) -> ClientConfig: async def _ensure_runtime(self: ClientT) -> None: # noqa: PYI019 if getattr(self, "_config", None) is None: self._config = await self._prepare_config() + + if self.__app is not None and self.__app.connection.is_open: + return + self._connection = self._build_connection() self._app = self._build_app() @@ -246,7 +249,10 @@ async def start(self: ClientT) -> None: # noqa: PYI019 async def close(self) -> None: """Закрывает соединение, фоновые задачи и файл сессии.""" - await self._app.close() + if self.__app is None: + return + + await self.__app.close() async def stop(self) -> None: """Останавливает клиента.""" diff --git a/src/pymax/versions/catalog.py b/src/pymax/versions/catalog.py index 2dd6857..dddb6bc 100644 --- a/src/pymax/versions/catalog.py +++ b/src/pymax/versions/catalog.py @@ -81,8 +81,8 @@ async def _fetch(self) -> dict[str, ApkBuildFingerprint]: aiohttp.ClientSession() as session, session.get(self.DEFAULT_REMOTE_URL) as response, ): - data = await response.json() response.raise_for_status() + data = await response.json() if not isinstance(data, dict): raise ValueError # TODO: msg diff --git a/src/pymax/versions/exceptions.py b/src/pymax/versions/exceptions.py index d728add..125f919 100644 --- a/src/pymax/versions/exceptions.py +++ b/src/pymax/versions/exceptions.py @@ -2,7 +2,7 @@ class VersionNotFoundError(ValueError): """Выбранная версия приложения отсутствует в каталоге fingerprints.""" def __init__(self, version: str) -> None: - super().__init__(f"Could not found version {version} in registry") + super().__init__(f"Could not find version {version} in registry") class VersionAlreadyExistsError(ValueError):