From 67896d0f7f9e021d33fd3fd9767427faad87f7e1 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:28:53 +0300 Subject: [PATCH 01/35] fix: support binary WebSocket protocol --- src/pymax/client_web.py | 4 +-- src/pymax/config.py | 2 +- src/pymax/protocol/tcp/payload.py | 22 +++++++++++++++- src/pymax/transport/websocket.py | 2 +- .../connection/test_readers_and_transports.py | 3 +++ tests/protocol/test_protocols.py | 26 +++++++++++++++++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index dfe9c3e..77cc61e 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -6,7 +6,7 @@ from pymax.connection import ConnectionManager from pymax.connection.readers import WSReader from pymax.logging import configure_logging, get_logger -from pymax.protocol.ws import WsProtocol +from pymax.protocol.tcp import TcpProtocol from pymax.transport.websocket import WebSocketTransport from .base import BaseClient @@ -80,5 +80,5 @@ def _build_connection(self) -> ConnectionManager: return ConnectionManager( reader=reader, transport=transport, - protocol=WsProtocol(), + protocol=TcpProtocol(), ) diff --git a/src/pymax/config.py b/src/pymax/config.py index f4bab88..c212bdf 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -176,7 +176,7 @@ class ExtraConfig(BaseModel): host: str = "api.oneme.ru" port: int = 443 - url: str = "wss://ws-api.oneme.ru/websocket" + url: str = "wss://api.oneme.ru/websocket" use_ssl: bool = True proxy: str | None = None reconnect: bool = True diff --git a/src/pymax/protocol/tcp/payload.py b/src/pymax/protocol/tcp/payload.py index 3d94012..175d1aa 100644 --- a/src/pymax/protocol/tcp/payload.py +++ b/src/pymax/protocol/tcp/payload.py @@ -11,6 +11,8 @@ class MsgpackPayloadCodec: + WRAPPED_VALUE_EXT_CODE = 1 + def _to_msgpack_value(self, value: Any) -> Any: if isinstance(value, Enum): return value.value @@ -30,10 +32,25 @@ def encode(self, payload: object) -> bytes: def _unpack_stream( self, payload_bytes: bytes, *, raw: bool ) -> list[Any]: # TODO: deprecate? idk - unpacker = msgpack.Unpacker(raw=raw, strict_map_key=False) + unpacker = msgpack.Unpacker( + raw=raw, + strict_map_key=False, + ext_hook=self._decode_ext, + ) unpacker.feed(payload_bytes) return list(unpacker) + def _decode_ext(self, code: int, data: bytes) -> Any: + if code != self.WRAPPED_VALUE_EXT_CODE: + return msgpack.ExtType(code, data) + + return msgpack.unpackb( + data, + raw=False, + strict_map_key=False, + ext_hook=self._decode_ext, + ) + def decode(self, payload_bytes: bytes) -> Any: if not payload_bytes: return {} @@ -43,6 +60,7 @@ def decode(self, payload_bytes: bytes) -> Any: payload_bytes, raw=False, strict_map_key=False, + ext_hook=self._decode_ext, ) except msgpack.exceptions.ExtraData as e: @@ -81,6 +99,8 @@ def _normalize_keys(self, obj: Any) -> Any: return {self._normalize_key(k): self._normalize_keys(v) for k, v in obj.items()} if isinstance(obj, list): return [self._normalize_keys(item) for item in obj] + if isinstance(obj, msgpack.ExtType): + return obj if isinstance(obj, tuple): return tuple(self._normalize_keys(item) for item in obj) return obj diff --git a/src/pymax/transport/websocket.py b/src/pymax/transport/websocket.py index cab0f29..776564c 100644 --- a/src/pymax/transport/websocket.py +++ b/src/pymax/transport/websocket.py @@ -41,7 +41,7 @@ async def recv(self, n: int | None = None) -> bytes | str: if self.ws is None or not self.connected: raise ConnectionError("Not connected to the server") - return await self.ws.recv(decode=True) + return await self.ws.recv(decode=False) @property def connected(self) -> bool: diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 62153d3..1767bb5 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -150,11 +150,13 @@ def __init__(self) -> None: self.close_code = None self.sent: list[bytes | str] = [] self.closed = False + self.recv_decode: bool | None = None async def send(self, data: bytes | str) -> None: self.sent.append(data) async def recv(self, decode=True): + self.recv_decode = decode return "incoming" async def close(self) -> None: @@ -184,4 +186,5 @@ async def connect(*args, **kwargs): assert ws.sent == ["hello"] assert incoming == "incoming" + assert ws.recv_decode is False assert transport.connected is False diff --git a/tests/protocol/test_protocols.py b/tests/protocol/test_protocols.py index 76ae14c..645ba69 100644 --- a/tests/protocol/test_protocols.py +++ b/tests/protocol/test_protocols.py @@ -129,6 +129,32 @@ def test_msgpack_codec_uses_first_dict_when_stream_has_extra_data() -> None: assert codec.decode(encoded) == {"ok": True} +def test_msgpack_codec_decodes_wrapped_value_extension() -> None: + codec = MsgpackPayloadCodec() + expected = { + "expiresAt": 1783264954296, + "pollingInterval": 5000, + "ttl": 119992, + } + encoded = msgpack.packb( + {key: msgpack.ExtType(1, msgpack.packb(value)) for key, value in expected.items()}, + use_bin_type=True, + ) + + assert codec.decode(encoded) == expected + + +def test_tcp_payload_decoder_preserves_unknown_extensions() -> None: + codec = MsgpackPayloadCodec() + extension = msgpack.ExtType(42, b"unknown") + decoder = TcpPayloadDecoder(serializer=codec) + + decoded = decoder.decode(msgpack.packb({"extension": extension}, use_bin_type=True)) + + assert decoded["extension"] == extension + assert isinstance(decoded["extension"], msgpack.ExtType) + + def test_tcp_payload_decoder_decompresses_lz4_for_compression_factor_four() -> None: # This is a raw LZ4 block produced by the official-compatible compressor. # Its first byte is 0xF4, which MsgPack reads as -12 when decompression is From 710ed847b38b34fcda88db259c4b48bee2a5a347 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:49:34 +0300 Subject: [PATCH 02/35] fix: parse video URLs by MP4 quality --- src/pymax/types/domain/attachments/video.py | 35 ++++++++++++++++----- tests/api/test_message_service.py | 27 ++++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/pymax/types/domain/attachments/video.py b/src/pymax/types/domain/attachments/video.py index f1c33a7..8e89c13 100644 --- a/src/pymax/types/domain/attachments/video.py +++ b/src/pymax/types/domain/attachments/video.py @@ -62,18 +62,18 @@ class VideoRequest(CamelModel): :vartype external: str | bool | None :ivar cache: Использовать ли кеш. :vartype cache: bool - :ivar url: URL видео. - :vartype url: str + :ivar url: Прямой URL видео или ``None`` для внешнего видео. + :vartype url: str | None """ external: str | bool | None = Field(default=None, alias="EXTERNAL") cache: bool - url: str + url: str | None = None @model_validator(mode="before") @classmethod - def unwrap_dynamic_url(cls, value: Any) -> Any: - """Нормализует динамический ключ URL в поле ``url``. + def select_video_url(cls, value: Any) -> Any: + """Выбирает прямой URL с максимальным доступным MP4-качеством. :param value: Значение, переданное в валидатор модели. :type value: Any @@ -83,8 +83,29 @@ def unwrap_dynamic_url(cls, value: Any) -> Any: if not isinstance(value, dict) or "url" in value: return value + mp4_urls: list[tuple[int, str]] = [] for key, url in value.items(): - if key not in ("EXTERNAL", "cache"): - return {**value, "url": url} + if not isinstance(key, str) or not isinstance(url, str): + continue + + normalized_key = key.upper() + if not normalized_key.startswith("MP4_"): + continue + + try: + quality = int(normalized_key.removeprefix("MP4_")) + except ValueError: + continue + + if quality > 0: + mp4_urls.append((quality, url)) + + if mp4_urls: + _, url = max(mp4_urls, key=lambda item: item[0]) + return {**value, "url": url} + + legacy_url = value.get("dynamicUrl", value.get("dynamic_url")) + if isinstance(legacy_url, str): + return {**value, "url": legacy_url} return value diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 019ee25..3241f12 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -7,6 +7,7 @@ from pymax.exceptions import UploadError from pymax.files import File, Photo, Video from pymax.protocol import Opcode +from pymax.types.domain.attachments import VideoRequest from tests.conftest import FakeApp, frame, message_payload @@ -372,7 +373,15 @@ async def test_reaction_methods_parse_optional_reaction_info() -> None: async def test_get_video_and_file_by_id_parse_request_models() -> None: app = FakeApp( [ - frame({"cache": True, "dynamicUrl": "https://video.test"}), + frame( + { + "cache": True, + "FAILOVER_HOSTS": ["maxvd759.okcdn.ru"], + "MP4_480": "https://video.test/480", + "EXTERNAL": "https://m.ok.ru/video/1", + "MP4_720": "https://video.test/720", + } + ), frame({"unsafe": False, "url": "https://file.test"}), ] ) @@ -381,7 +390,8 @@ async def test_get_video_and_file_by_id_parse_request_models() -> None: file = await app.api.messages.get_file_by_id(100, "10", 30) assert video is not None - assert video.url == "https://video.test" + assert video.url == "https://video.test/720" + assert video.external == "https://m.ok.ru/video/1" assert file is not None assert file.url == "https://file.test" assert [call.opcode for call in app.calls] == [ @@ -400,6 +410,19 @@ async def test_get_video_and_file_by_id_parse_request_models() -> None: } +def test_video_request_supports_legacy_and_external_only_payloads() -> None: + legacy = VideoRequest.model_validate( + {"cache": True, "dynamicUrl": "https://video.test/legacy"} + ) + external = VideoRequest.model_validate( + {"cache": True, "EXTERNAL": "https://m.ok.ru/video/1"} + ) + + assert legacy.url == "https://video.test/legacy" + assert external.url is None + assert external.external == "https://m.ok.ru/video/1" + + def test_next_cid_is_monotonic_when_clock_does_not_move( monkeypatch: pytest.MonkeyPatch, ) -> None: From 7cf3671ffdb120db2929b814f990a5085c303a14 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:59:49 +0300 Subject: [PATCH 03/35] fix: ReactionUpdateEvent optional fields --- src/pymax/types/events/reaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymax/types/events/reaction.py b/src/pymax/types/events/reaction.py index 964729a..496c7ef 100644 --- a/src/pymax/types/events/reaction.py +++ b/src/pymax/types/events/reaction.py @@ -17,5 +17,5 @@ class ReactionUpdateEvent(CamelModel): message_id: str chat_id: int - counters: list[ReactionCounter] - total_count: int + counters: list[ReactionCounter] | None + total_count: int = 0 From edf68d34027aefa7d492f47df15d1a22f2788781 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:46:18 +0300 Subject: [PATCH 04/35] feat: add device fingerprinting to authentication --- src/pymax/_data/apk_fingerprints.json | 529 ++++++++++++++++++ src/pymax/api/auth/payloads.py | 7 +- src/pymax/api/auth/service.py | 41 +- src/pymax/api/session/service.py | 29 +- src/pymax/app.py | 14 +- src/pymax/config.py | 27 +- src/pymax/fingerprint/__init__.py | 1 + src/pymax/fingerprint/fingerprint.py | 47 ++ src/pymax/fingerprint/models.py | 11 + src/pymax/types/domain/__init__.py | 1 + src/pymax/types/domain/handshake.py | 11 + tests/api/test_auth_service.py | 12 + .../test_chat_user_self_session_services.py | 11 +- tests/api/test_message_service.py | 4 +- tests/app/test_app_runtime.py | 10 +- tests/conftest.py | 4 + 16 files changed, 728 insertions(+), 31 deletions(-) create mode 100644 src/pymax/_data/apk_fingerprints.json create mode 100644 src/pymax/fingerprint/__init__.py create mode 100644 src/pymax/fingerprint/fingerprint.py create mode 100644 src/pymax/fingerprint/models.py create mode 100644 src/pymax/types/domain/handshake.py diff --git a/src/pymax/_data/apk_fingerprints.json b/src/pymax/_data/apk_fingerprints.json new file mode 100644 index 0000000..04a9bcb --- /dev/null +++ b/src/pymax/_data/apk_fingerprints.json @@ -0,0 +1,529 @@ +{ + "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, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "40ce6cc7fdd85631210bc2096727f20a12a01da416cfb52b3eb0c3f2585a7367", + "so_meta_sha256_arm64_v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "so_meta_sha256": { + "arm64-v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "armeabi-v7a": "93c05715a6d63eeec9485f02e38c55b53d5ec5e1ddcbbd987e211bb308322fcc", + "x86": "7677e418a5a2558d1ed8b5fb19f6f7099289ba15b93cf732be098882c06b83e8", + "x86_64": "cbfec115b7a7c19b6a9fd36ec1f066cac829e783f8ceb0ba3314cd8abeb8159d" + }, + "build_number": 6648 + }, + "26.10.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "8e36caf5dbfc4988a8d4a0e758651b40c50b7fdf3a5accb9f3d31ec36bad57a6", + "so_meta_sha256_arm64_v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "so_meta_sha256": { + "arm64-v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "armeabi-v7a": "93c05715a6d63eeec9485f02e38c55b53d5ec5e1ddcbbd987e211bb308322fcc", + "x86": "7677e418a5a2558d1ed8b5fb19f6f7099289ba15b93cf732be098882c06b83e8", + "x86_64": "cbfec115b7a7c19b6a9fd36ec1f066cac829e783f8ceb0ba3314cd8abeb8159d" + }, + "build_number": 6653 + }, + "26.11.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "17e5ebaa23b199c6b8efae0d8d95e162d7c682d9e80f68959af36a7e3fc1bd35", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6665 + }, + "26.11.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "67fe33fefaed5e8395bb3238a75f5d62f8601676d9edad18fd8b8f52844f14a6", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6669 + }, + "26.11.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "63fcf3e2e6469b071ab968da239d34628277e990c57f3f83ace078dfe44baf52", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6670 + }, + "26.12.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "f04895bdcfb986071501d5723fa90d52f2c7e50a41c49b47796843307003e46a", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6676 + }, + "26.12.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "c9e5bd819ad44fedab72ffb096303a8ca106a26734eb8f9012384ec4b3ed8828", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6679 + }, + "26.12.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "bc2d8fb636db050a79751d4894c57ccb9ac72d2d98777aabd7af39b43e4150da", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6681 + }, + "26.13.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d07bf9979006300cc81a1e8db07138c9f67dd485e95da8a1793844586dec47f9", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6683 + }, + "26.14.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "ac3a88bd9a3f8a0c5448d425ac71e55c7c3605f9e6d318ef59ce83680c911973", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6685 + }, + "26.14.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "19ef26ac2d0e081ad7f75a8b71a7f1bb8211c0ff7fedca1e029b883039c09523", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6686 + }, + "26.15.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d731008f3040c26f57a3712551ad802860df8c9b58d40d7dd5a932e0e3becc83", + "so_meta_sha256_arm64_v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "so_meta_sha256": { + "arm64-v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "armeabi-v7a": "91ac4177f3c199c18037bb7f41ac9d5e38b07f4bdbbcaec3e75f09e2eb10424e", + "x86": "b5cccece8fa721d810d5eeec01c54ad1c9e53d9f08004aa4fed7143a1a81be51", + "x86_64": "62e7d25a401d85190e3f0be6880e22c89c7c5cbd9d54c3c12ff02762225c354b" + }, + "build_number": 6689 + }, + "26.15.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "2554fc75f0071c664b26ccc00d3dbe0da5a4e88cfa60b6489ddecda6ab7f9546", + "so_meta_sha256_arm64_v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "so_meta_sha256": { + "arm64-v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "armeabi-v7a": "91ac4177f3c199c18037bb7f41ac9d5e38b07f4bdbbcaec3e75f09e2eb10424e", + "x86": "b5cccece8fa721d810d5eeec01c54ad1c9e53d9f08004aa4fed7143a1a81be51", + "x86_64": "62e7d25a401d85190e3f0be6880e22c89c7c5cbd9d54c3c12ff02762225c354b" + }, + "build_number": 6690 + }, + "26.15.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "54fc7e9d3c650a71959b3f33a15f1fe5b1e400554a52eb69f5ad11bc728a8131", + "so_meta_sha256_arm64_v8a": "50587953906a4f339caa16efe6e7899ee7793867b1acbe2c070a6af8b82003d8", + "so_meta_sha256": { + "arm64-v8a": "50587953906a4f339caa16efe6e7899ee7793867b1acbe2c070a6af8b82003d8", + "armeabi-v7a": "e04e120ccf068113d17a74b257c6c99b74fb2b27c353dfb680788918e4d1dae3", + "x86": "c80ad2ca78112f4de0a433ac00f9d649a9ea54d19e708afd383bf3aafb6b738c", + "x86_64": "6ff5f113c2e1629066a6f0c711d03421c30f9fc9f21460925bdc9784dc35cc35" + }, + "build_number": 6695 + }, + "26.16.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "83087537a8f09362f12f3349cd7d706071cf6ab05936bf16ea1feb25137c2b87", + "so_meta_sha256_arm64_v8a": "516c645f72b401bfd7f87cd569f729829d693994aa42e48e3a3577b956d1a7cf", + "so_meta_sha256": { + "arm64-v8a": "516c645f72b401bfd7f87cd569f729829d693994aa42e48e3a3577b956d1a7cf", + "armeabi-v7a": "444c24284fbdc8b30c76db93701c4bed66228a988076004dbd2c28c1b5d7d518", + "x86": "8a1311f525d0bfe194c2fb035fa5a31a13938eee336c5b10f9b2471db9b6a7a7", + "x86_64": "6743a193eed8f1fa278088e30ac05e5a7904f7b9085dd7863fecd4516551912d" + }, + "build_number": 6698 + }, + "26.16.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "488def68d46ec1fd2619e32f660b3952a2e66d20b83ea278f329aa9f0d26ede4", + "so_meta_sha256_arm64_v8a": "523ab358b5dbc6a2da414628e3db3c2d58b8609b16d3bd58bd85d302735fd11d", + "so_meta_sha256": { + "arm64-v8a": "523ab358b5dbc6a2da414628e3db3c2d58b8609b16d3bd58bd85d302735fd11d", + "armeabi-v7a": "b557e40be72405eaced69a68ac1933d116e03e0566a0680a8fe63fb80befb453", + "x86": "7719bedd257bcb7a11086e6a5103331fb4b09f0ec31362e6629b941b136cde22", + "x86_64": "2e70f23f1647d9eb2102eda444f16af19f2c8a2ddb31d2d83ce5a5d796136069" + }, + "build_number": 6700 + }, + "26.16.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "6a10808cad3df516fbcc9104489aeec66ec44f97adfe738e4b8850c3b726ce65", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6701 + }, + "26.16.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "4ffe56a6ce29fd969d0914f82dbb0d6c27f0ddc562ef260a366009dc291b8522", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6702 + }, + "26.16.4": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "70efe2238d390938c35b37f9308403a5f9c80eb567b621f813114b99cc6af51e", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6704 + }, + "26.17.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6712 + }, + "26.18.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "f83628293b3a049e8d1d52e7c4d5ae1f9d82657fe79b41c6ee825383b7380c75", + "so_meta_sha256_arm64_v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "so_meta_sha256": { + "arm64-v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "armeabi-v7a": "3de2820ce703cca5cf3bf84cbcc54a68335f0613e6f2dffa869516f8ddb0be27", + "x86": "b5285b7d54cdadc30b9ebaf5c03e2be837961dbad306235ec845be4c949ed658", + "x86_64": "8cdb44f669a2a85894b9efa15d06532e11f630fce0e8a8ca7de9f45deba3f983" + }, + "build_number": 6715 + }, + "26.18.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "5cf3b57eaf67a647fc3276cc889f931e8e4e2a8a31a8f021649a071878dd55ba", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6716 + }, + "26.18.2": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "35fea4a39ea9ac47ec55232b6fcd365edb3755465f260c0b3f70fe18e63a401a", + "so_meta_sha256_arm64_v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "so_meta_sha256": { + "arm64-v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "armeabi-v7a": "3de2820ce703cca5cf3bf84cbcc54a68335f0613e6f2dffa869516f8ddb0be27", + "x86": "b5285b7d54cdadc30b9ebaf5c03e2be837961dbad306235ec845be4c949ed658", + "x86_64": "8cdb44f669a2a85894b9efa15d06532e11f630fce0e8a8ca7de9f45deba3f983" + }, + "build_number": 6720 + }, + "26.18.4": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "bf5c685810d20e9dde60d142169329f1fafbdc0d4b64de853bf1b45f4e36250c", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6724 + }, + "26.19.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "e9e3e7435850b0081d8200bb29e4d755bbd4b22e713048b2ebf952092220c1da", + "so_meta_sha256_arm64_v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "so_meta_sha256": { + "arm64-v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "armeabi-v7a": "0aae3ded3151f83dfd59bca3058bcb155798cf72895a52ca629d3dd4646183d5", + "x86": "34d2583be4b2161709ab0511a6834e9d9503093a7b43e84c42334e53308576b6", + "x86_64": "3b55db2a5780e6808092fe5ac228933e1a4eadc3102018ec8dea1575f4748a8e" + }, + "build_number": 6727 + }, + "26.19.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "0fefb0ece6b4d59f0b7a25ccbc403dfa1492e73b13b3f94d80f585a2d21f7de4", + "so_meta_sha256_arm64_v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "so_meta_sha256": { + "arm64-v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "armeabi-v7a": "0aae3ded3151f83dfd59bca3058bcb155798cf72895a52ca629d3dd4646183d5", + "x86": "34d2583be4b2161709ab0511a6834e9d9503093a7b43e84c42334e53308576b6", + "x86_64": "3b55db2a5780e6808092fe5ac228933e1a4eadc3102018ec8dea1575f4748a8e" + }, + "build_number": 6729 + }, + "26.19.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d590910db09464e19553e22c184c135ace8c6cb6d4407920f949d561724fb8fe", + "so_meta_sha256_arm64_v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "so_meta_sha256": { + "arm64-v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "armeabi-v7a": "0a7747106ea8b797fb0b8093b1701b08cebb5573c1a952f42f684cd818abf3e5", + "x86": "d4ee5fde610e036dbedfe51da6d900c4fd348f291658a95350d59d6e1f8e2ff5", + "x86_64": "e12ac08d99994274ade0d7f7ce2ec39c73c11ef8e8bb51e4961a26fe8f8fb8ad" + }, + "build_number": 6732 + }, + "26.19.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "853ab9946ed47dedc407018d8934ae977f39b960cd41e2b3773fc0b841deed1b", + "so_meta_sha256_arm64_v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "so_meta_sha256": { + "arm64-v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "armeabi-v7a": "0a7747106ea8b797fb0b8093b1701b08cebb5573c1a952f42f684cd818abf3e5", + "x86": "d4ee5fde610e036dbedfe51da6d900c4fd348f291658a95350d59d6e1f8e2ff5", + "x86_64": "e12ac08d99994274ade0d7f7ce2ec39c73c11ef8e8bb51e4961a26fe8f8fb8ad" + }, + "build_number": 6734 + }, + "26.20.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "ff5014aa2cf21e07616a7a72d89712bb9a2c06219f95b5526757dcdb2ce3609a", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6740 + }, + "26.20.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "0a6265f6e5d8231b9cba641f8c40475e6f3baeb06ed41b804b9bf7307aa4214e", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6758 + } +} diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index d17f644..8004e04 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -10,12 +10,13 @@ class RequestCodePayload(CamelModel): phone: str type: AuthType = AuthType.START_AUTH - language: str = "ru" + mode: bytes | None class SendCodePayload(CamelModel): token: str verify_code: str + auth_token_type: AuthType = AuthType.CHECK_CODE @@ -54,7 +55,7 @@ def from_sync_state( class SyncPayload(CamelModel): user_agent: MobileUserAgentPayload token: str - chat_hash_fingerprint: str | None = None + chat_cache_fingerprint: bytes | None = None chats_count: int | None = None chats_sync: int = -1 contacts_sync: int = -1 @@ -70,10 +71,12 @@ def from_sync_state( user_agent: MobileUserAgentPayload, token: str, sync: SyncState, + chat_cache_fingerprint: bytes | None = None, ) -> "SyncPayload": return cls( user_agent=user_agent, token=token, + chat_cache_fingerprint=chat_cache_fingerprint, chats_sync=sync.chats_sync, contacts_sync=sync.contacts_sync, drafts_sync=sync.drafts_sync, diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index af92e24..7beaa5f 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -58,7 +58,23 @@ def __init__(self, app: App) -> None: async def request_code(self, phone: str) -> StartAuthResponse: logger.info("requesting sms code phone_set=%s", bool(phone)) - frame = RequestCodePayload(phone=phone) + + if not self.app.handshake_response: + logger.error("request_code requested without handshake response") + raise RuntimeError("No handshake response available for request_code") + + device_id = ( + self.app.session.device_id if self.app.session else self.app.config.device.device_id + ) + + 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", + ) + + frame = RequestCodePayload(phone=phone, mode=mode) response = await self.app.invoke(Opcode.AUTH_REQUEST, frame.to_payload()) logger.debug( "sms code request accepted payload_keys=%s", @@ -72,7 +88,11 @@ async def send_code(self, token: str, verify_code: str) -> CheckCodeResponse: bool(token), bool(verify_code), ) - frame = SendCodePayload(token=token, verify_code=verify_code) + + frame = SendCodePayload( + token=token, + verify_code=verify_code, + ) response = await self.app.invoke(Opcode.AUTH, frame.to_payload()) logger.debug( "sms code response payload_keys=%s", @@ -117,11 +137,28 @@ async def mobile_login(self) -> LoginResponse: raise RuntimeError("No session available for login") logger.info("logging in") + + if not self.app.handshake_response: + logger.error("login requested without handshake response") + raise RuntimeError("No handshake response available for login") + + device_id = ( + self.app.session.device_id if self.app.session else self.app.config.device.device_id + ) + + 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", + ) + sync = self.app.config.sync.resolve(session.sync) frame = SyncPayload.from_sync_state( user_agent=self.app.config.device.user_agent, token=session.token, sync=sync, + chat_cache_fingerprint=ccf, ) response = await self.app.invoke(Opcode.LOGIN, frame.to_payload()) diff --git a/src/pymax/api/session/service.py b/src/pymax/api/session/service.py index 626dbff..74e7c9f 100644 --- a/src/pymax/api/session/service.py +++ b/src/pymax/api/session/service.py @@ -2,8 +2,10 @@ from typing import TYPE_CHECKING +from pymax.api.response import require_payload_model from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types.domain import HandshakeResponse from .enums import DeviceType from .payloads import ( @@ -28,19 +30,18 @@ async def handshake( mt_instance_id: str, user_agent: MobileUserAgentPayload, device_id: str, - ) -> None: + ) -> HandshakeResponse: if user_agent.device_type == DeviceType.WEB: - await self.web_handshake(user_agent, device_id) - return + return await self.web_handshake(user_agent, device_id) - await self.mobile_handshake(mt_instance_id, user_agent, device_id) + return await self.mobile_handshake(mt_instance_id, user_agent, device_id) async def mobile_handshake( self, mt_instance_id: str, user_agent: MobileUserAgentPayload, device_id: str, - ) -> None: + ) -> HandshakeResponse: logger.debug( "mobile handshake mt_instance_id_set=%s device_id=%s app_version=%s", bool(mt_instance_id), @@ -52,10 +53,17 @@ async def mobile_handshake( user_agent=user_agent, device_id=device_id, ) - await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) + response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) logger.info("mobile handshake completed") - async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str) -> None: + return require_payload_model( + response, + HandshakeResponse, + ) + + async def web_handshake( + self, user_agent: MobileUserAgentPayload, device_id: str + ) -> HandshakeResponse: logger.debug( "web handshake device_id=%s app_version=%s browser=%s", device_id, @@ -66,5 +74,10 @@ async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str user_agent=user_agent, device_id=device_id, ) - await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) + response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) logger.info("web handshake completed") + + return require_payload_model( + response, + HandshakeResponse, + ) diff --git a/src/pymax/app.py b/src/pymax/app.py index a968e7e..d96622a 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -8,6 +8,7 @@ from pymax.dispatch import Dispatcher from pymax.dispatch.router import EventType, Router from pymax.exceptions import ApiError +from pymax.fingerprint import FingerprintGenerator from pymax.logging import get_logger from pymax.protocol import Command, InboundFrame, OutboundFrame from pymax.protocol.enums import Opcode @@ -15,7 +16,7 @@ from pymax.session.models import SessionInfo from pymax.telemetry import TelemetryService from pymax.types import MaxApiError, Message -from pymax.types.domain import Chat, Profile, User +from pymax.types.domain import Chat, HandshakeResponse, Profile, User if TYPE_CHECKING: from pymax.base import BaseClient @@ -38,6 +39,7 @@ 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.me: Profile | None = None self.chats: list[Chat] | None = None @@ -46,6 +48,7 @@ def __init__( self.messages: dict[int, list[Message]] = {} self.session: SessionInfo | None = None + self.handshake_response: HandshakeResponse | None = None self.started = False self._ping_task: asyncio.Task[None] | None = None @@ -81,12 +84,14 @@ async def start(self) -> None: session_data.device_id if session_data else self.config.device.device_id ) logger.debug("running handshake") - await self.handshake(handshake_device_id) + handshake_response = await self.handshake(handshake_device_id) except (ConnectionError, EOFError, OSError, TimeoutError) as e: logger.exception("failed to connect or handshake") await self.connection.close() raise ConnectionError(f"Failed to connect and handshake: {e}") from e + self.handshake_response = handshake_response + self._ping_task = asyncio.create_task(self._ping_loop()) if not session_data: @@ -168,13 +173,14 @@ async def start(self) -> None: if self._telemetry: self._telemetry.start() - async def handshake(self, device_id: str) -> None: - await self.api.session.handshake( + async def handshake(self, device_id: str) -> HandshakeResponse: + response = await self.api.session.handshake( self.config.device.mt_instance_id, self.config.device.user_agent, device_id, ) logger.debug("handshake completed device_id=%s", device_id) + return response async def close(self) -> None: if self._telemetry: diff --git a/src/pymax/config.py b/src/pymax/config.py index c212bdf..bb34830 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -12,16 +12,37 @@ from pymax.types.domain.sync import SyncOverrides APP_VERSIONS: tuple[tuple[str, int], ...] = ( + ("26.20.2", 6758), + ("26.20.1", 6740), + ("26.19.3", 6734), + ("26.19.2", 6732), + ("26.19.1", 6729), + ("26.19.0", 6727), + ("26.18.4", 6724), + ("26.18.2", 6720), + ("26.18.1", 6716), + ("26.18.0", 6715), + ("26.17.1", 6712), + ("26.16.4", 6704), + ("26.16.3", 6702), + ("26.16.2", 6701), + ("26.16.1", 6700), + ("26.16.0", 6698), + ("26.15.3", 6695), + ("26.15.1", 6690), + ("26.15.0", 6689), ("26.14.1", 6686), ("26.14.0", 6685), ("26.13.0", 6683), ("26.12.2", 6681), ("26.12.1", 6679), - ("26.12.0", 6678), - ("26.11.3", 6680), + ("26.12.0", 6676), + ("26.11.3", 6670), ("26.11.2", 6669), ("26.11.1", 6665), - ("26.11.0", 6661), + ("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"), diff --git a/src/pymax/fingerprint/__init__.py b/src/pymax/fingerprint/__init__.py new file mode 100644 index 0000000..1f97240 --- /dev/null +++ b/src/pymax/fingerprint/__init__.py @@ -0,0 +1 @@ +from .fingerprint import FingerprintGenerator diff --git a/src/pymax/fingerprint/fingerprint.py b/src/pymax/fingerprint/fingerprint.py new file mode 100644 index 0000000..a277693 --- /dev/null +++ b/src/pymax/fingerprint/fingerprint.py @@ -0,0 +1,47 @@ +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 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 + ).digest() + h2 = hashlib.sha256( + bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes + ).digest() + h3 = hashlib.sha256( + bytes.fromhex(model.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 new file mode 100644 index 0000000..9bcb2da --- /dev/null +++ b/src/pymax/fingerprint/models.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ApkBuildFingerprint(BaseModel): + signature_scheme: str + certificate_count: int + certificate_meta_sha256: str + certificate_sha256: list[str] + dex_meta_sha256: str + so_meta_sha256: dict[str, str] + build_number: int diff --git a/src/pymax/types/domain/__init__.py b/src/pymax/types/domain/__init__.py index e118c45..fb13175 100644 --- a/src/pymax/types/domain/__init__.py +++ b/src/pymax/types/domain/__init__.py @@ -3,6 +3,7 @@ from .chat import Chat from .error import MaxApiError from .folder import Folder, FolderList, FolderUpdate +from .handshake import HandshakeResponse from .login import LoginResponse from .member import Member from .message import Message, ReactionCounter, ReactionInfo, ReadState diff --git a/src/pymax/types/domain/handshake.py b/src/pymax/types/domain/handshake.py new file mode 100644 index 0000000..c4d04c0 --- /dev/null +++ b/src/pymax/types/domain/handshake.py @@ -0,0 +1,11 @@ +from .base import CamelModel + + +class HandshakeResponse(CamelModel): + """Результат рукопожатия. + + :ivar calls_seed: Сид для генерации хэшей вызовов. + :vartype calls_seed: int + """ + + calls_seed: int diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index c2a3daf..dd569f3 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -55,6 +55,11 @@ async def test_request_and_send_code_parse_auth_responses() -> None: Opcode.AUTH, ] 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, + ) assert app.calls[1].payload["verifyCode"] == "111111" @@ -93,6 +98,13 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert app.calls[0].opcode == Opcode.LOGIN assert app.calls[0].payload["token"] == "local-token" assert app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.ANDROID + 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, + ) assert app.session is not None assert app.session.mt_instance_id == "mt-test" assert app.session.sync.chats_sync == 777 diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 7a0f5f0..773a239 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -424,15 +424,15 @@ async def test_self_service_profile_photo_folders_and_logout() -> None: @pytest.mark.asyncio async def test_session_handshake_switches_between_mobile_and_web_payloads() -> None: - mobile_app = FakeApp([frame({})]) - await mobile_app.api.session.handshake( + mobile_app = FakeApp([frame({"callsSeed": 101})]) + mobile_response = await mobile_app.api.session.handshake( "mt", mobile_app.config.device.user_agent, "device", ) - web_app = FakeApp([frame({})], device_type=DeviceType.WEB) - await web_app.api.session.handshake( + web_app = FakeApp([frame({"callsSeed": 202})], device_type=DeviceType.WEB) + web_response = await web_app.api.session.handshake( "ignored", web_app.config.device.user_agent, "web-device", @@ -443,6 +443,9 @@ async def test_session_handshake_switches_between_mobile_and_web_payloads() -> N assert web_app.calls[0].payload["deviceId"] == "web-device" assert web_app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.WEB assert "mt_instanceid" not in web_app.calls[0].payload + assert mobile_response is not None + assert mobile_response.calls_seed == 101 + assert web_response.calls_seed == 202 @pytest.mark.asyncio diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 3241f12..ac8586e 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -414,9 +414,7 @@ def test_video_request_supports_legacy_and_external_only_payloads() -> None: legacy = VideoRequest.model_validate( {"cache": True, "dynamicUrl": "https://video.test/legacy"} ) - external = VideoRequest.model_validate( - {"cache": True, "EXTERNAL": "https://m.ok.ru/video/1"} - ) + external = VideoRequest.model_validate({"cache": True, "EXTERNAL": "https://m.ok.ru/video/1"}) assert legacy.url == "https://video.test/legacy" assert external.url is None diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index b53fe1d..4d9b790 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -123,7 +123,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), @@ -165,7 +165,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), InboundFrame( opcode=Opcode.LOGIN, cmd=Command.ERROR, @@ -218,7 +218,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), InboundFrame( opcode=Opcode.LOGIN, cmd=Command.ERROR, @@ -273,7 +273,7 @@ async def fail_wait_closed() -> None: config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), @@ -418,7 +418,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), diff --git a/tests/conftest.py b/tests/conftest.py index de27e0f..a160539 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,10 @@ ) from pymax.api.users.service import UserService from pymax.config import ClientConfig, DeviceConfig +from pymax.fingerprint.fingerprint import FingerprintGenerator from pymax.protocol import Command, InboundFrame from pymax.session.models import SessionInfo +from pymax.types.domain import HandshakeResponse from pymax.types.domain.sync import SyncOverrides @@ -152,7 +154,9 @@ def __init__( self.contacts: list[Any] = [] self.messages: dict[int, list[Any]] = {} self.session: SessionInfo | None = None + self.handshake_response: HandshakeResponse | None = HandshakeResponse(calls_seed=123) self.started = True + self.fingerprint_generator = FingerprintGenerator() self.api = SimpleNamespace() self.api.uploads = FakeUploads() From a5e10d74d961da4014721cbef12a71027b2e059b Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:57:55 +0300 Subject: [PATCH 05/35] feat: add account presence control --- src/pymax/api/auth/payloads.py | 4 ++++ src/pymax/api/auth/service.py | 2 ++ src/pymax/api/self/service.py | 4 ++++ src/pymax/app.py | 2 +- src/pymax/config.py | 2 ++ src/pymax/infra/self.py | 9 +++++++++ 6 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index 8004e04..ecae311 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -43,12 +43,14 @@ def from_sync_state( cls, token: str, sync: SyncState, + interactive: bool = True, ) -> "WebSyncPayload": return cls( token=token, chats_sync=sync.chats_sync, contacts_sync=sync.contacts_sync, drafts_sync=sync.drafts_sync, + interactive=interactive, ) @@ -72,6 +74,7 @@ def from_sync_state( token: str, sync: SyncState, chat_cache_fingerprint: bytes | None = None, + interactive: bool = True, ) -> "SyncPayload": return cls( user_agent=user_agent, @@ -82,6 +85,7 @@ def from_sync_state( drafts_sync=sync.drafts_sync, presence_sync=sync.presence_sync, config_hash=sync.config_hash, + interactive=interactive, ) diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 7beaa5f..3617719 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -159,6 +159,7 @@ async def mobile_login(self) -> LoginResponse: token=session.token, sync=sync, chat_cache_fingerprint=ccf, + interactive=self.app.config.interactive, ) response = await self.app.invoke(Opcode.LOGIN, frame.to_payload()) @@ -183,6 +184,7 @@ async def web_login(self) -> LoginResponse: frame = WebSyncPayload.from_sync_state( token=session.token, sync=sync, + interactive=self.app.config.interactive, ) response = await self.app.invoke(Opcode.LOGIN, frame.to_payload()) diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 3dc2164..166046e 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -150,3 +150,7 @@ async def logout(self) -> bool: logger.info("logging out") await self.app.invoke(Opcode.LOGOUT, {}) return True + + def set_presence(self, online: bool) -> None: + logger.info("setting presence to %s", "online" if online else "offline") + self.app.config.interactive = online diff --git a/src/pymax/app.py b/src/pymax/app.py index d96622a..a4255aa 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -252,7 +252,7 @@ async def _ping_loop(self) -> None: while True: await self.invoke( opcode=Opcode.PING, - payload={"interactive": True}, + payload={"interactive": self.config.interactive}, timeout=self.config.request_timeout, ) await asyncio.sleep(30) diff --git a/src/pymax/config.py b/src/pymax/config.py index bb34830..4fbc044 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -140,6 +140,8 @@ class ClientConfig(BaseModel): log_level: str = "INFO" telemetry: bool = False + interactive: bool = True + store: StoreProtocol | None = None sync: SyncOverrides = Field(default_factory=SyncOverrides) diff --git a/src/pymax/infra/self.py b/src/pymax/infra/self.py index 361b055..03baf27 100644 --- a/src/pymax/infra/self.py +++ b/src/pymax/infra/self.py @@ -133,3 +133,12 @@ async def logout(self) -> bool: ``True``, если сервер принял запрос на выход. """ return await self._app.api.account.logout() + + def set_presence(self, online: bool) -> None: + """Устанавливает статус присутствия текущего аккаунта. (Статус применяется не мгновенно, а при следущем login/ping) + + Args: + online: ``True``, если нужно установить статус "в сети", иначе + ``False``. + """ + self._app.api.account.set_presence(online) From d43079b0b14c9f97a113020d5ab3dd2c72d54db1 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:23:35 +0300 Subject: [PATCH 06/35] feat: add poll attachments support --- src/pymax/api/messages/payloads.py | 9 +- src/pymax/api/messages/service.py | 35 +++++-- src/pymax/infra/message.py | 11 ++- src/pymax/infra/self.py | 6 +- .../types/domain/attachments/__init__.py | 3 +- src/pymax/types/domain/attachments/enums.py | 18 +++- src/pymax/types/domain/attachments/poll.py | 98 +++++++++++++++++++ src/pymax/types/domain/message.py | 7 +- 8 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 src/pymax/types/domain/attachments/poll.py diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 0302007..6d2645d 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -8,6 +8,7 @@ AttachPhotoPayload, VideoAttachPayload, ) +from pymax.types.domain import Poll from .enums import ItemType, ReadAction @@ -20,9 +21,9 @@ class GetMessagesPayload(CamelModel): class EditMessagePayload(CamelModel): chat_id: int message_id: int - text: str + text: str | None = None elements: list[Any] - attachments: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field( + attachments: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = Field( default_factory=list ) @@ -33,10 +34,10 @@ class ReplyLink(CamelModel): class SendMessagePayloadMessage(CamelModel): - text: str + text: str | None = None cid: int elements: list[Any] - attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] + attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] link: ReplyLink | None = None diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 602c350..085436f 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -25,6 +25,7 @@ from pymax.types.domain import ( FileRequest, Message, + Poll, ReactionInfo, ReadState, VideoRequest, @@ -55,7 +56,7 @@ if TYPE_CHECKING: from pymax.app import App -SendAttachment: TypeAlias = Photo | File | Video +SendAttachment: TypeAlias = Photo | File | Video | Poll SendAttachments: TypeAlias = Sequence[SendAttachment] | None logger = get_logger(__name__) @@ -76,8 +77,8 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments - ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] + ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll]: + result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = [] if not attachments: return result @@ -106,20 +107,30 @@ async def _upload_attachments( result.append(upload_result) + elif isinstance(attachment, Poll): + result.append(attachment) + return result async def send_message( self, chat_id: int, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, ) -> Message | None: - logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text)) + logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0) + + if not text and not attachments: + logger.error("send_message failed: no text or attachments provided") + raise ValueError("Either text or attachments must be provided") - clean_text, elements = Formatter.format_markdown(text) + if text: + clean_text, elements = Formatter.format_markdown(text) + else: + clean_text, elements = None, [] frame = SendMessagePayload( chat_id=chat_id, @@ -208,10 +219,18 @@ async def edit_message( self, chat_id: int, message_id: int, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: - clean_text, elements = Formatter.format_markdown(text) + if not text and not attachments: + logger.error("edit_message failed: no text or attachments provided") + raise ValueError("Either text or attachments must be provided") + + if text: + clean_text, elements = Formatter.format_markdown(text) + else: + clean_text, elements = None, [] + frame = EditMessagePayload( chat_id=chat_id, message_id=message_id, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index ea1d3f1..7109a48 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -17,7 +17,7 @@ class MessageMixin(IClientProtocol): async def send_message( self, chat_id: int, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, @@ -27,13 +27,16 @@ async def send_message( Args: chat_id: ID чата. - text: Текст сообщения. + text: Текст сообщения. Можно не передавать при наличии вложений. reply_to: ID сообщения для ответа. - attachments: Файлы, фотографии или видео для отправки. + attachments: Файлы, фотографии, видео или опросы для отправки. notify: Отправить ли получателям push-уведомление. Returns: Отправленное сообщение или ``None``, если сервер не вернул его. + + Raises: + ValueError: Если не переданы ни текст, ни вложения. """ return await self._app.api.messages.send_message( chat_id, @@ -121,7 +124,7 @@ async def edit_message( chat_id: ID чата. message_id: ID сообщения. text: Новый текст сообщения с поддержкой markdown. - attachments: Новые файлы, фотографии или видео для сообщения. + attachments: Новые файлы, фотографии, видео или опросы для сообщения. Returns: Отредактированное сообщение. diff --git a/src/pymax/infra/self.py b/src/pymax/infra/self.py index 03baf27..f854801 100644 --- a/src/pymax/infra/self.py +++ b/src/pymax/infra/self.py @@ -134,8 +134,10 @@ async def logout(self) -> bool: """ return await self._app.api.account.logout() - def set_presence(self, online: bool) -> None: - """Устанавливает статус присутствия текущего аккаунта. (Статус применяется не мгновенно, а при следущем login/ping) + def set_presence(self, *, online: bool) -> None: + """Устанавливает статус присутствия текущего аккаунта. + + Статус применяется не мгновенно, а при следующем запросе login/ping. Args: online: ``True``, если нужно установить статус "в сети", иначе diff --git a/src/pymax/types/domain/attachments/__init__.py b/src/pymax/types/domain/attachments/__init__.py index bd9e402..da26fb0 100644 --- a/src/pymax/types/domain/attachments/__init__.py +++ b/src/pymax/types/domain/attachments/__init__.py @@ -2,10 +2,11 @@ from .call import CallAttachment from .contact import ContactAttachment from .control import ControlAttachment -from .enums import AttachmentType +from .enums import AttachmentType, PollFlags from .file import FileAttachment, FileRequest from .keyboards import InlineKeyboardAttachment from .photo import PhotoAttachment +from .poll import Poll, PollAnswer, PollAttachment from .share import ShareAttachment from .sticker import StickerAttachment from .unknown import UnknownAttachment diff --git a/src/pymax/types/domain/attachments/enums.py b/src/pymax/types/domain/attachments/enums.py index 65d23ec..583f821 100644 --- a/src/pymax/types/domain/attachments/enums.py +++ b/src/pymax/types/domain/attachments/enums.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import Enum, IntFlag class AttachmentType(str, Enum): @@ -14,6 +14,7 @@ class AttachmentType(str, Enum): CALL = "CALL" SHARE = "SHARE" INLINE_KEYBOARD = "INLINE_KEYBOARD" + POLL = "POLL" UNKNOWN = "UNKNOWN" @@ -26,3 +27,18 @@ class TranscriptionStatus(str, Enum): PROCESSING = "PROCESSING" SUCCESS = "SUCCESS" UNKNOWN = "UNKNOWN" + + +class PollFlags(IntFlag): + """Настройки опроса, представленные битовой маской. + + Несколько настроек объединяются оператором ``|``. Числовые значения, + полученные от Max, автоматически преобразуются в ``PollFlags``. + """ + + FLAG_SETTINGS_ANONYMOUS = 1 + FLAG_SETTINGS_MULTISELECT = 2 + FLAG_SETTINGS_REVOTE = 4 + FLAG_SETTINGS_CLOSED = 8 + FLAG_SETTINGS_QUIZ = 16 + FLAG_SETTINGS_CAN_FORWARD = 32 diff --git a/src/pymax/types/domain/attachments/poll.py b/src/pymax/types/domain/attachments/poll.py new file mode 100644 index 0000000..356e8b7 --- /dev/null +++ b/src/pymax/types/domain/attachments/poll.py @@ -0,0 +1,98 @@ +from typing import Literal + +from pydantic import Field + +from pymax.types.domain.base import CamelModel + +from .enums import AttachmentType, PollFlags + + +class PollAnswer(CamelModel): + """Вариант ответа в опросе. + + :ivar text: Текст варианта ответа. + :vartype text: str + :ivar answer_id: ID варианта, назначенный Max. + :vartype answer_id: int | None + """ + + text: str + answer_id: int | None = None + + +class PollResult(CamelModel): + """Результат голосования по одному варианту ответа. + + :ivar answer_id: ID варианта ответа. + :vartype answer_id: int + :ivar vote_count: Количество голосов. + :vartype vote_count: int + :ivar votes: ID проголосовавших пользователей, доступные текущему аккаунту. + :vartype votes: list[int] + :ivar rate: Доля голосов в формате, возвращаемом Max. + :vartype rate: int + :ivar options: Дополнительные параметры результата от Max. + :vartype options: int + """ + + answer_id: int + vote_count: int + votes: list[int] + rate: int + options: int + + +class PollState(CamelModel): + """Текущее состояние голосования. + + :ivar total: Общее количество голосов. + :vartype total: int + :ivar result: Результаты по вариантам ответа. + :vartype result: list[PollResult] | None + :ivar voter_preview_ids: ID пользователей для предпросмотра списка + проголосовавших. + :vartype voter_preview_ids: list[int] + """ + + total: int = 0 + result: list[PollResult] | None = None + voter_preview_ids: list[int] + + +class Poll(CamelModel): + """Опрос для отправки в сообщении. + + :ivar title: Вопрос или заголовок опроса. + :vartype title: str + :ivar answers: Варианты ответа. + :vartype answers: list[PollAnswer] + :ivar settings: Настройки опроса. Несколько ``PollFlags`` объединяются + оператором ``|``. + :vartype settings: PollFlags + :ivar type: Тип вложения. + :vartype type: Literal[AttachmentType.POLL] + """ + + title: str + answers: list[PollAnswer] + settings: PollFlags + type: Literal[AttachmentType.POLL] = Field(alias="_type", default=AttachmentType.POLL) + + +class PollAttachment(Poll): + """Опрос, полученный как вложение сообщения. + + Помимо параметров опроса содержит назначенный Max ID, версию и текущее + состояние голосования. + + :ivar poll_id: ID опроса. + :vartype poll_id: int + :ivar version: Версия данных опроса. + :vartype version: int + :ivar state: Текущее состояние голосования. + :vartype state: PollState + """ + + poll_id: int + version: int + state: PollState diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index ec4fc84..3b98909 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -14,6 +14,8 @@ FileAttachment, InlineKeyboardAttachment, PhotoAttachment, + Poll, + PollAttachment, ShareAttachment, StickerAttachment, UnknownAttachment, @@ -38,11 +40,12 @@ | ControlAttachment | InlineKeyboardAttachment | ShareAttachment - | CallAttachment, + | CallAttachment + | PollAttachment, Field(discriminator="type"), ] Attachment: TypeAlias = KnownAttachment | UnknownAttachment -SendAttachment: TypeAlias = Photo | File | Video +SendAttachment: TypeAlias = Photo | File | Video | Poll SendAttachments: TypeAlias = Sequence[SendAttachment] | None From 8ec73c3baf6fadb6c898cd8933f3c652f4aea3be Mon Sep 17 00:00:00 2001 From: igorbunov Date: Thu, 16 Jul 2026 13:45:34 +0300 Subject: [PATCH 07/35] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B0=D1=88?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=87=D0=BB=D0=B5=D0=BD=D0=BE=D0=B2=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0/=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pymax/api/chats/payloads.py | 7 +++++++ src/pymax/api/chats/service.py | 9 +++++++++ src/pymax/infra/chat.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 1adc8be..4d8981d 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -95,6 +95,13 @@ class GetChatInfoPayload(CamelModel): chat_ids: list[int] +class GetChatMembersPayload(CamelModel): + type: str = "MEMBER" # TODO: ENUMM!!! + chat_id: int + marker: int + count: int = 50 + + class LeaveChatPayload(CamelModel): chat_id: int diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 83c5469..26a04e9 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -27,6 +27,7 @@ FetchChatsPayload, FetchJoinRequests, GetChatInfoPayload, + GetChatMembersPayload, InviteUsersPayload, JoinChatPayload, JoinRequestActionPayload, @@ -266,6 +267,14 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: return [cached[chat_id] for chat_id in chat_ids if chat_id in cached] + async def get_chat_members(self, chat_id: int, marker: int | None = None) -> list[Member]: + frame = GetChatMembersPayload(chat_id=chat_id, marker=marker or 0) + response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) + return bind_api_model( + self.app, + parse_payload_list(response, ChatPayloadKey.MEMBERS, Member), + ) + async def get_chat(self, chat_id: int) -> Chat: chats = await self.get_chats([chat_id]) if not chats: diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 7e13218..873fa8a 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -211,6 +211,22 @@ async def get_chat(self, chat_id: int) -> Chat: """ return await self._app.api.chats.get_chat(chat_id) + async def get_chat_members(self, chat_id: int, marker: int | None = None) -> list[Member]: + """Возвращает участников чата по ID. + + Args: + chat_id: ID чата. + marker: Маркер пагинации. Если ``None``, запрашивается первая + страница. + + Returns: + Список участников. + + Raises: + PyMaxError: Если сервер не вернул участников. + """ + return await self._app.api.chats.get_chat_members(chat_id, marker) + async def leave_group(self, chat_id: int) -> None: """Выходит из группы. From 64d8ee0ba13abfb833ccf9a4a4b2c63a619d4bc0 Mon Sep 17 00:00:00 2001 From: igorbunov Date: Fri, 17 Jul 2026 11:46:20 +0300 Subject: [PATCH 08/35] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pymax/api/chats/enums.py | 1 + src/pymax/api/chats/service.py | 12 ++++++++++-- src/pymax/infra/chat.py | 19 +++++++++++-------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/pymax/api/chats/enums.py b/src/pymax/api/chats/enums.py index 4f61325..ffc1275 100644 --- a/src/pymax/api/chats/enums.py +++ b/src/pymax/api/chats/enums.py @@ -22,6 +22,7 @@ class ChatPayloadKey(str, Enum): CHAT = "chat" CHATS = "chats" MEMBERS = "members" + MARKER = "marker" class ChatLinkPrefix(str, Enum): diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 26a04e9..fcf7cd1 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -7,6 +7,7 @@ from pymax.api.response import ( parse_payload_item_model, parse_payload_list, + payload_item, require_payload_item_model, require_payload_model, ) @@ -267,13 +268,20 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: return [cached[chat_id] for chat_id in chat_ids if chat_id in cached] - async def get_chat_members(self, chat_id: int, marker: int | None = None) -> list[Member]: + async def get_chat_members( + self, + chat_id: int, + marker: int | None = None, + ) -> tuple[list[Member], int]: frame = GetChatMembersPayload(chat_id=chat_id, marker=marker or 0) response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) - return bind_api_model( + + members = bind_api_model( self.app, parse_payload_list(response, ChatPayloadKey.MEMBERS, Member), ) + next_marker = payload_item(response, ChatPayloadKey.MARKER, int) or 0 + return members, next_marker async def get_chat(self, chat_id: int) -> Chat: chats = await self.get_chats([chat_id]) diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 873fa8a..689ae99 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -211,19 +211,22 @@ async def get_chat(self, chat_id: int) -> Chat: """ return await self._app.api.chats.get_chat(chat_id) - async def get_chat_members(self, chat_id: int, marker: int | None = None) -> list[Member]: - """Возвращает участников чата по ID. + async def get_chat_members( + self, + chat_id: int, + marker: int | None = None, + ) -> tuple[list[Member], int]: + """Возвращает страницу участников чата по ID. Args: chat_id: ID чата. - marker: Маркер пагинации. Если ``None``, запрашивается первая - страница. + marker: Маркер страницы. Returns: - Список участников. - - Raises: - PyMaxError: Если сервер не вернул участников. + ``(members, next_marker)``. Если участников больше, чем + уместилось в ответ, ``next_marker`` ненулевой — передайте его в + следующий вызов, чтобы получить следующую страницу. ``0`` + означает, что дальше страниц нет. """ return await self._app.api.chats.get_chat_members(chat_id, marker) From cfb27fdb3639f2d060817ddaf9678812043d621b Mon Sep 17 00:00:00 2001 From: igorbunov Date: Mon, 20 Jul 2026 10:23:06 +0300 Subject: [PATCH 09/35] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pymax/api/chats/service.py | 3 ++- src/pymax/infra/chat.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index fcf7cd1..a731fce 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -272,8 +272,9 @@ async def get_chat_members( self, chat_id: int, marker: int | None = None, + count: int = 50, ) -> tuple[list[Member], int]: - frame = GetChatMembersPayload(chat_id=chat_id, marker=marker or 0) + frame = GetChatMembersPayload(chat_id=chat_id, marker=marker or 0, count=count) response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) members = bind_api_model( diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 689ae99..34d908c 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -215,12 +215,15 @@ async def get_chat_members( self, chat_id: int, marker: int | None = None, + count: int = 50, ) -> tuple[list[Member], int]: """Возвращает страницу участников чата по ID. Args: chat_id: ID чата. - marker: Маркер страницы. + marker: Маркер страницы. Если ``None``, запрашивается первая + страница. + count: Максимальное количество участников в ответе. Returns: ``(members, next_marker)``. Если участников больше, чем @@ -228,7 +231,7 @@ async def get_chat_members( следующий вызов, чтобы получить следующую страницу. ``0`` означает, что дальше страниц нет. """ - return await self._app.api.chats.get_chat_members(chat_id, marker) + return await self._app.api.chats.get_chat_members(chat_id, marker, count) async def leave_group(self, chat_id: int) -> None: """Выходит из группы. From 5bef59a9695d7dabad2aaa0291c272f56045279f Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:40:58 +0300 Subject: [PATCH 10/35] chore: update protocol enums --- src/pymax/api/auth/enums.py | 1 + src/pymax/protocol/enums.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/pymax/api/auth/enums.py b/src/pymax/api/auth/enums.py index 52e473b..bb9750d 100644 --- a/src/pymax/api/auth/enums.py +++ b/src/pymax/api/auth/enums.py @@ -6,6 +6,7 @@ class AuthType(str, Enum): CHECK_CODE = "CHECK_CODE" REGISTER = "REGISTER" RESEND = "RESEND" + LOGIN = "LOGIN" class ProfileOptions(int, Enum): diff --git a/src/pymax/protocol/enums.py b/src/pymax/protocol/enums.py index 8d10717..ac46316 100644 --- a/src/pymax/protocol/enums.py +++ b/src/pymax/protocol/enums.py @@ -18,6 +18,7 @@ class Opcode(int, Enum): RECONNECT = 3 LOG = 5 SESSION_INIT = 6 + LOGIN2 = 8 PROFILE = 16 AUTH_REQUEST = 17 AUTH = 18 @@ -152,6 +153,18 @@ class Opcode(int, Enum): PROFILE_DELETE = 199 PROFILE_DELETE_TIME = 200 TRANSCRIBE_MEDIA = 202 + STORIES_LIST = 208 + STORIES_LIST_BY_OWNER_ID = 209 + STORIES_GET_BY_OWNER_ID = 210 + STORIES_GET_STATS = 211 + STORIES_GET_DETAILED_STATS = 212 + STORIES_REACT = 213 + STORIES_MARK = 214 + STORIES_SEND = 215 + NOTIF_STORIES_UPDATE = 216 + STORIES_EDIT = 217 + STORIES_DELETE = 218 + STORIES_GET_BY_STORY_ID = 220 ORG_INFO = 256 CHAT_REACTIONS_SETTINGS_SET = 257 REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258 From 7f6e7bdefe7d2d15432accb579345250dc64258c Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:41:16 +0300 Subject: [PATCH 11/35] feat: support two-step mobile login --- src/pymax/api/auth/payloads.py | 19 ++++ src/pymax/api/auth/service.py | 28 +++++- src/pymax/app.py | 57 +++++++++--- src/pymax/types/domain/__init__.py | 2 +- src/pymax/types/domain/login.py | 31 ++++++- tests/api/test_auth_service.py | 61 ++++++++++++- tests/app/test_app_runtime.py | 135 ++++++++++++++++++++++++++++- tests/domain/test_login_models.py | 63 ++++++++++++++ 8 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 tests/domain/test_login_models.py diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index ecae311..d9f6d31 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -145,3 +145,22 @@ class ConfirmRegistrationPayload(CamelModel): last_name: str | None = None token: str token_type: AuthType = AuthType.REGISTER + + +class Login2Payload(CamelModel): + need_profile: bool + contacts_sync: int + config_hash: ConfigHash + + @classmethod + def from_sync_state( + cls, + sync: SyncState, + profile_enabled: bool, + contact_enabled: bool, + ) -> "Login2Payload": + return cls( + need_profile=profile_enabled, + contacts_sync=sync.contacts_sync if contact_enabled else -1, + config_hash=sync.config_hash, + ) diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 3617719..fa8f7ce 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -13,6 +13,7 @@ from pymax.auth.providers import ConsoleEmailCodeProvider from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types.domain import Login2Flags, Login2Response from pymax.types.domain.auth import ( CheckCodeResponse, CheckPasswordResponse, @@ -31,6 +32,7 @@ ConfirmQrPayload, ConfirmRegistrationPayload, CreateAuthTrackPayload, + Login2Payload, MobileUserAgentPayload, RemoveTwoFactorPayload, RequestCodePayload, @@ -172,6 +174,30 @@ async def mobile_login(self) -> LoginResponse: await self._update_session(login_response) return login_response + async def mobile_login2(self, flags: Login2Flags) -> Login2Response: + session = self.app.session + if session is None: + logger.error("login2 requested without session") + raise RuntimeError("No session available for login2") + + sync = self.app.config.sync.resolve(session.sync) + + frame = Login2Payload.from_sync_state( + sync, + profile_enabled=flags.profile_enabled, + contact_enabled=flags.contact_enabled, + ) + + response = await self.app.invoke(Opcode.LOGIN2, frame.to_payload()) + logger.debug("login2 response payload_keys=%s", payload_keys(response)) + + login2_response = bind_api_model( + self.app, + require_payload_model(response, Login2Response), + ) + await self._update_session(login2_response) + return login2_response + async def web_login(self) -> LoginResponse: session = self.app.session if session is None: @@ -216,7 +242,7 @@ async def confirm_qr(self, track_id: str) -> CheckCodeResponse: return require_payload_model(response, CheckCodeResponse) - async def _update_session(self, response: LoginResponse) -> None: + async def _update_session(self, response: LoginResponse | Login2Response) -> None: session = self.app.session if session is None: return diff --git a/src/pymax/app.py b/src/pymax/app.py index a4255aa..4f87fed 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -16,7 +16,8 @@ from pymax.session.models import SessionInfo from pymax.telemetry import TelemetryService from pymax.types import MaxApiError, Message -from pymax.types.domain import Chat, HandshakeResponse, Profile, User +from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User +from pymax.types.domain.login import LoginResponse if TYPE_CHECKING: from pymax.base import BaseClient @@ -134,9 +135,22 @@ async def start(self) -> None: logger.debug("logging in") try: - response = await self.api.auth.login( - self.config.device.user_agent, - ) + login_response, login2_response = await self.login() + + if ( + login2_response + and login_response.login2_flags + and login_response.login2_flags.profile_enabled + and login2_response.profile + ): + self.me = login2_response.profile + elif login_response.profile: + self.me = login_response.profile + elif login2_response and login2_response.profile: + self.me = login2_response.profile + else: + logger.error("Impossible state: login response does not contain profile") + raise RuntimeError("Login response does not contain profile") except Exception as e: handled = False if self.dispatcher.client is not None: @@ -153,15 +167,23 @@ async def start(self) -> None: await self.close() return - if response.token is not None and response.token != self.session.token: - await self.store.update_token(self.session.token, response.token) - self.session.token = response.token + if login_response.token is not None and login_response.token != self.session.token: + await self.store.update_token(self.session.token, login_response.token) + self.session.token = login_response.token - self.me = response.profile - self.chats = response.chats + self.chats = login_response.chats self.users[self.me.contact.id] = self.me.contact - self.contacts = response.contacts - self.messages = response.messages + + if ( + login2_response + and login_response.login2_flags + and login_response.login2_flags.contact_enabled + ): + self.contacts = login2_response.contacts + else: + self.contacts = login_response.contacts + + self.messages = login_response.messages self.started = True logger.info( @@ -173,6 +195,19 @@ async def start(self) -> None: if self._telemetry: self._telemetry.start() + async def login(self) -> tuple[LoginResponse, Login2Response | None]: + login_response = await self.api.auth.login( + self.config.device.user_agent, + ) + + if login_response.login2_flags and login_response.login2_flags.enabled: + logger.debug("login2 required; proceeding with login2") + login2_response = await self.api.auth.mobile_login2(login_response.login2_flags) + + return login_response, login2_response + + return login_response, None + async def handshake(self, device_id: str) -> HandshakeResponse: response = await self.api.session.handshake( self.config.device.mt_instance_id, diff --git a/src/pymax/types/domain/__init__.py b/src/pymax/types/domain/__init__.py index fb13175..7e1d0c4 100644 --- a/src/pymax/types/domain/__init__.py +++ b/src/pymax/types/domain/__init__.py @@ -4,7 +4,7 @@ from .error import MaxApiError from .folder import Folder, FolderList, FolderUpdate from .handshake import HandshakeResponse -from .login import LoginResponse +from .login import Login2Flags, Login2Response, LoginResponse from .member import Member from .message import Message, ReactionCounter, ReactionInfo, ReadState from .name import Name diff --git a/src/pymax/types/domain/login.py b/src/pymax/types/domain/login.py index 91ef2bf..e623619 100644 --- a/src/pymax/types/domain/login.py +++ b/src/pymax/types/domain/login.py @@ -13,14 +13,26 @@ class LoginConfig(CamelModel): hash: ConfigHash | None = None +class Login2Flags(CamelModel): + config_enabled: bool = False + contact_enabled: bool = False + profile_enabled: bool = False + + @property + def enabled(self) -> bool: + return self.config_enabled or self.contact_enabled or self.profile_enabled + + class LoginResponse(CamelModel): chats: list[Chat] = Field(default_factory=list) - profile: Profile + profile: Profile | None = None messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message] contacts: list[User | None] = Field(default_factory=list) token: str | None = None time: int | None = None config: LoginConfig | None = None + updates: int | None = None + login2_flags: Login2Flags | None = None def update_sync_state(self, current: SyncState) -> SyncState: sync_time = self.time @@ -33,3 +45,20 @@ def update_sync_state(self, current: SyncState) -> SyncState: presence_sync=(sync_time if sync_time is not None else current.presence_sync), config_hash=(config_hash if config_hash is not None else current.config_hash), ) + + +class Login2Response(CamelModel): + profile: Profile | None = None + contacts: list[User | None] = Field(default_factory=list, alias="contactInfos") + config: LoginConfig | None = None + + def update_sync_state(self, current: SyncState) -> SyncState: + config_hash = self.config.hash if self.config is not None else None + + return SyncState( + chats_sync=current.chats_sync, + contacts_sync=current.contacts_sync, + drafts_sync=current.drafts_sync, + presence_sync=current.presence_sync, + config_hash=(config_hash if config_hash is not None else current.config_hash), + ) diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index dd569f3..4328988 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -2,10 +2,11 @@ import pytest -from pymax.api.auth.enums import ProfileOptions, TwoFactorAction +from pymax.api.auth.enums import AuthType, ProfileOptions, TwoFactorAction from pymax.api.session.enums import DeviceType from pymax.protocol import Opcode from pymax.session.models import SessionInfo +from pymax.types.domain import Login2Flags from pymax.types.domain.sync import SyncState from tests.conftest import ( FakeApp, @@ -122,6 +123,61 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert response.contacts[0]._actions is app.api.users +@pytest.mark.asyncio +async def test_mobile_login2_sends_flags_binds_models_and_updates_config_hash() -> None: + app = FakeApp( + [ + frame( + { + "profile": profile_payload(42), + "contactInfos": [user_payload(43)], + "config": {"hash": "new-hash"}, + } + ) + ] + ) + app.session = SessionInfo( + token="local-token", + device_id="device-test", + phone="+79990000000", + sync=SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="old-hash", + ), + ) + + response = await app.api.auth.mobile_login2( + Login2Flags( + config_enabled=True, + contact_enabled=True, + profile_enabled=True, + ) + ) + + assert app.calls[0].opcode == Opcode.LOGIN2 + assert app.calls[0].payload == { + "needProfile": True, + "contactsSync": 2, + "configHash": "old-hash", + } + assert response.profile is not None + assert response.profile.contact._actions is app.api.users + assert response.contacts[0] is not None + assert response.contacts[0]._actions is app.api.users + assert app.session is not None + assert app.session.sync == SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="new-hash", + ) + assert app.store.saved_sessions == [app.session] + + @pytest.mark.asyncio async def test_login_uses_web_payload_for_web_user_agent() -> None: app = FakeApp( @@ -264,7 +320,7 @@ async def test_confirm_registration_sends_profile_and_parses_token() -> None: { "userToken": 42, "profile": profile_payload(42), - "tokenType": "REGISTER", + "tokenType": "LOGIN", "token": "registered-token", } ) @@ -278,6 +334,7 @@ async def test_confirm_registration_sends_profile_and_parses_token() -> None: ) assert result.token == "registered-token" + assert result.token_type == AuthType.LOGIN assert result.profile.contact.id == 42 assert app.calls[0].opcode == Opcode.AUTH_CONFIRM assert app.calls[0].payload == { diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 4d9b790..093e224 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 +from tests.conftest import frame, make_config, profile_payload, user_payload class RuntimeStore: @@ -153,6 +153,139 @@ async def idle_ping_loop(self): assert store.closed is True +@pytest.mark.asyncio +async def test_app_start_completes_login2_and_uses_deferred_profile_and_contacts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "token": "login-token", + "chats": [], + "messages": {}, + "time": 777, + "config": {"hash": "login-hash"}, + "login2Flags": { + "contactEnabled": True, + "configEnabled": True, + "profileEnabled": True, + }, + } + ), + frame( + { + "profile": profile_payload(77), + "contactInfos": [user_payload(88)], + "config": {"hash": "login2-hash"}, + } + ), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.started is True + assert app.me is not None + assert app.me.contact.id == 77 + assert app.me.contact._actions is app.api.users + assert app.contacts[0] is not None + assert app.contacts[0].id == 88 + assert app.contacts[0]._actions is app.api.users + assert app.session is not None + assert app.session.token == "login-token" + assert app.session.sync.config_hash == "login2-hash" + assert [sent[0].opcode for sent in connection.sent] == [ + Opcode.SESSION_INIT, + Opcode.LOGIN, + Opcode.LOGIN2, + ] + assert connection.sent[2][0].payload == { + "needProfile": True, + "contactsSync": 777, + "configHash": "login-hash", + } + + await app.close() + + +@pytest.mark.asyncio +async def test_app_start_keeps_login_contacts_when_login2_contacts_are_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "profile": profile_payload(77), + "contacts": [user_payload(88)], + "login2Flags": {"configEnabled": True}, + } + ), + frame({"config": {"hash": "login2-hash"}}), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.contacts[0] is not None + assert app.contacts[0].id == 88 + assert connection.sent[2][0].payload["contactsSync"] == -1 + + await app.close() + + +@pytest.mark.asyncio +async def test_app_start_emits_missing_profile_error_to_root_router( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame({"chats": [], "messages": {}}), + ] + ) + root_router: Router[object] = Router() + app: App[object] = App(connection, config, StaticAuthFlow(), root_router) + app.dispatcher.bind_client(object()) + seen: list[Exception] = [] + + @root_router.on_error() + async def on_error(exc, ctx): + seen.append(exc) + + await app.start() + + assert len(seen) == 1 + assert isinstance(seen[0], RuntimeError) + assert str(seen[0]) == "Login response does not contain profile" + assert app.started is False + assert connection.closed is True + assert store.closed is True + + @pytest.mark.asyncio async def test_app_start_emits_login_errors_to_root_router( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/domain/test_login_models.py b/tests/domain/test_login_models.py new file mode 100644 index 0000000..70306ca --- /dev/null +++ b/tests/domain/test_login_models.py @@ -0,0 +1,63 @@ +from pymax.types.domain import Login2Response, LoginResponse +from pymax.types.domain.sync import SyncState +from tests.conftest import profile_payload, user_payload + + +def test_login_response_accepts_login2_deferred_profile() -> None: + response = LoginResponse.model_validate( + { + "chats": [], + "messages": {}, + "config": {}, + "time": 1783438624879, + "updates": 1, + "login2Flags": { + "contactEnabled": True, + "configEnabled": True, + "profileEnabled": True, + }, + } + ) + + assert response.profile is None + assert response.login2_flags is not None + assert response.login2_flags.enabled is True + assert response.login2_flags.profile_enabled is True + + +def test_login2_response_maps_contact_infos() -> None: + response = Login2Response.model_validate( + { + "profile": profile_payload(42), + "contactInfos": [user_payload(43)], + "config": {"hash": "cfg-hash"}, + } + ) + + assert response.profile is not None + assert response.profile.contact.id == 42 + assert response.contacts[0] is not None + assert response.contacts[0].id == 43 + assert response.config is not None + assert response.config.hash == "cfg-hash" + + +def test_login2_response_updates_only_config_sync_state() -> None: + response = Login2Response.model_validate({"config": {"hash": "new-hash"}}) + current = SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="old-hash", + ) + + updated = response.update_sync_state(current) + + assert updated == SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="new-hash", + ) From 6c90d4e094b133aef551977cc66324a0a0065932 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:41:34 +0300 Subject: [PATCH 12/35] fix: parse poll vote details --- src/pymax/types/domain/attachments/poll.py | 19 ++++++++++-- tests/domain/test_message_models.py | 36 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/pymax/types/domain/attachments/poll.py b/src/pymax/types/domain/attachments/poll.py index 356e8b7..f1e4ff1 100644 --- a/src/pymax/types/domain/attachments/poll.py +++ b/src/pymax/types/domain/attachments/poll.py @@ -20,6 +20,19 @@ class PollAnswer(CamelModel): answer_id: int | None = None +class PollVote(CamelModel): + """Голос пользователя за один вариант ответа. + + :ivar timestamp: Время голосования в формате Unix time. + :vartype timestamp: int + :ivar user_id: ID проголосовавшего пользователя. + :vartype user_id: int + """ + + timestamp: int + user_id: int + + class PollResult(CamelModel): """Результат голосования по одному варианту ответа. @@ -27,8 +40,8 @@ class PollResult(CamelModel): :vartype answer_id: int :ivar vote_count: Количество голосов. :vartype vote_count: int - :ivar votes: ID проголосовавших пользователей, доступные текущему аккаунту. - :vartype votes: list[int] + :ivar votes: Голоса пользователей, доступные текущему аккаунту. + :vartype votes: list[PollVote] :ivar rate: Доля голосов в формате, возвращаемом Max. :vartype rate: int :ivar options: Дополнительные параметры результата от Max. @@ -37,7 +50,7 @@ class PollResult(CamelModel): answer_id: int vote_count: int - votes: list[int] + votes: list[PollVote] rate: int options: int diff --git a/tests/domain/test_message_models.py b/tests/domain/test_message_models.py index 8c9296e..baf57a9 100644 --- a/tests/domain/test_message_models.py +++ b/tests/domain/test_message_models.py @@ -3,6 +3,7 @@ from pymax.types.domain import ( AudioAttachment, Message, + PollAttachment, UnknownAttachment, VideoAttachment, ) @@ -88,6 +89,41 @@ def test_video_attachment_accepts_missing_duration() -> None: assert attach.video_id == 42 +def test_poll_attachment_parses_vote_details() -> None: + payload = message_payload(1, 100) + payload["attaches"] = [ + { + "_type": "POLL", + "title": "Question", + "answers": [{"text": "Answer", "answerId": 1}], + "settings": 0, + "pollId": 42, + "version": 1, + "state": { + "total": 1, + "result": [ + { + "answerId": 1, + "voteCount": 1, + "votes": [{"timestamp": 123456, "userId": 77}], + "rate": 100, + "options": 0, + } + ], + "voterPreviewIds": [77], + }, + } + ] + + message = Message.model_validate(payload) + + attach = message.attaches[0] + assert isinstance(attach, PollAttachment) + assert attach.state.result is not None + assert attach.state.result[0].votes[0].user_id == 77 + assert attach.state.result[0].votes[0].timestamp == 123456 + + def test_message_elements_accept_missing_length_and_attribute_url() -> None: payload = message_payload(1, 100) payload["elements"] = [ From f16f668cfb14463a3dc66f41bb855f7b8e2fdfa6 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:42:22 +0300 Subject: [PATCH 13/35] chore: update Android app fingerprints --- src/pymax/_data/apk_fingerprints.json | 17 +++++++++++++++++ src/pymax/config.py | 1 + 2 files changed, 18 insertions(+) diff --git a/src/pymax/_data/apk_fingerprints.json b/src/pymax/_data/apk_fingerprints.json index 04a9bcb..31d9fc7 100644 --- a/src/pymax/_data/apk_fingerprints.json +++ b/src/pymax/_data/apk_fingerprints.json @@ -525,5 +525,22 @@ "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" }, "build_number": 6758 + }, + "26.21.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "722162bea4e63c7fe39da201a8c7be94e53967f97c50fc397e7b16f3043467a2", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6763 } } diff --git a/src/pymax/config.py b/src/pymax/config.py index 4fbc044..a591dcb 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -12,6 +12,7 @@ from pymax.types.domain.sync import SyncOverrides APP_VERSIONS: tuple[tuple[str, int], ...] = ( + ("26.21.1", 6763), ("26.20.2", 6758), ("26.20.1", 6740), ("26.19.3", 6734), From 971fba0f2f68071c5201a539b89bf412432efe03 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:21:58 +0300 Subject: [PATCH 14/35] feat: add add_admin method --- src/pymax/api/chats/__init__.py | 1 + src/pymax/api/chats/enums.py | 14 ++++++++++++++ src/pymax/api/chats/payloads.py | 10 +++++++++- src/pymax/api/chats/service.py | 19 ++++++++++++++++++- src/pymax/infra/chat.py | 21 +++++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/pymax/api/chats/__init__.py b/src/pymax/api/chats/__init__.py index 24ce6cd..9c4f51b 100644 --- a/src/pymax/api/chats/__init__.py +++ b/src/pymax/api/chats/__init__.py @@ -1,4 +1,5 @@ from .enums import ( + ChannelPermissions, ChatLinkPrefix, ChatMemberOperation, ChatOption, diff --git a/src/pymax/api/chats/enums.py b/src/pymax/api/chats/enums.py index ffc1275..d924529 100644 --- a/src/pymax/api/chats/enums.py +++ b/src/pymax/api/chats/enums.py @@ -27,3 +27,17 @@ class ChatPayloadKey(str, Enum): class ChatLinkPrefix(str, Enum): JOIN = "join/" + + +class ChannelPermissions(int, Enum): + ADD_REMOVE_MEMBER = 2 + ADD_ADMIN = 4 + CHANGE_CHAT_INFO = 8 + PIN_MESSAGE = 16 + POST_MESSAGE = 256 + EDIT_MESSAGE = 512 + DELETE_MESSAGE = 1024 + + +class PermType(str, Enum): + ADMIN = "ADMIN" diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 4d8981d..03de78a 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -6,7 +6,7 @@ from pymax.types.domain.attachments.enums import AttachmentType from pymax.types.domain.enums import ChatType -from .enums import ChatMemberOperation, ChatOption, ControlEvent +from .enums import ChatMemberOperation, ChatOption, ControlEvent, PermType class CreateGroupAttach(CamelModel): @@ -128,3 +128,11 @@ class DeleteChatPayload(CamelModel): chat_id: int last_event_time: int for_all: bool = True + + +class AddAdminPayload(CamelModel): + chat_id: int + user_ids: list[int] + type: PermType = PermType.ADMIN + operation: str = "add" + permissions: int diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index a731fce..8d8b452 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -1,6 +1,8 @@ from __future__ import annotations import time +from functools import reduce +from operator import or_ from typing import TYPE_CHECKING from pymax.api.binding import bind_api_model @@ -16,8 +18,9 @@ from pymax.protocol import Opcode from pymax.types.domain import Chat, Member, Message -from .enums import ChatLinkPrefix, ChatMemberOperation, ChatPayloadKey +from .enums import ChannelPermissions, ChatLinkPrefix, ChatMemberOperation, ChatPayloadKey from .payloads import ( + AddAdminPayload, ChangeGroupProfilePayload, ChangeGroupSettingsOptions, ChangeGroupSettingsPayload, @@ -398,3 +401,17 @@ async def delete_chat( await self.app.invoke(Opcode.CHAT_DELETE, frame.to_payload()) self._remove_cached_chat(chat_id) + + async def add_admin( + self, + chat_id: int, + user_id: int, + permissions: list[ChannelPermissions], + ) -> None: + frame = AddAdminPayload( + chat_id=chat_id, + user_ids=[user_id], + permissions=reduce(or_, permissions), + ) + + await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 34d908c..69ca396 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -1,3 +1,4 @@ +from pymax.api.chats import ChannelPermissions from pymax.types import Chat, Member, Message from .protocol import IClientProtocol @@ -394,3 +395,23 @@ async def join_channel(self, link: str) -> Chat: Канал, в который вступил клиент. """ return await self._app.api.chats.join_channel(link=link) + + async def add_admin( + self, + chat_id: int, + user_id: int, + permissions: list[ChannelPermissions], + ) -> None: + """ + Добавляет админа в канал + + Args: + chat_id: id чата + user_id: Айди юзера + permissions: Список разрешений для юзера + + Returns: + None + + """ + return await self._app.api.chats.add_admin(chat_id, user_id, permissions) From bf13d9b6fb0b12e54378143028ef458a53dca588 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:23:20 +0300 Subject: [PATCH 15/35] fix: ReactionUpdateEvent.counters annotation --- src/pymax/types/events/reaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymax/types/events/reaction.py b/src/pymax/types/events/reaction.py index 496c7ef..2693f15 100644 --- a/src/pymax/types/events/reaction.py +++ b/src/pymax/types/events/reaction.py @@ -17,5 +17,5 @@ class ReactionUpdateEvent(CamelModel): message_id: str chat_id: int - counters: list[ReactionCounter] | None + counters: list[ReactionCounter] | None = None total_count: int = 0 From 94b60ab663ff212dd6cec5eb6caaf19be552629a Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:38:16 +0300 Subject: [PATCH 16/35] feat: support voice message uploads --- src/pymax/__init__.py | 3 +- src/pymax/api/messages/service.py | 14 +- src/pymax/api/uploads/payloads.py | 2 + src/pymax/api/uploads/service.py | 157 ++++++++++++++++++++++- src/pymax/dispatch/enums.py | 1 + src/pymax/dispatch/mapping.py | 4 +- src/pymax/dispatch/resolvers.py | 8 +- src/pymax/files/__init__.py | 2 + src/pymax/files/voice.py | 33 +++++ src/pymax/types/domain/message.py | 4 +- src/pymax/types/events/__init__.py | 1 + src/pymax/types/events/voice.py | 5 + tests/api/test_message_service.py | 9 +- tests/api/test_upload_service.py | 69 +++++++++- tests/conftest.py | 11 +- tests/dispatch/test_dispatcher.py | 8 ++ tests/files/test_files_and_formatting.py | 5 + 17 files changed, 317 insertions(+), 19 deletions(-) create mode 100644 src/pymax/files/voice.py create mode 100644 src/pymax/types/events/voice.py diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 182048c..655f37d 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -17,7 +17,7 @@ from .config import ExtraConfig, RegistrationConfig from .dispatch import EventType, Router from .exceptions import ApiError, PyMaxError, UploadError -from .files import File, Photo, Video +from .files import File, Photo, Video, Voice from .logging import configure_logging from .routers import ClientRouter, WebRouter from .types import ( @@ -66,6 +66,7 @@ "UploadError", "User", "Video", + "Voice", "WebClient", "WebRouter", "__version__", diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 085436f..d529aac 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -18,7 +18,7 @@ VideoAttachPayload, ) from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.formatting.markdown import Formatter from pymax.logging import get_logger from pymax.protocol import Opcode @@ -56,7 +56,7 @@ if TYPE_CHECKING: from pymax.app import App -SendAttachment: TypeAlias = Photo | File | Video | Poll +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice SendAttachments: TypeAlias = Sequence[SendAttachment] | None logger = get_logger(__name__) @@ -83,7 +83,15 @@ async def _upload_attachments( return result for attachment in attachments: - if isinstance(attachment, Photo): + if isinstance(attachment, Voice): + upload_result = await self.app.api.uploads.upload_voice(attachment) + if not upload_result: + logger.error("Voice uploading failed") + raise UploadError("Voice uploading failed") + + result.append(upload_result) + + elif isinstance(attachment, Photo): upload_result = await self.app.api.uploads.upload_photo(attachment) if not upload_result: logger.error("Photo uploading failed") diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 5dc7794..0ecdd20 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -22,4 +22,6 @@ class AttachFilePayload(CamelModel): class UploadPayload(CamelModel): count: int = 1 + type: int = 0 + uploader_type: int = 0 profile: bool = False diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index 9ae3751..128dee2 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -11,9 +11,10 @@ from pymax.api.response import payload_item from pymax.dispatch.enums import EventType from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types import AttachmentType, AudioUploadSignal from .models import ( FileUploadResponse, @@ -40,14 +41,16 @@ def __init__(self, app: App) -> None: self.app = app self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {} self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {} + self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {} self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach) self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach) + self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach) async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload: logger.info("Uploading photo") logger.debug("Preparing photo upload payload") - payload = UploadPayload(profile=profile).model_dump() + payload = UploadPayload(profile=profile).to_payload() try: data = await self.app.invoke( @@ -178,11 +181,143 @@ 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: + logger.info("Uploading voice") + + payload = UploadPayload( + type=2, + uploader_type=1, + ).to_payload() + + try: + data = await self.app.invoke( + Opcode.VIDEO_UPLOAD, + payload=payload, + ) + except Exception as e: + logger.exception("Failed to request voice upload URL") + raise UploadError("Failed to request voice upload URL") from e + + try: + response = VideoUploadResponse.model_validate(data.payload) + except ValidationError as e: + logger.exception("Invalid voice upload response model") + logger.debug("Invalid voice upload payload=%r", data.payload) + raise UploadError("Invalid voice upload response model") from e + except Exception as e: + logger.exception("Failed to parse voice upload response") + logger.debug("Invalid voice upload payload=%r", data.payload) + raise UploadError("Failed to parse voice upload response") from e + + try: + upload_info = response.info[0] + except IndexError as e: + logger.error("voice upload response info is empty") + logger.debug("voice upload response=%r", response) + raise UploadError("voice upload response info is empty") from e + except Exception as e: + logger.exception("Failed to get voice upload info") + logger.debug("voice upload response=%r", response) + raise UploadError("Failed to get voice upload info") from e + + try: + file_size = await voice.size() + except Exception as e: + logger.exception("Failed to get voice size") + raise UploadError("Failed to get voice size") from e + + headers = { + "Content-Disposition": f"attachment; filename={quote(voice.name)}", + "Content-Range": f"0-{file_size - 1}/{file_size}", + "Content-Length": str(file_size), + "Connection": "keep-alive", + "Content-Type": "application/octet-stream", + } + + logger.debug( + "Voice upload headers prepared content_range=%s", + headers["Content-Range"], + ) + + loop = asyncio.get_running_loop() + future: asyncio.Future[AudioUploadSignal] = loop.create_future() + + timeout = aiohttp.ClientTimeout(total=900, 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: + async with aiohttp.ClientSession( + timeout=timeout, proxy=self.app.config.proxy + ) as session: + logger.debug("Starting voice upload HTTP request voice_id=%s", video_id) + + async with session.post( + url=upload_info.url, + headers=headers, + data=voice.iter_chunks(1024 * 1024), + ) as response: + logger.debug( + "Voice upload HTTP response status=%s voice_id=%s", + response.status, + video_id, + ) + + if response.status != HTTPStatus.OK: + logger.error( + "Voice upload failed with status %s video_id=%s", + response.status, + video_id, + ) + raise UploadError( + "Voice upload failed with status " + 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}" + ) + + logger.debug("Voice upload complete voice_id=%s", video_id) + return VideoAttachPayload( + type=AttachmentType.AUDIO, video_id=video_id, token=token + ) + + except UploadError: + raise + except aiohttp.ClientError as e: + logger.exception("HTTP error during voice upload voice_id=%s", video_id) + raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e + except asyncio.TimeoutError as e: + logger.exception("Timed out during voice upload voice_id=%s", video_id) + raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e + except Exception as e: + logger.exception("Unexpected error during voice upload voice_id=%s", video_id) + raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e + finally: + self.voice_upload_waiters.pop(video_id, None) + logger.debug("Voice upload waiter removed voice_id=%s", video_id) + async def upload_video(self, video: Video) -> VideoAttachPayload: logger.info("Uploading video") logger.debug("Preparing video upload payload") - payload = UploadPayload().model_dump() + payload = UploadPayload().to_payload() try: data = await self.app.invoke( @@ -314,7 +449,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: async def upload_file(self, file: File) -> AttachFilePayload: logger.info("Uploading file") - payload = UploadPayload().model_dump() + payload = UploadPayload().to_payload() try: data = await self.app.invoke( @@ -430,6 +565,20 @@ async def upload_file(self, file: File) -> AttachFilePayload: self.file_upload_waiters.pop(file_id, None) logger.debug("File upload waiter removed file=%s", file_id) + async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None: + future = self.voice_upload_waiters.pop(attach.audio_id, None) + + if not future: + logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id) + return + + if future.done(): + logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id) + return + + future.set_result(attach) + logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id) + async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None: logger.debug("Received attach event video_id=%s", attach.video_id) diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index 631c1fb..da7dc96 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -13,5 +13,6 @@ class EventType(str, Enum): USER_UPDATE = "user_update" VIDEO_READY = "video_ready" FILE_READY = "file_ready" + VOICE_READY = "voice_ready" RAW = "raw" ON_START = "on_start" diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index e814948..bdbb3fe 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -6,7 +6,7 @@ from pymax.api.binding import bind_api_model from pymax.protocol import InboundFrame, Opcode from pymax.protocol.enums import Command -from pymax.types import Chat, MessageDeleteEvent +from pymax.types import AudioUploadSignal, Chat, MessageDeleteEvent from pymax.types.domain import Message from pymax.types.events import ( FileUploadSignal, @@ -100,4 +100,6 @@ def map(self, event_type: EventType, frame: InboundFrame): return VideoUploadSignal.model_validate(frame.payload) elif event_type == EventType.FILE_READY: return FileUploadSignal.model_validate(frame.payload) + elif event_type == EventType.VOICE_READY: + return AudioUploadSignal.model_validate(frame.payload) return frame diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index 25d72d8..bf4fb70 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -3,7 +3,7 @@ from pymax.logging import get_logger from pymax.protocol import InboundFrame from pymax.protocol.enums import Opcode -from pymax.types import Message +from pymax.types import AudioUploadSignal, Message from pymax.types.domain.enums import MessageStatus from pymax.types.events import FileUploadSignal, VideoUploadSignal @@ -49,6 +49,12 @@ def resolve_attach(frame: InboundFrame) -> EventType | None: except ValidationError: logger.debug("attach event is not a video upload signal") + try: + AudioUploadSignal.model_validate(frame.payload) + return EventType.VOICE_READY + except ValidationError: + logger.debug("attach event is not a voice upload signal") + return None diff --git a/src/pymax/files/__init__.py b/src/pymax/files/__init__.py index eca51f9..a639c28 100644 --- a/src/pymax/files/__init__.py +++ b/src/pymax/files/__init__.py @@ -1,9 +1,11 @@ from .file import File from .photo import Photo from .video import Video +from .voice import Voice __all__ = ( "File", "Photo", "Video", + "Voice", ) diff --git a/src/pymax/files/voice.py b/src/pymax/files/voice.py new file mode 100644 index 0000000..39e44fb --- /dev/null +++ b/src/pymax/files/voice.py @@ -0,0 +1,33 @@ +from collections.abc import AsyncGenerator +from pathlib import Path + +from .base import BaseFile + + +class Voice(BaseFile): + def __init__( + self, + raw: bytes | None = None, + *, + path: str | None = None, + url: str | None = None, + name: str | None = None, + ) -> None: + 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=raw, url=url, path=path, name=self.name) + + async def read(self) -> bytes: + return await super().read() + + async def size(self) -> int: + return await super().size() + + def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: + return super().iter_chunks(size) diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 3b98909..d9b5cd5 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -5,7 +5,7 @@ from pydantic import Field, PrivateAttr, model_validator -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.types.domain import ( AudioAttachment, CallAttachment, @@ -45,7 +45,7 @@ Field(discriminator="type"), ] Attachment: TypeAlias = KnownAttachment | UnknownAttachment -SendAttachment: TypeAlias = Photo | File | Video | Poll +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice SendAttachments: TypeAlias = Sequence[SendAttachment] | None diff --git a/src/pymax/types/events/__init__.py b/src/pymax/types/events/__init__.py index 1667633..dc7a56a 100644 --- a/src/pymax/types/events/__init__.py +++ b/src/pymax/types/events/__init__.py @@ -5,3 +5,4 @@ from .reaction import ReactionUpdateEvent from .typing import TypingEvent from .video import VideoUploadSignal +from .voice import AudioUploadSignal diff --git a/src/pymax/types/events/voice.py b/src/pymax/types/events/voice.py new file mode 100644 index 0000000..a11c7b0 --- /dev/null +++ b/src/pymax/types/events/voice.py @@ -0,0 +1,5 @@ +from pymax.types.domain.base import CamelModel + + +class AudioUploadSignal(CamelModel): + audio_id: int diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index ac8586e..d2d70b7 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -5,7 +5,7 @@ from pymax.api.messages.enums import ItemType, MessagePayloadKey from pymax.api.uploads.payloads import AttachPhotoPayload from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.protocol import Opcode from pymax.types.domain.attachments import VideoRequest from tests.conftest import FakeApp, frame, message_payload @@ -104,10 +104,13 @@ async def test_upload_attachments_handles_file_video_and_empty_lists() -> None: app = FakeApp() assert await app.api.messages._upload_attachments(None) == [] - result = await app.api.messages._upload_attachments([File(raw=b"abc", name="doc.txt")]) + file = File(raw=b"abc", name="doc.txt") + voice = Voice(raw=b"voice", name="voice.ogg") + result = await app.api.messages._upload_attachments([file, voice]) assert result[0].file_id == 30 - assert app.api.uploads.calls[0][0] == "file" + assert result[1].type.value == "AUDIO" + assert app.api.uploads.calls == [("file", file), ("voice", voice)] @pytest.mark.asyncio diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py index 4441a0c..ed2390a 100644 --- a/tests/api/test_upload_service.py +++ b/tests/api/test_upload_service.py @@ -2,10 +2,12 @@ import pytest +from pymax.api.uploads.payloads import UploadPayload from pymax.api.uploads.service import UploadService -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.protocol import Opcode -from pymax.types.events import FileUploadSignal, VideoUploadSignal +from pymax.types import AttachmentType +from pymax.types.events import AudioUploadSignal, FileUploadSignal, VideoUploadSignal from tests.conftest import FakeApp, frame @@ -46,6 +48,15 @@ def post(self, **kwargs): return self.response +def test_upload_payload_uses_regular_video_defaults() -> None: + assert UploadPayload().to_payload() == { + "count": 1, + "type": 0, + "uploaderType": 0, + "profile": False, + } + + @pytest.mark.asyncio async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( monkeypatch: pytest.MonkeyPatch, @@ -70,22 +81,27 @@ async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( @pytest.mark.asyncio -async def test_upload_waiters_resolve_video_and_file_processing_signals() -> None: +async def test_upload_waiters_resolve_processing_signals() -> None: app = FakeApp() service = UploadService(app) loop = __import__("asyncio").get_running_loop() video_future = loop.create_future() file_future = loop.create_future() + voice_future = loop.create_future() service.video_upload_waiters[1] = video_future service.file_upload_waiters[2] = file_future + service.voice_upload_waiters[3] = voice_future await service.on_video_attach(VideoUploadSignal(video_id=1), None) await service.on_file_attach(FileUploadSignal(file_id=2), None) + await service.on_voice_attach(AudioUploadSignal(audio_id=3), None) assert video_future.result().video_id == 1 assert file_future.result().file_id == 2 + assert voice_future.result().audio_id == 3 assert service.video_upload_waiters == {} assert service.file_upload_waiters == {} + assert service.voice_upload_waiters == {} @pytest.mark.asyncio @@ -128,6 +144,53 @@ def resolve_processing() -> None: assert FakeHttpSession.posts[0]["url"] == "https://upload.test/video" +@pytest.mark.asyncio +async def test_upload_voice_posts_chunks_waits_for_processing_and_cleans_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp( + [ + frame( + { + "info": [ + { + "url": "https://upload.test/voice", + "videoId": 12, + "token": "voice-token", + } + ] + } + ) + ] + ) + 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) + monkeypatch.setattr( + "pymax.api.uploads.service.aiohttp.ClientSession", + FakeHttpSession, + ) + + result = await service.upload_voice(Voice(raw=b"voice", name="voice.ogg")) + + assert result.type == AttachmentType.AUDIO + assert result.video_id == 12 + assert result.token == "voice-token" + assert app.calls[0].payload == { + "count": 1, + "type": 2, + "uploaderType": 1, + "profile": False, + } + assert service.voice_upload_waiters == {} + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-4/5" + assert FakeHttpSession.posts[0]["url"] == "https://upload.test/voice" + + @pytest.mark.asyncio async def test_upload_file_posts_chunks_waits_for_processing_and_cleans_waiter( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/conftest.py b/tests/conftest.py index a160539..cbf6c5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ from pymax.fingerprint.fingerprint import FingerprintGenerator from pymax.protocol import Command, InboundFrame from pymax.session.models import SessionInfo -from pymax.types.domain import HandshakeResponse +from pymax.types.domain import AttachmentType, HandshakeResponse from pymax.types.domain.sync import SyncOverrides @@ -78,6 +78,11 @@ def __init__(self) -> None: token="video-token", ) self.file_result: AttachFilePayload | None = AttachFilePayload(file_id=30) + self.voice_result: VideoAttachPayload | None = VideoAttachPayload( + type=AttachmentType.AUDIO, + video_id=40, + token="voice-token", + ) self.calls: list[tuple[str, Any]] = [] async def upload_photo(self, photo: Any) -> AttachPhotoPayload | None: @@ -92,6 +97,10 @@ async def upload_file(self, file: Any) -> AttachFilePayload | None: self.calls.append(("file", file)) return self.file_result + async def upload_voice(self, voice: Any) -> VideoAttachPayload | None: + self.calls.append(("voice", voice)) + return self.voice_result + def mobile_user_agent( device_type: DeviceType = DeviceType.ANDROID, diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index bbf8a37..c340692 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -72,6 +72,10 @@ async def on_delete(event, _client): async def on_file(signal, _client): seen.append(("file", signal.file_id, None)) + @dispatcher.on_internal(EventType.VOICE_READY) + async def on_voice(signal, _client): + seen.append(("voice", signal.audio_id, None)) + await dispatcher.dispatch( frame( { @@ -97,11 +101,15 @@ async def on_file(signal, _client): await dispatcher.dispatch( frame({"fileId": 99}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) ) + await dispatcher.dispatch( + frame({"audioId": 100}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) + ) assert seen == [ ("chat", 5, True), ("delete", (1, 2), None), ("file", 99, None), + ("voice", 100, None), ] diff --git a/tests/files/test_files_and_formatting.py b/tests/files/test_files_and_formatting.py index 46ae036..d73a3eb 100644 --- a/tests/files/test_files_and_formatting.py +++ b/tests/files/test_files_and_formatting.py @@ -2,6 +2,7 @@ import pytest +from pymax import Voice from pymax.files import File, Photo from pymax.formatting.markdown import Formatter @@ -45,6 +46,10 @@ def test_file_and_photo_validation_errors() -> None: Photo(raw=b"not image", name="bad.txt").validate_photo() +def test_voice_derives_name_from_path() -> None: + assert Voice(path="recordings/voice.ogg").name == "voice.ogg" + + def test_photo_url_validation_ignores_query_string() -> None: photo = Photo(url="https://example.com/image.jpg?quality=95&as=32x20") From 2667f91cd00fa47224eddf9e05ce658a1b51612d Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:22:09 +0300 Subject: [PATCH 17/35] fix: StickerAttachment set_id type --- src/pymax/types/domain/attachments/sticker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymax/types/domain/attachments/sticker.py b/src/pymax/types/domain/attachments/sticker.py index 6ba4bbc..924fa6a 100644 --- a/src/pymax/types/domain/attachments/sticker.py +++ b/src/pymax/types/domain/attachments/sticker.py @@ -42,7 +42,7 @@ class StickerAttachment(CamelModel): sticker_id: int tags: list[str] | None = None width: int - set_id: int + set_id: int | None = None time: int sticker_type: str audio: bool From faa5ce90d62e8273f3dda9053bc3a05e60610368 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:26:32 +0300 Subject: [PATCH 18/35] fix: webosocket cap --- src/pymax/transport/websocket.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pymax/transport/websocket.py b/src/pymax/transport/websocket.py index 776564c..726065c 100644 --- a/src/pymax/transport/websocket.py +++ b/src/pymax/transport/websocket.py @@ -17,7 +17,10 @@ def __init__(self, url: str, proxy: str | None) -> None: async def connect(self) -> None: if self.proxy: self.ws = await client.connect( - self.url, origin=Origin("https://web.max.ru"), proxy=self.proxy + self.url, + origin=Origin("https://web.max.ru"), + proxy=self.proxy, + max_size=1024 * 1024 * 10, # 10 MB ) else: self.ws = await client.connect( From 958fa0642564b26cc164785df19470f1d1011f43 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:31:27 +0300 Subject: [PATCH 19/35] fix: some minor fixes --- src/pymax/types/domain/attachments/video.py | 2 +- src/pymax/types/domain/user.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pymax/types/domain/attachments/video.py b/src/pymax/types/domain/attachments/video.py index 8e89c13..f1f583a 100644 --- a/src/pymax/types/domain/attachments/video.py +++ b/src/pymax/types/domain/attachments/video.py @@ -67,7 +67,7 @@ class VideoRequest(CamelModel): """ external: str | bool | None = Field(default=None, alias="EXTERNAL") - cache: bool + cache: bool = False # TODO: idk maybe | None = None better url: str | None = None @model_validator(mode="before") diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index 2c04b60..0641faf 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -71,10 +71,8 @@ class User(CamelModel): phone: int | None = None status: str | None = None description: str | None = None - # Bots may send ``gender`` as a numeric code and ``web_app`` as a URL - # string instead of an object; accept these so profile parsing won't fail. gender: str | int | None = None - link: str | None = None + link: str | int | None = None web_app: dict[str, Any] | str | None = None menu_button: dict[str, Any] | None = None From 25e156cb47e7925cd6097a5d71f8e5295e83f67e Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:29:06 +0300 Subject: [PATCH 20/35] fix: handle missing handshake calls seed --- src/pymax/api/auth/service.py | 27 +++++-- src/pymax/app.py | 2 +- src/pymax/types/domain/handshake.py | 2 +- tests/api/test_auth_service.py | 79 ++++++++++++++++++- .../test_chat_user_self_session_services.py | 4 +- 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index fa8f7ce..38a917f 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -69,12 +69,21 @@ async def request_code(self, phone: str) -> StartAuthResponse: self.app.session.device_id if self.app.session else self.app.config.device.device_id ) - 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", - ) + if self.app.config.device.user_agent.device_type != DeviceType.WEB: + if self.app.handshake_response.calls_seed is None: + raise ValueError( + "Unexpected internal state: handshake_response.calls_seed is missing " + + "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", + ) + else: + mode = None frame = RequestCodePayload(phone=phone, mode=mode) response = await self.app.invoke(Opcode.AUTH_REQUEST, frame.to_payload()) @@ -148,6 +157,12 @@ async def mobile_login(self) -> LoginResponse: self.app.session.device_id if self.app.session else self.app.config.device.device_id ) + if self.app.handshake_response.calls_seed is None: + raise ValueError( + "Unexpected internal state: handshake_response.calls_seed is missing " + + "in AuthService.mobile_login. 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, diff --git a/src/pymax/app.py b/src/pymax/app.py index 4f87fed..69b0c51 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -149,7 +149,7 @@ async def start(self) -> None: elif login2_response and login2_response.profile: self.me = login2_response.profile else: - logger.error("Impossible state: login response does not contain profile") + logger.error("Unexpected internal state: login response does not contain profile") raise RuntimeError("Login response does not contain profile") except Exception as e: handled = False diff --git a/src/pymax/types/domain/handshake.py b/src/pymax/types/domain/handshake.py index c4d04c0..8ea5601 100644 --- a/src/pymax/types/domain/handshake.py +++ b/src/pymax/types/domain/handshake.py @@ -8,4 +8,4 @@ class HandshakeResponse(CamelModel): :vartype calls_seed: int """ - calls_seed: int + calls_seed: int | None = None diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index 4328988..b4f4b1d 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -6,7 +6,7 @@ from pymax.api.session.enums import DeviceType from pymax.protocol import Opcode from pymax.session.models import SessionInfo -from pymax.types.domain import Login2Flags +from pymax.types.domain import HandshakeResponse, Login2Flags from pymax.types.domain.sync import SyncState from tests.conftest import ( FakeApp, @@ -64,6 +64,67 @@ async def test_request_and_send_code_parse_auth_responses() -> None: assert app.calls[1].payload["verifyCode"] == "111111" +@pytest.mark.asyncio +async def test_web_request_code_omits_mode_without_calls_seed() -> None: + app = FakeApp( + [ + frame( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 2, + "altActionDuration": 5, + } + ) + ], + device_type=DeviceType.WEB, + ) + app.handshake_response = HandshakeResponse() + + await app.api.auth.request_code("+79990000000") + + assert app.calls[0].opcode == Opcode.AUTH_REQUEST + assert "mode" not in app.calls[0].payload + + +@pytest.mark.asyncio +async def test_mobile_request_code_requires_calls_seed() -> None: + app = FakeApp() + app.handshake_response = HandshakeResponse() + + with pytest.raises(ValueError, match="AuthService.request_code"): + await app.api.auth.request_code("+79990000000") + + assert app.calls == [] + + +@pytest.mark.asyncio +async def test_mobile_request_code_accepts_zero_calls_seed() -> None: + app = FakeApp( + [ + frame( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 2, + "altActionDuration": 5, + } + ) + ] + ) + app.handshake_response = HandshakeResponse(calls_seed=0) + + 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, + ) + + @pytest.mark.asyncio async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> None: app = FakeApp( @@ -209,6 +270,22 @@ async def test_login_without_session_raises_runtime_error() -> None: assert app.calls == [] +@pytest.mark.asyncio +async def test_mobile_login_requires_calls_seed() -> None: + app = FakeApp() + app.session = SessionInfo( + token="local-token", + device_id="device-test", + phone="+79990000000", + ) + app.handshake_response = HandshakeResponse() + + with pytest.raises(ValueError, match="AuthService.mobile_login"): + await app.api.auth.mobile_login() + + assert app.calls == [] + + @pytest.mark.asyncio async def test_set_2fa_runs_password_email_hint_and_final_commit() -> None: provider = StaticEmailProvider() diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 773a239..3b3f3ca 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -431,7 +431,7 @@ async def test_session_handshake_switches_between_mobile_and_web_payloads() -> N "device", ) - web_app = FakeApp([frame({"callsSeed": 202})], device_type=DeviceType.WEB) + web_app = FakeApp([frame({"serverTime": 123})], device_type=DeviceType.WEB) web_response = await web_app.api.session.handshake( "ignored", web_app.config.device.user_agent, @@ -445,7 +445,7 @@ async def test_session_handshake_switches_between_mobile_and_web_payloads() -> N assert "mt_instanceid" not in web_app.calls[0].payload assert mobile_response is not None assert mobile_response.calls_seed == 101 - assert web_response.calls_seed == 202 + assert web_response.calls_seed is None @pytest.mark.asyncio From 80fb9cfd7b0a9ec040dc1bd13e2c9adab79c1a3b Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:01:03 +0300 Subject: [PATCH 21/35] feat: add video note uploads --- pyproject.toml | 5 +++ src/pymax/api/messages/service.py | 4 +-- src/pymax/api/uploads/payloads.py | 4 +++ src/pymax/api/uploads/service.py | 45 +++++++++++++++++++------- src/pymax/files/__init__.py | 3 +- src/pymax/files/video.py | 52 +++++++++++++++++++++++++++++++ src/pymax/files/voice.py | 1 + src/pymax/types/domain/message.py | 4 +-- uv.lock | 16 ++++++++++ 9 files changed, 117 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7cffd59..f9b280e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,11 @@ dev = [ "ruff>=0.8.0", ] +[project.optional-dependencies] +video = [ + "tinytag>=2.2.1", +] + [tool.uv] package = true diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index d529aac..2be8817 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -18,7 +18,7 @@ VideoAttachPayload, ) from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video, Voice +from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.formatting.markdown import Formatter from pymax.logging import get_logger from pymax.protocol import Opcode @@ -56,7 +56,7 @@ if TYPE_CHECKING: from pymax.app import App -SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None logger = get_logger(__name__) diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 0ecdd20..0448431 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -13,6 +13,10 @@ class VideoAttachPayload(CamelModel): type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type") video_id: int token: str + video_type: int = 0 + thumbhash: bytes | None = None + duration: int | None = None + wave: bytes | None = None class AttachFilePayload(CamelModel): diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index 128dee2..afff890 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 from http import HTTPStatus from typing import TYPE_CHECKING from urllib.parse import parse_qs, quote, urlparse @@ -11,7 +12,7 @@ from pymax.api.response import payload_item from pymax.dispatch.enums import EventType from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video, Voice +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 @@ -313,11 +314,14 @@ 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, video: Video) -> VideoAttachPayload: + async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload: logger.info("Uploading video") logger.debug("Preparing video upload payload") - payload = UploadPayload().to_payload() + if isinstance(uploadable_video, VideoNote): + payload = UploadPayload(type=1, uploader_type=1).to_payload() + else: + payload = UploadPayload().to_payload() try: data = await self.app.invoke( @@ -351,7 +355,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: raise UploadError("Failed to get video upload info") from e try: - file_size = await video.size() + file_size = await uploadable_video.size() except Exception as e: logger.exception("Failed to get video size") raise UploadError("Failed to get video size") from e @@ -365,8 +369,8 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: timeout = aiohttp.ClientTimeout(total=900, sock_read=60) headers = { - "Content-Disposition": f"attachment; filename={quote(video.name)}", - "Content-Range": f"0-{file_size - 1}/{file_size}", + "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}", + "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", "Content-Length": str(file_size), "Connection": "keep-alive", } @@ -394,7 +398,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: async with session.post( url=upload_info.url, headers=headers, - data=video.iter_chunks(1024 * 1024), + data=uploadable_video.iter_chunks(1024 * 1024), ) as response: logger.debug( "Video upload HTTP response status=%s video_id=%s", @@ -414,11 +418,27 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: ) try: - logger.debug( - "Waiting for video processing notification video_id=%s", - video_id, - ) - await asyncio.wait_for(future, 60) + if isinstance(uploadable_video, VideoNote): + data = await response.json(content_type=None) + + thumbhash = data.get("thumbhash") + if thumbhash: + thumbhash += "=" * (-len(thumbhash) % 4) + thumbhash = base64.b64decode(thumbhash) + + return VideoAttachPayload( + video_id=video_id, + token=token, + video_type=1, + thumbhash=thumbhash, + duration=await uploadable_video.get_duration(), + ) + else: + logger.debug( + "Waiting for video processing notification video_id=%s", + video_id, + ) + await asyncio.wait_for(future, 60) except asyncio.TimeoutError: logger.warning( "Timed out waiting for video processing notification video_id=%s", @@ -429,6 +449,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: ) logger.debug("Video upload complete video_id=%s", video_id) + return VideoAttachPayload(video_id=video_id, token=token) except UploadError: diff --git a/src/pymax/files/__init__.py b/src/pymax/files/__init__.py index a639c28..b5f842d 100644 --- a/src/pymax/files/__init__.py +++ b/src/pymax/files/__init__.py @@ -1,11 +1,12 @@ from .file import File from .photo import Photo -from .video import Video +from .video import Video, VideoNote from .voice import Voice __all__ = ( "File", "Photo", "Video", + "VideoNote", "Voice", ) diff --git a/src/pymax/files/video.py b/src/pymax/files/video.py index 79fd8a9..5e2aa03 100644 --- a/src/pymax/files/video.py +++ b/src/pymax/files/video.py @@ -1,8 +1,14 @@ from collections.abc import AsyncGenerator +from io import BytesIO from pathlib import Path from .base import BaseFile +try: + from tinytag import TinyTag +except ImportError: + TinyTag = None + class Video(BaseFile): """Видео для отправки в сообщение. @@ -72,3 +78,49 @@ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: Async generator с байтами. """ return super().iter_chunks(size) + + +# TODO: add docs +class VideoNote(Video): + def __init__( + self, + raw: bytes | None = None, + *, + url: str | None = None, + path: str | None = None, + name: str | None = None, + 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) diff --git a/src/pymax/files/voice.py b/src/pymax/files/voice.py index 39e44fb..8e86ae6 100644 --- a/src/pymax/files/voice.py +++ b/src/pymax/files/voice.py @@ -4,6 +4,7 @@ from .base import BaseFile +# TODO: add docs. can be only ogg class Voice(BaseFile): def __init__( self, diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index d9b5cd5..e01b3de 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -5,7 +5,7 @@ from pydantic import Field, PrivateAttr, model_validator -from pymax.files import File, Photo, Video, Voice +from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.types.domain import ( AudioAttachment, CallAttachment, @@ -45,7 +45,7 @@ Field(discriminator="type"), ] Attachment: TypeAlias = KnownAttachment | UnknownAttachment -SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None diff --git a/uv.lock b/uv.lock index 840292f..a8669ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1031,6 +1031,11 @@ dependencies = [ { name = "zstandard" }, ] +[package.optional-dependencies] +video = [ + { name = "tinytag" }, +] + [package.dev-dependencies] dev = [ { name = "furo" }, @@ -1070,9 +1075,11 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0" }, { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.1" }, { name = "qrcode", specifier = ">=8.2" }, + { name = "tinytag", marker = "extra == 'video'", specifier = ">=2.2.1" }, { name = "websockets", specifier = ">=16.0" }, { name = "zstandard", specifier = ">=0.25.0" }, ] +provides-extras = ["video"] [package.metadata.requires-dev] dev = [ @@ -2153,6 +2160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tinytag" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214, upload-time = "2026-03-15T18:48:01.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927, upload-time = "2026-03-15T18:47:59.613Z" }, +] + [[package]] name = "tomli" version = "2.4.1" From b373f0cac890da3fa8d97f73f32eec50e027e373 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:12:11 +0300 Subject: [PATCH 22/35] feat: add is_update_available --- src/pymax/api/auth/service.py | 5 +++++ src/pymax/infra/auth.py | 3 +++ src/pymax/types/domain/handshake.py | 3 +++ 3 files changed, 11 insertions(+) diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 38a917f..0ca8504 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -457,3 +457,8 @@ async def confirm_registration( response = await self.app.invoke(Opcode.AUTH_CONFIRM, frame.to_payload()) return require_payload_model(response, ConfirmRegistrationResponse) + + def is_update_available(self) -> bool: + return bool( + self.app.handshake_response.app_update_type if self.app.handshake_response else False + ) diff --git a/src/pymax/infra/auth.py b/src/pymax/infra/auth.py index 932034b..46971df 100644 --- a/src/pymax/infra/auth.py +++ b/src/pymax/infra/auth.py @@ -95,3 +95,6 @@ async def check_2fa(self) -> bool: """ return await self._app.api.auth.check_2fa() + + def is_update_available(self) -> bool: + return self._app.api.auth.is_update_available() diff --git a/src/pymax/types/domain/handshake.py b/src/pymax/types/domain/handshake.py index 8ea5601..e6337bf 100644 --- a/src/pymax/types/domain/handshake.py +++ b/src/pymax/types/domain/handshake.py @@ -1,3 +1,5 @@ +from pydantic import Field + from .base import CamelModel @@ -9,3 +11,4 @@ class HandshakeResponse(CamelModel): """ calls_seed: int | None = None + app_update_type: int | None = Field(alias="app-update-type") # very bizarre casing From c73d520827cefa6747ecf9b0ccee4f33cdd80d4f Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:10:06 +0300 Subject: [PATCH 23/35] fix: some minor fixes --- src/pymax/api/uploads/service.py | 21 ++++++++++++++------- src/pymax/types/domain/handshake.py | 4 +++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index afff890..cc1e441 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -379,15 +379,16 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach "Video upload headers prepared content_range=%s", headers["Content-Range"], ) - - loop = asyncio.get_running_loop() - future: asyncio.Future[VideoUploadSignal] = loop.create_future() - - video_id = upload_info.video_id token = upload_info.token + video_id = upload_info.video_id + + future = None + if not isinstance(uploadable_video, VideoNote): + loop = asyncio.get_running_loop() + future = loop.create_future() + self.video_upload_waiters[video_id] = future - self.video_upload_waiters[video_id] = future - logger.debug("Video upload waiter registered video_id=%s", video_id) + logger.debug("Video upload waiter registered video_id=%s", video_id) try: async with aiohttp.ClientSession( @@ -434,6 +435,12 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach duration=await uploadable_video.get_duration(), ) else: + if future is None: + raise ValueError( + "Unexpected internal state: " + + "future is missing in UplpadService.upload_video." + + f" video type = {type(uploadable_video)}" + ) logger.debug( "Waiting for video processing notification video_id=%s", video_id, diff --git a/src/pymax/types/domain/handshake.py b/src/pymax/types/domain/handshake.py index e6337bf..4de92d2 100644 --- a/src/pymax/types/domain/handshake.py +++ b/src/pymax/types/domain/handshake.py @@ -11,4 +11,6 @@ class HandshakeResponse(CamelModel): """ calls_seed: int | None = None - app_update_type: int | None = Field(alias="app-update-type") # very bizarre casing + app_update_type: int | None = Field( + alias="app-update-type", default=None + ) # very bizarre casing From e4038c6f48b7219cbe3c1d2fb5af38b4a3552ae3 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:44:30 +0300 Subject: [PATCH 24/35] fix: stop client cleanly --- src/pymax/connection/connection.py | 47 ++++++++++++++++++----------- tests/connection/test_connection.py | 43 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/pymax/connection/connection.py b/src/pymax/connection/connection.py index f194d6a..2ad91e9 100644 --- a/src/pymax/connection/connection.py +++ b/src/pymax/connection/connection.py @@ -37,6 +37,8 @@ def __init__( self._recv_task: asyncio.Task[None] | None = None self._event_tasks: set[asyncio.Task[None]] = set() + self._closed_event = asyncio.Event() + self._closed_event.set() async def open(self) -> None: if self._is_open: @@ -48,6 +50,7 @@ async def open(self) -> None: self._is_open = True self._connection_lost = False self._close_reported = False + self._closed_event.clear() self._recv_task = asyncio.create_task(self._recv_loop()) logger.debug("receive loop started") @@ -58,25 +61,28 @@ async def close(self) -> None: return logger.info("closing connection") - if self._recv_task: - if not self._recv_task.done(): - self._recv_task.cancel() - with suppress(asyncio.CancelledError, Exception): - await self._recv_task - logger.debug("receive loop stopped") - self._recv_task = None - - for task in tuple(self._event_tasks): - if not task.done(): - task.cancel() - for task in tuple(self._event_tasks): - with suppress(asyncio.CancelledError, Exception): - await task - self._event_tasks.clear() - - self.requests.cancel_all() - await self.transport.close() self._is_open = False + try: + if self._recv_task: + if not self._recv_task.done(): + self._recv_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._recv_task + logger.debug("receive loop stopped") + self._recv_task = None + + for task in tuple(self._event_tasks): + if not task.done(): + task.cancel() + for task in tuple(self._event_tasks): + with suppress(asyncio.CancelledError, Exception): + await task + self._event_tasks.clear() + + self.requests.cancel_all() + await self.transport.close() + finally: + self._closed_event.set() logger.info("connection closed") async def fail(self, exc: Exception | None = None) -> None: @@ -147,6 +153,11 @@ async def wait_closed(self) -> None: try: await self._recv_task + except asyncio.CancelledError: + if self._is_open: + raise + await self._closed_event.wait() + return except Exception as e: if self._connection_lost: raise ConnectionError("Connection lost") from e diff --git a/tests/connection/test_connection.py b/tests/connection/test_connection.py index b176d00..6c26878 100644 --- a/tests/connection/test_connection.py +++ b/tests/connection/test_connection.py @@ -60,6 +60,12 @@ async def read(self) -> bytes: return item +class BlockingReader: + async def read(self) -> bytes: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + @pytest.mark.asyncio async def test_pending_requests_resolve_reject_discard_and_cancel() -> None: pending = PendingRequests() @@ -185,6 +191,43 @@ async def on_event(event: InboundFrame) -> None: assert [event.opcode for event in events] == [42] +@pytest.mark.asyncio +async def test_connection_wait_closed_returns_after_explicit_close() -> None: + manager = ConnectionManager( + reader=BlockingReader(), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + await manager.open() + waiter = asyncio.create_task(manager.wait_closed()) + await asyncio.sleep(0) + + await manager.close() + await waiter + + assert manager.is_open is False + + +@pytest.mark.asyncio +async def test_connection_wait_closed_propagates_external_cancellation() -> None: + manager = ConnectionManager( + reader=BlockingReader(), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + await manager.open() + waiter = asyncio.create_task(manager.wait_closed()) + await asyncio.sleep(0) + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + + await manager.close() + + @pytest.mark.asyncio async def test_send_requires_open_connection() -> None: manager = ConnectionManager( From a80b63f281de897dfb762b72a26241db0189e977 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:45:59 +0300 Subject: [PATCH 25/35] fix: correct public API types --- src/pymax/__init__.py | 3 ++- src/pymax/api/messages/service.py | 8 ++++---- src/pymax/infra/bots.py | 2 +- src/pymax/infra/message.py | 8 ++++---- src/pymax/infra/self.py | 3 ++- src/pymax/types/domain/chat.py | 6 +++--- src/pymax/types/domain/message.py | 10 +++++----- 7 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 655f37d..f4d054c 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -17,7 +17,7 @@ from .config import ExtraConfig, RegistrationConfig from .dispatch import EventType, Router from .exceptions import ApiError, PyMaxError, UploadError -from .files import File, Photo, Video, Voice +from .files import File, Photo, Video, VideoNote, Voice from .logging import configure_logging from .routers import ClientRouter, WebRouter from .types import ( @@ -66,6 +66,7 @@ "UploadError", "User", "Video", + "VideoNote", "Voice", "WebClient", "WebRouter", diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 2be8817..a6f0e85 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -128,7 +128,7 @@ async def send_message( attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0) if not text and not attachments: @@ -168,7 +168,7 @@ async def forward_message( source_chat_id: int | None = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: source_chat_id = chat_id if source_chat_id is None else source_chat_id logger.info( "forwarding message source_chat_id=%s chat_id=%s message_id=%s", @@ -270,7 +270,7 @@ async def fetch_history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: frame = ChatHistoryPayload( chat_id=chat_id, forward=forward, @@ -292,7 +292,7 @@ async def fetch_history( self.app, parse_payload_list(response, MessagePayloadKey.MESSAGES, Message), ) - return messages or None + return messages async def delete_message( self, diff --git a/src/pymax/infra/bots.py b/src/pymax/infra/bots.py index d64551f..0dc2a1f 100644 --- a/src/pymax/infra/bots.py +++ b/src/pymax/infra/bots.py @@ -9,7 +9,7 @@ class BotsMixin(IClientProtocol): async def get_bot_init_data( self, bot_id: int, - chat_id: int, + chat_id: int | None = None, start_param: str | None = None, ) -> InitData: """Получает начальные данные для бота в контексте конкретного чата. diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 7109a48..4b64abb 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -22,7 +22,7 @@ async def send_message( attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в чат. Args: @@ -72,7 +72,7 @@ async def forward_message( source_chat_id: int | None = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Пересылает существующее сообщение в чат. Args: @@ -115,7 +115,7 @@ async def edit_message( self, chat_id: int, message_id: int, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения сообщения. @@ -148,7 +148,7 @@ async def fetch_history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: """Загружает историю сообщений чата. Args: diff --git a/src/pymax/infra/self.py b/src/pymax/infra/self.py index f854801..a1ea3e2 100644 --- a/src/pymax/infra/self.py +++ b/src/pymax/infra/self.py @@ -1,5 +1,6 @@ from typing import Any +from pymax.files import Photo from pymax.types import FolderList, FolderUpdate from .protocol import IClientProtocol @@ -19,7 +20,7 @@ async def change_profile( first_name: str, last_name: str | None = None, description: str | None = None, - photo: Any | None = None, + photo: Photo | None = None, *, photo_token: str | None = None, ) -> bool: diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index fed0e86..70af71f 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -156,12 +156,12 @@ def bind( async def answer( self, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в этот чат. :param text: Текст сообщения. @@ -198,7 +198,7 @@ async def history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: """Загружает историю сообщений этого чата. ``from_time``, ``backward_time`` и ``forward_time`` передаются в diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index e01b3de..8e6e4af 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -185,11 +185,11 @@ def bind(self, actions: MessageService) -> Message: async def reply( self, - text: str, + text: str | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет ответ на это сообщение в тот же чат. :param text: Текст сообщения. @@ -216,12 +216,12 @@ async def reply( async def answer( self, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в тот же чат. :param text: Текст сообщения. @@ -253,7 +253,7 @@ async def forward( chat_id: int, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Пересылает это сообщение в другой чат. :param chat_id: ID целевого чата. From 5713a82ea55f77f40e75fc2bedd2e9a9b6ffe553 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:46:41 +0300 Subject: [PATCH 26/35] feat: add poll voting --- src/pymax/api/messages/payloads.py | 7 +++++ src/pymax/api/messages/service.py | 16 ++++++++++ src/pymax/infra/message.py | 15 ++++++++++ .../types/domain/attachments/__init__.py | 2 +- tests/api/test_message_service.py | 29 +++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 6d2645d..840857d 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -130,3 +130,10 @@ class ReadMessagesPayload(CamelModel): chat_id: int message_id: str | int # Сокет просит int а вс str mark: int + + +class VotePollPayload(CamelModel): + chat_id: int + message_id: int + poll_id: int + answers_ids: list[int] diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index a6f0e85..da6c23d 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -26,6 +26,7 @@ FileRequest, Message, Poll, + PollState, ReactionInfo, ReadState, VideoRequest, @@ -51,6 +52,7 @@ ReplyLink, SendMessagePayload, SendMessagePayloadMessage, + VotePollPayload, ) if TYPE_CHECKING: @@ -470,3 +472,17 @@ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: response = await self.app.invoke(Opcode.CHAT_MARK, frame.to_payload()) return require_payload_model(response, ReadState) + + async def vote_poll( + self, + chat_id: int, + message_id: int, + poll_id: int, + answer_ids: list[int], + ) -> PollState: + frame = VotePollPayload( + chat_id=chat_id, message_id=message_id, poll_id=poll_id, answers_ids=answer_ids + ) + response = await self.app.invoke(Opcode.SEND_VOTE, frame.to_payload()) + + return require_payload_item_model(response, "state", PollState) diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 4b64abb..0aa5676 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -3,6 +3,7 @@ from pymax.types import ( FileRequest, Message, + PollState, ReactionInfo, ReadState, VideoRequest, @@ -347,3 +348,17 @@ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: message_id=message_id, chat_id=chat_id, ) + + async def vote_poll( + self, + chat_id: int, + message_id: int, + poll_id: int, + answer_ids: list[int], + ) -> PollState: + return await self._app.api.messages.vote_poll( + chat_id=chat_id, + message_id=message_id, + poll_id=poll_id, + answer_ids=answer_ids, + ) diff --git a/src/pymax/types/domain/attachments/__init__.py b/src/pymax/types/domain/attachments/__init__.py index da26fb0..0aac71c 100644 --- a/src/pymax/types/domain/attachments/__init__.py +++ b/src/pymax/types/domain/attachments/__init__.py @@ -6,7 +6,7 @@ from .file import FileAttachment, FileRequest from .keyboards import InlineKeyboardAttachment from .photo import PhotoAttachment -from .poll import Poll, PollAnswer, PollAttachment +from .poll import Poll, PollAnswer, PollAttachment, PollState from .share import ShareAttachment from .sticker import StickerAttachment from .unknown import UnknownAttachment diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index d2d70b7..449dfe9 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -372,6 +372,35 @@ async def test_reaction_methods_parse_optional_reaction_info() -> None: ] +@pytest.mark.asyncio +async def test_vote_poll_builds_payload_and_parses_state() -> None: + app = FakeApp( + [ + frame( + { + "state": { + "total": 1, + "result": None, + "voterPreviewIds": [77], + } + } + ) + ] + ) + + state = await app.api.messages.vote_poll(100, 10, 42, [3]) + + assert state.total == 1 + assert state.voter_preview_ids == [77] + assert app.calls[0].opcode == Opcode.SEND_VOTE + assert app.calls[0].payload == { + "chatId": 100, + "messageId": 10, + "pollId": 42, + "answersIds": [3], + } + + @pytest.mark.asyncio async def test_get_video_and_file_by_id_parse_request_models() -> None: app = FakeApp( From 820354871eb0d40657384eef0ed04b3301e04337 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:50:37 +0300 Subject: [PATCH 27/35] fix: test fixes --- tests/api/test_message_service.py | 7 ++++++- tests/api/test_upload_service.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 449dfe9..56992e9 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -298,7 +298,12 @@ async def test_edit_message_uploads_single_and_multiple_attachments() -> None: assert app.calls[0].payload["attachments"] == [{"_type": "PHOTO", "photoToken": "photo-token"}] assert app.calls[1].payload["attachments"] == [ {"_type": "FILE", "fileId": 30}, - {"_type": "VIDEO", "videoId": 20, "token": "video-token"}, + { + "_type": "VIDEO", + "videoId": 20, + "token": "video-token", + "videoType": 0, + }, ] diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py index ed2390a..827863e 100644 --- a/tests/api/test_upload_service.py +++ b/tests/api/test_upload_service.py @@ -140,7 +140,7 @@ def resolve_processing() -> None: assert result.video_id == 10 assert result.token == "video-token" assert service.video_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/video" From fd1133c59fee4bea4d5c39fca290693c8f164b05 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:07:10 +0300 Subject: [PATCH 28/35] chore: prepare release 2.4.0 --- docs/account.rst | 10 ++++++ docs/api/files.rst | 8 +++++ docs/chats.rst | 34 ++++++++++++++++++ docs/client.rst | 26 ++++++++++++-- docs/files.rst | 37 +++++++++++++++++--- docs/index.rst | 1 + docs/messages.rst | 57 +++++++++++++++++++++++++++++-- docs/release-2-4-0.rst | 52 ++++++++++++++++++++++++++++ docs/types/enums.rst | 9 +++++ docs/types/index.rst | 3 +- docs/types/poll_attachment.rst | 6 ++++ pyproject.toml | 2 +- src/pymax/__init__.py | 2 +- src/pymax/files/video.py | 8 ++++- src/pymax/files/voice.py | 7 +++- src/pymax/infra/auth.py | 1 + src/pymax/infra/bots.py | 4 +-- src/pymax/infra/chat.py | 13 +++---- src/pymax/infra/message.py | 17 +++++++-- src/pymax/types/domain/chat.py | 12 +++---- src/pymax/types/domain/message.py | 18 +++++----- uv.lock | 2 +- 22 files changed, 283 insertions(+), 46 deletions(-) create mode 100644 docs/release-2-4-0.rst create mode 100644 docs/types/poll_attachment.rst diff --git a/docs/account.rst b/docs/account.rst index 83d6df6..9a1e8da 100644 --- a/docs/account.rst +++ b/docs/account.rst @@ -53,6 +53,16 @@ Account photo_token="PHOTO_TOKEN", ) +Статус присутствия +------------------ + +``set_presence()`` меняет статус, который будет использован при следующем +login или ping: + +.. code-block:: python + + client.set_presence(online=True) + Папки чатов ----------- diff --git a/docs/api/files.rst b/docs/api/files.rst index ab54f95..fe9cf6e 100644 --- a/docs/api/files.rst +++ b/docs/api/files.rst @@ -12,3 +12,11 @@ Files API .. autoclass:: pymax.Video :members: :show-inheritance: + +.. autoclass:: pymax.VideoNote + :members: + :show-inheritance: + +.. autoclass:: pymax.Voice + :members: + :show-inheritance: diff --git a/docs/chats.rst b/docs/chats.rst index 1f1d59b..26e353d 100644 --- a/docs/chats.rst +++ b/docs/chats.rst @@ -90,6 +90,40 @@ login/sync, а также методы для загрузки, создания ``invite()`` работает только для групп и каналов. Для личного диалога тип чата не подходит, и метод завершится ошибкой. +Участники и администраторы канала +--------------------------------- + +``get_chat_members()`` возвращает одну страницу участников и маркер следующей: + +.. code-block:: python + + members, marker = await client.get_chat_members(chat_id=123456, count=50) + for member in members: + print(member.contact.id) + + if marker: + next_members, marker = await client.get_chat_members( + chat_id=123456, + marker=marker, + count=50, + ) + +Назначить администратора канала можно с явным набором прав: + +.. code-block:: python + + from pymax.api.chats import ChannelPermissions + + await client.add_admin( + chat_id=123456, + user_id=111, + permissions=[ + ChannelPermissions.POST_MESSAGE, + ChannelPermissions.EDIT_MESSAGE, + ChannelPermissions.DELETE_MESSAGE, + ], + ) + Настройки и профиль группы -------------------------- diff --git a/docs/client.rst b/docs/client.rst index be51009..0fa4e3e 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -33,6 +33,18 @@ Client 6. Вызывается ``on_start``. 7. Клиент слушает события до закрытия соединения или отмены задачи. +Если ``start()`` запущен отдельной задачей, ``stop()`` штатно закрывает +соединение и завершает эту задачу без ``CancelledError``: + +.. code-block:: python + + import asyncio + + task = asyncio.create_task(client.start()) + # ... работа приложения ... + await client.stop() + await task + Данные после login ------------------ @@ -54,6 +66,15 @@ Client Для актуализации используйте явные методы: ``fetch_chats()``, ``get_chat()``, ``get_users()``, ``fetch_users()`` и ``fetch_history()``. +После handshake можно проверить, требует ли Max обновления версии приложения: + +.. code-block:: python + + @client.on_start() + async def on_start(client: Client) -> None: + if client.is_update_available(): + print("Для выбранной версии приложения доступно обновление") + Создание клиента ---------------- @@ -300,13 +321,14 @@ Debug-логи показывают handshake, login, входящие собы Клиент собирает несколько API-направлений: Сообщения - ``send_message()``, ``forward_message()``, ``fetch_history()``, + ``send_message()``, ``forward_message()``, ``fetch_history()``, ``vote_poll()``, ``delete_message()``, ``pin_message()``, ``read_message()``, реакции и получение URL для входящих файлов/видео. Чаты ``get_chat()``, ``fetch_chats()``, создание групп, invite-ссылки, - участники, настройки групп, удаление чатов и выход из групп/каналов. + участники, назначение администраторов, настройки групп, удаление чатов и + выход из групп/каналов. Пользователи ``get_user()``, ``get_users()``, ``fetch_users()``, ``search_by_phone()``, diff --git a/docs/files.rst b/docs/files.rst index 351afcc..f75ee95 100644 --- a/docs/files.rst +++ b/docs/files.rst @@ -4,7 +4,7 @@ Files Что это ------- -Для отправки вложений PyMax использует три класса: +Для отправки вложений PyMax использует пять основных классов: ``Photo`` Фото. Проверяет расширение и MIME-тип. @@ -15,6 +15,13 @@ Files ``File`` Обычный файл. Тоже загружается чанками и ждет событие готовности. +``Voice`` + Голосовое сообщение в формате OGG. + +``VideoNote`` + Круглое видеосообщение. Можно передать длительность вручную или установить + extra ``video`` для автоматического определения. + Как отправить файл ------------------ @@ -22,7 +29,7 @@ Files import asyncio - from pymax import Client, File, Photo, Video + from pymax import Client, File, Photo, Video, VideoNote, Voice client = Client(phone="+79990000000", work_dir="cache") @@ -46,6 +53,11 @@ Files attachments=[Video(path="clip.mp4")], ) + await chat.answer(attachments=[Voice(path="voice.ogg")]) + await chat.answer( + attachments=[VideoNote(path="circle.mp4", duration=4200)] + ) + asyncio.run(client.start()) @@ -59,9 +71,19 @@ Files Photo(path="image.jpg") File(url="https://example.com/report.pdf") Video(raw=b"...", name="clip.mp4") + Voice(path="voice.ogg") + VideoNote(path="circle.mp4", duration=4200) -Для ``raw`` обязательно указывайте ``name``. Для ``File`` и ``Video`` имя -берется из ``path`` или ``url``, если не передано явно. +Для ``raw`` обязательно указывайте ``name``. Для ``File``, ``Video``, +``Voice`` и ``VideoNote`` имя берется из ``path`` или ``url``, если не +передано явно. + +Если длительность ``VideoNote`` не передана, установите дополнительную +зависимость: + +.. code-block:: console + + uv add "maxapi-python[video]" Как работает upload ------------------- @@ -69,7 +91,8 @@ Files 1. PyMax запрашивает у Max временный upload URL. 2. Читает файл из ``path``, ``url`` или ``raw``. 3. Загружает данные HTTP-запросом. -4. Для ``File`` и ``Video`` ждет служебное событие готовности до 60 секунд. +4. Для ``File``, ``Video``, ``Voice`` и ``VideoNote`` ждет служебное событие + готовности до 60 секунд. 5. Подставляет token/file_id/video_id в отправляемое сообщение. Фото проходит проще: после HTTP-upload PyMax сразу достает token из ответа. @@ -115,3 +138,7 @@ Files Upload-сервис не получил нужный ответ от Max. Включите ``DEBUG``-логи: часто причина в недоступном URL, неверном размере файла, timeout или в том, что событие готовности файла не пришло за 60 секунд. + +``Automatic video duration detection requires the 'video' extra`` + Передайте ``duration`` в миллисекундах или установите + ``maxapi-python[video]``. diff --git a/docs/index.rst b/docs/index.rst index 955401e..a9885ba 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-4-0 release-2-3-1 release-2-3-0 release-2-2-0 diff --git a/docs/messages.rst b/docs/messages.rst index ff84f88..0c56f47 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -83,6 +83,57 @@ Messages source_chat_id=123456, ) +Опросы +------ + +Опрос можно отправить без текста. Настройки объединяются оператором ``|``: + +.. code-block:: python + + from pymax.types import Poll, PollAnswer, PollFlags + + await client.send_message( + chat_id=123456, + attachments=[ + Poll( + title="Какой вариант выбрать?", + answers=[ + PollAnswer(text="Первый"), + PollAnswer(text="Второй"), + ], + settings=( + PollFlags.FLAG_SETTINGS_ANONYMOUS + | PollFlags.FLAG_SETTINGS_REVOTE + ), + ) + ], + ) + +Для голосования нужны ID сообщения, опроса и вариантов из входящего +``PollAttachment``: + +.. code-block:: python + + from pymax import Message + from pymax.types import PollAttachment + + @client.on_message() + async def vote(message: Message, client: Client) -> None: + if message.chat_id is None: + return + + for attach in message.attaches: + if isinstance(attach, PollAttachment): + answer_id = attach.answers[0].answer_id + if answer_id is not None: + state = await client.vote_poll( + chat_id=message.chat_id, + message_id=message.id, + poll_id=attach.poll_id, + answer_ids=[answer_id], + ) + print(state.total) + Ответ, реакции, удаление и прочтение ---------------------------------------- @@ -147,7 +198,7 @@ Messages .. code-block:: python history = await client.fetch_history(chat_id=123456, backward=50) - for message in history or []: + for message in history: print(message.id, message.text) ``fetch_history()`` принимает ``item_type``. По умолчанию используются обычные @@ -178,8 +229,8 @@ Max присылает разные формы событий. Некоторы -------- Входящие вложения лежат в ``message.attaches``. Тип вложения определяется по -полю ``type``: фото, видео, файл, стикер, аудио, контакт, звонок, share или -inline-клавиатура. +полю ``type``: фото, видео, файл, стикер, аудио, опрос, контакт, звонок, share +или inline-клавиатура. .. code-block:: python diff --git a/docs/release-2-4-0.rst b/docs/release-2-4-0.rst new file mode 100644 index 0000000..b4a4174 --- /dev/null +++ b/docs/release-2-4-0.rst @@ -0,0 +1,52 @@ +PyMax 2.4.0 +=========== + +Изменения относительно ``2.3.1``. + +Добавлено +--------- + +* Поддержка отправки опросов и голосования через ``vote_poll()``. Входящие + опросы доступны как ``PollAttachment`` с типизированным состоянием и + результатами голосования. +* Голосовые сообщения через ``Voice`` и круглые видеосообщения через + ``VideoNote``. Оба класса экспортируются из ``pymax`` и принимаются в + ``attachments``. +* ``get_chat_members()`` с пагинацией и ``add_admin()`` с явным набором + ``ChannelPermissions``. +* ``set_presence()`` для управления статусом аккаунта и + ``is_update_available()`` для проверки результата handshake. +* Автоматическая генерация fingerprint для поддерживаемых Android-версий и + двухэтапный mobile login через LOGIN/LOGIN2. + +Исправлено +---------- + +* ``stop()`` штатно завершает ожидающий ``start()`` без утечки + ``CancelledError``; внешняя отмена задачи по-прежнему распространяется. +* Binary WebSocket-фреймы, выбор лучшего MP4 URL и частичные события реакций. +* Startup при handshake без ``callsSeed`` теперь завершается контролируемой + ошибкой вместо некорректного внутреннего состояния. +* Очистка upload-waiter-ов и обработка ошибок при загрузке голоса и видео. +* ``StickerAttachment.set_id`` и ряд публичных type hints, включая + ``send_message()``, ``forward_message()``, ``edit_message()`` и bound-методы + ``Message``/``Chat``. +* ``get_bot_init_data()`` теперь допускает запуск без ``chat_id``; + ``change_profile()`` принимает типизированный ``Photo``. + +Изменилось +---------- + +* ``send_message()``, ``forward_message()`` и их bound-варианты возвращают + ``Message``: отсутствие обязательного сообщения в ответе считается ошибкой. +* ``fetch_history()`` всегда возвращает ``list[Message]``; отсутствие сообщений + представлено пустым списком. +* ``send_message()``, ``Message.reply()``, ``Message.answer()`` и + ``Chat.answer()`` поддерживают сообщения только с вложениями без текста. + +Зависимости +----------- + +* Добавлен необязательный extra ``video`` с ``tinytag`` для автоматического + определения длительности ``VideoNote``. Если ``duration`` передан вручную, + extra не требуется. diff --git a/docs/types/enums.rst b/docs/types/enums.rst index 967cba2..03b480c 100644 --- a/docs/types/enums.rst +++ b/docs/types/enums.rst @@ -19,6 +19,15 @@ Domain enums .. autoclass:: pymax.types.domain.attachments.enums.TranscriptionStatus :members: +.. autoclass:: pymax.types.domain.attachments.enums.PollFlags + :members: + +Chat API enums +-------------- + +.. autoclass:: pymax.api.chats.ChannelPermissions + :members: + Client config enums ------------------- diff --git a/docs/types/index.rst b/docs/types/index.rst index 07d4c8b..dcac8d9 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -40,7 +40,7 @@ Types ``ContactInfo`` Контакт телефонной книги для ``import_contacts()``. -``PhotoAttachment``, ``VideoAttachment``, ``FileAttachment`` и другие +``PhotoAttachment``, ``VideoAttachment``, ``FileAttachment``, ``PollAttachment`` и другие Входящие вложения в ``message.attaches``. ``SyncState`` и ``SyncOverrides`` @@ -112,6 +112,7 @@ API reference control_attachment file_attachment photo_attachment + poll_attachment share_attachment sticker_attachment video_attachment diff --git a/docs/types/poll_attachment.rst b/docs/types/poll_attachment.rst new file mode 100644 index 0000000..e1f1f8f --- /dev/null +++ b/docs/types/poll_attachment.rst @@ -0,0 +1,6 @@ +PollAttachment +============== + +.. autoclass:: pymax.types.domain.attachments.poll.PollAttachment + :members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index f9b280e..3fec737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.3.1" +version = "2.4.0" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index f4d054c..8f5dbd1 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.1" +__version__ = "2.4.0" from .auth import ( diff --git a/src/pymax/files/video.py b/src/pymax/files/video.py index 5e2aa03..5474658 100644 --- a/src/pymax/files/video.py +++ b/src/pymax/files/video.py @@ -80,8 +80,14 @@ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: return super().iter_chunks(size) -# TODO: add docs class VideoNote(Video): + """Круглое видеосообщение для отправки. + + Принимает те же источники, что и ``Video``. Длительность задается в + миллисекундах. Если она не передана, требуется extra ``video`` для + автоматического определения длительности. + """ + def __init__( self, raw: bytes | None = None, diff --git a/src/pymax/files/voice.py b/src/pymax/files/voice.py index 8e86ae6..2196b39 100644 --- a/src/pymax/files/voice.py +++ b/src/pymax/files/voice.py @@ -4,8 +4,13 @@ from .base import BaseFile -# TODO: add docs. can be only ogg class Voice(BaseFile): + """Голосовое сообщение в формате OGG для отправки. + + Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно + передать имя файла. + """ + def __init__( self, raw: bytes | None = None, diff --git a/src/pymax/infra/auth.py b/src/pymax/infra/auth.py index 46971df..bdc6e87 100644 --- a/src/pymax/infra/auth.py +++ b/src/pymax/infra/auth.py @@ -97,4 +97,5 @@ async def check_2fa(self) -> bool: return await self._app.api.auth.check_2fa() def is_update_available(self) -> bool: + """Возвращает признак доступного обновления приложения по handshake.""" return self._app.api.auth.is_update_available() diff --git a/src/pymax/infra/bots.py b/src/pymax/infra/bots.py index 0dc2a1f..8688acb 100644 --- a/src/pymax/infra/bots.py +++ b/src/pymax/infra/bots.py @@ -12,11 +12,11 @@ async def get_bot_init_data( chat_id: int | None = None, start_param: str | None = None, ) -> InitData: - """Получает начальные данные для бота в контексте конкретного чата. + """Получает начальные данные для запуска бота. Args: bot_id: Идентификатор бота. - chat_id: Идентификатор чата, в котором бот будет использоваться. + chat_id: Необязательный ID чата, в котором бот будет использоваться. start_param: Необязательный параметр, передаваемый при запуске бота. diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 69ca396..3d11b93 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -402,16 +402,11 @@ async def add_admin( user_id: int, permissions: list[ChannelPermissions], ) -> None: - """ - Добавляет админа в канал + """Назначает пользователя администратором канала. Args: - chat_id: id чата - user_id: Айди юзера - permissions: Список разрешений для юзера - - Returns: - None - + chat_id: ID канала. + user_id: ID пользователя. + permissions: Непустой список прав администратора. """ return await self._app.api.chats.add_admin(chat_id, user_id, permissions) diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 0aa5676..92bd614 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -34,7 +34,7 @@ async def send_message( notify: Отправить ли получателям push-уведомление. Returns: - Отправленное сообщение или ``None``, если сервер не вернул его. + Отправленное сообщение. Raises: ValueError: Если не переданы ни текст, ни вложения. @@ -84,7 +84,7 @@ async def forward_message( notify: Отправить ли получателям push-уведомление. Returns: - Пересланное сообщение или ``None``, если сервер не вернул его. + Пересланное сообщение. """ return await self._app.api.messages.forward_message( chat_id=chat_id, @@ -166,7 +166,7 @@ async def fetch_history( interactive: Пометить запрос как интерактивный. Returns: - Сообщения или ``None``, если сервер не вернул список. + Список сообщений. Если сервер не вернул сообщения, список пуст. """ return await self._app.api.messages.fetch_history( chat_id=chat_id, @@ -356,6 +356,17 @@ async def vote_poll( poll_id: int, answer_ids: list[int], ) -> PollState: + """Отправляет выбранные ответы в опросе. + + Args: + chat_id: ID чата с опросом. + message_id: ID сообщения с опросом. + poll_id: ID опроса из ``PollAttachment.poll_id``. + answer_ids: ID выбранных вариантов ответа. + + Returns: + Обновленное состояние опроса. + """ return await self._app.api.messages.vote_poll( chat_id=chat_id, message_id=message_id, diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index 70af71f..4805a74 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -165,16 +165,15 @@ async def answer( """Отправляет сообщение в этот чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param reply_to: ID сообщения для ответа. :type reply_to: int | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если чат не привязан к клиенту. """ actions, _ = self._bound() @@ -224,8 +223,9 @@ async def history( :type get_messages: bool :param interactive: Пометить запрос как интерактивный. :type interactive: bool - :returns: Сообщения или ``None``, если сервер не вернул список. - :rtype: list[Message] | None + :returns: Список сообщений. Если сервер не вернул сообщения, список + пуст. + :rtype: list[Message] :raises RuntimeError: Если чат не привязан к клиенту. """ actions, _ = self._bound() diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 8e6e4af..fe84a28 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -193,14 +193,13 @@ async def reply( """Отправляет ответ на это сообщение в тот же чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ @@ -225,16 +224,15 @@ async def answer( """Отправляет сообщение в тот же чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param reply_to: ID сообщения для ответа. :type reply_to: int | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ @@ -260,8 +258,8 @@ async def forward( :type chat_id: int :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Пересланное сообщение или ``None``, если сервер его не вернул. - :rtype: Message | None + :returns: Пересланное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ diff --git a/uv.lock b/uv.lock index a8669ba..a5a4395 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.3.1" +version = "2.4.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 6f34ae1e1ec7ac37c3c57a36b5824e5d27c4e061 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:24:59 +0300 Subject: [PATCH 29/35] docs: document polls and media formats --- docs/files.rst | 32 +++++++++++++++++++++++++++++++- docs/release-2-4-0.rst | 15 +++++++++------ docs/types/index.rst | 1 + docs/types/poll.rst | 22 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 docs/types/poll.rst diff --git a/docs/files.rst b/docs/files.rst index f75ee95..989f5cb 100644 --- a/docs/files.rst +++ b/docs/files.rst @@ -16,7 +16,8 @@ Files Обычный файл. Тоже загружается чанками и ждет событие готовности. ``Voice`` - Голосовое сообщение в формате OGG. + Голосовое сообщение. Поддерживается только формат OGG; PyMax не + конвертирует другие аудиоформаты. ``VideoNote`` Круглое видеосообщение. Можно передать длительность вручную или установить @@ -85,6 +86,35 @@ Files uv add "maxapi-python[video]" +Формат Voice и VideoNote +------------------------ + +Для ``Voice`` используйте готовый OGG-файл. Простого переименования MP3, WAV +или другого аудиофайла в ``.ogg`` недостаточно: PyMax загружает исходные байты +без перекодирования. + +``VideoNote`` также не перекодирует видео. Для совместимости с официальным +клиентом 26.21.1 рекомендуется следующий формат: + +* контейнер MP4; +* видео H.264/AVC, 480x480, 30 FPS и bitrate около 1 024 000 bit/s; +* pixel format ``yuv420p`` при подготовке через FFmpeg; +* ключевой кадр примерно раз в секунду, то есть GOP около 30 кадров; +* аудио AAC в том же MP4-контейнере; +* длительность до 60 секунд. + +Официальный клиент задает квадратное разрешение, фиксированные 30 FPS и +максимальную длительность через server config. Нижняя граница в одну секунду +не является подтвержденным ограничением upload API, поэтому PyMax ее не +проверяет. + +Поворот лучше физически применить при перекодировании и убрать rotation +metadata: так файл меньше зависит от того, как конкретный клиент обработает +orientation hint. H.264 profile и level специально фиксировать не требуется. +``faststart`` официальный recorder явно не включает; при самостоятельной +подготовке файла его можно использовать, но для PyMax это не обязательное +условие. + Как работает upload ------------------- diff --git a/docs/release-2-4-0.rst b/docs/release-2-4-0.rst index b4a4174..26e6255 100644 --- a/docs/release-2-4-0.rst +++ b/docs/release-2-4-0.rst @@ -6,12 +6,15 @@ PyMax 2.4.0 Добавлено --------- -* Поддержка отправки опросов и голосования через ``vote_poll()``. Входящие - опросы доступны как ``PollAttachment`` с типизированным состоянием и - результатами голосования. -* Голосовые сообщения через ``Voice`` и круглые видеосообщения через - ``VideoNote``. Оба класса экспортируются из ``pymax`` и принимаются в - ``attachments``. +* Создание и отправка опросов через ``Poll``, ``PollAnswer`` и ``PollFlags``. + Опрос можно отправить как единственное вложение, без текста. +* Голосование через ``vote_poll()``. Входящие опросы доступны как + ``PollAttachment`` с типизированными ``PollState``, результатами и голосами. +* Голосовые сообщения через ``Voice``. Поддерживается только формат OGG; + библиотека не конвертирует другие аудиоформаты автоматически. +* Круглые видеосообщения через ``VideoNote``. Для совместимости рекомендуется + MP4 с H.264-видео 480x480, 30 FPS и AAC-аудио. ``Voice`` и ``VideoNote`` + экспортируются из ``pymax`` и принимаются в ``attachments``. * ``get_chat_members()`` с пагинацией и ``add_admin()`` с явным набором ``ChannelPermissions``. * ``set_presence()`` для управления статусом аккаунта и diff --git a/docs/types/index.rst b/docs/types/index.rst index dcac8d9..b82f594 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -112,6 +112,7 @@ API reference control_attachment file_attachment photo_attachment + poll poll_attachment share_attachment sticker_attachment diff --git a/docs/types/poll.rst b/docs/types/poll.rst new file mode 100644 index 0000000..fc74bb1 --- /dev/null +++ b/docs/types/poll.rst @@ -0,0 +1,22 @@ +Poll +==== + +.. autoclass:: pymax.types.domain.attachments.poll.Poll + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollAnswer + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollState + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollResult + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollVote + :members: + :show-inheritance: From d2cc1c8e6282541c4e7ae6afc73106008e7ea873 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:36:51 +0300 Subject: [PATCH 30/35] fix: reauthenticate after session revocation --- docs/release-2-4-0.rst | 3 + src/pymax/app.py | 13 ++++ src/pymax/base.py | 8 +++ src/pymax/transport/tcp.py | 7 +- tests/app/test_app_runtime.py | 68 +++++++++++++++++++ .../connection/test_readers_and_transports.py | 48 +++++++++++++ 6 files changed, 146 insertions(+), 1 deletion(-) diff --git a/docs/release-2-4-0.rst b/docs/release-2-4-0.rst index 26e6255..1748f3b 100644 --- a/docs/release-2-4-0.rst +++ b/docs/release-2-4-0.rst @@ -27,6 +27,9 @@ PyMax 2.4.0 * ``stop()`` штатно завершает ожидающий ``start()`` без утечки ``CancelledError``; внешняя отмена задачи по-прежнему распространяется. +* После отзыва текущей сессии на другом устройстве клиент удаляет только + недействительный локальный token и запускает авторизацию заново. Зависший + TLS shutdown больше не блокирует остановку клиента бесконечно. * Binary WebSocket-фреймы, выбор лучшего MP4 URL и частичные события реакций. * Startup при handshake без ``callsSeed`` теперь завершается контролируемой ошибкой вместо некорректного внутреннего состояния. diff --git a/src/pymax/app.py b/src/pymax/app.py index 69b0c51..f7ccefa 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -152,6 +152,9 @@ async def start(self) -> None: logger.error("Unexpected internal state: login response does not contain profile") raise RuntimeError("Login response does not contain profile") except Exception as e: + if self._is_invalid_login_token_error(e): + raise + handled = False if self.dispatcher.client is not None: handled = await self.dispatcher.emit_error( @@ -195,6 +198,16 @@ async def start(self) -> None: if self._telemetry: self._telemetry.start() + @staticmethod + def _is_invalid_login_token_error(exc: Exception) -> bool: + return ( + isinstance(exc, ApiError) + and exc.opcode == Opcode.LOGIN + and any( + err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL") + ) + ) + async def login(self) -> tuple[LoginResponse, Login2Response | None]: login_response = await self.api.auth.login( self.config.device.user_agent, diff --git a/src/pymax/base.py b/src/pymax/base.py index 4cf5dde..64bfe11 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -7,6 +7,7 @@ from pymax.dispatch import ErrorScope, Router from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator +from pymax.exceptions import ApiError from pymax.infra import BaseMixin from pymax.logging import get_logger @@ -138,6 +139,13 @@ async def start(self: ClientT) -> None: # noqa: PYI019 except asyncio.CancelledError: await self.close() raise + except ApiError as e: + if not self._app._is_invalid_login_token_error(e): + await self.close() + raise + + logger.warning("login token was revoked; starting authentication again") + await self.relogin(start=False) except ( # noqa: PERF203 ConnectionError, EOFError, diff --git a/src/pymax/transport/tcp.py b/src/pymax/transport/tcp.py index a0eb24b..309766c 100644 --- a/src/pymax/transport/tcp.py +++ b/src/pymax/transport/tcp.py @@ -7,6 +7,7 @@ from .base import Transport logger = get_logger(__name__) +_CLOSE_TIMEOUT = 5.0 class TCPTransport(Transport): @@ -66,7 +67,11 @@ async def close(self) -> None: if writer: logger.debug("tcp close") writer.close() - await writer.wait_closed() + try: + await asyncio.wait_for(writer.wait_closed(), _CLOSE_TIMEOUT) + except (OSError, TimeoutError) as e: + logger.warning("tcp close did not finish cleanly: %s", e) + writer.transport.abort() logger.debug("tcp closed") async def send(self, data: bytes | str) -> None: diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index 093e224..3e9dc8b 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -391,6 +391,74 @@ async def on_start(_client): assert store.closed is True +@pytest.mark.parametrize( + ("error", "message"), + [ + ("FAIL_LOGIN_TOKEN", "Token expired"), + ("login_failed", "FAIL_LOGIN_TOKEN"), + ], +) +@pytest.mark.asyncio +async def test_client_start_reauthenticates_after_login_token_revocation( + monkeypatch: pytest.MonkeyPatch, + error: str, + message: str, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + old_session = SessionInfo(token="revoked-token", device_id="dev", phone="+7") + store = RuntimeStore(old_session) + config = make_config().model_copy(update={"token": "revoked-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + InboundFrame( + opcode=Opcode.LOGIN, + cmd=Command.ERROR, + seq=1, + payload={ + "error": error, + "title": "Login failed", + "message": message, + "localizedMessage": "Session expired", + }, + ), + frame({"callsSeed": 456}), + frame( + { + "profile": profile_payload(77), + "token": "new-login-token", + "contacts": [profile_payload(77)["contact"]], + "chats": [], + "messages": {}, + } + ), + ] + ) + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + errors: list[Exception] = [] + + @root_router.on_error() + async def on_error(exc, ctx): + errors.append(exc) + + await client.start() + + assert errors == [] + assert store.deleted == ["revoked-token"] + assert store.saved[0].token == "auth-token" + assert store.loaded is not None + assert store.loaded.token == "new-login-token" + assert client.extra_config.token is None + assert client._config.token is None + assert client.me is not None + + @pytest.mark.asyncio async def test_client_start_emits_disconnect_before_reraising_without_reconnect( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 1767bb5..5104fc5 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio + import pytest from pymax.connection.readers.tcp import TCPReader @@ -62,6 +64,7 @@ class FakeStreamWriter: def __init__(self) -> None: self.writes: list[bytes] = [] self.closed = False + self.transport = FakeStreamTransport() def write(self, data: bytes) -> None: self.writes.append(data) @@ -76,6 +79,14 @@ async def wait_closed(self) -> None: return None +class FakeStreamTransport: + def __init__(self) -> None: + self.aborted = False + + def abort(self) -> None: + self.aborted = True + + @pytest.mark.asyncio async def test_tcp_transport_connect_send_recv_and_close( monkeypatch: pytest.MonkeyPatch, @@ -100,6 +111,43 @@ async def open_connection(*args, **kwargs): assert writer.closed is True +@pytest.mark.asyncio +async def test_tcp_transport_aborts_when_tls_close_stalls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + writer = FakeStreamWriter() + + async def wait_closed() -> None: + await asyncio.Event().wait() + + writer.wait_closed = wait_closed + monkeypatch.setattr("pymax.transport.tcp._CLOSE_TIMEOUT", 0.01) + transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) + transport._writer = writer + + await transport.close() + + assert writer.closed is True + assert writer.transport.aborted is True + + +@pytest.mark.asyncio +async def test_tcp_transport_ignores_tls_close_error() -> None: + writer = FakeStreamWriter() + + async def wait_closed() -> None: + raise OSError("SSL shutdown failed") + + writer.wait_closed = wait_closed + transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) + transport._writer = writer + + await transport.close() + + assert writer.closed is True + assert writer.transport.aborted is True + + @pytest.mark.asyncio async def test_tcp_transport_proxy_ssl_passes_server_hostname( monkeypatch: pytest.MonkeyPatch, From e3a905313180197e0619c675a931ab31a5a3b8c9 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:27:09 +0300 Subject: [PATCH 31/35] feat: add account privacy settings --- src/pymax/__init__.py | 3 ++ src/pymax/api/self/__init__.py | 3 +- src/pymax/api/self/enums.py | 9 ++++ src/pymax/api/self/payloads.py | 41 ++++++++++++++++++- src/pymax/api/self/service.py | 33 +++++++++++++++ src/pymax/infra/self.py | 12 ++++++ .../test_chat_user_self_session_services.py | 35 ++++++++++++++++ 7 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 8f5dbd1..bebc9b8 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,6 +1,7 @@ __version__ = "2.4.0" +from .api.self import PrivacyAccess, PrivacySettingsUpdate from .auth import ( AuthFlow, ConsolePasswordProvider, @@ -52,6 +53,8 @@ "Photo", "PresenceEvent", "Profile", + "PrivacyAccess", + "PrivacySettingsUpdate", "PyMaxError", "QrAuthFlow", "QrHandler", diff --git a/src/pymax/api/self/__init__.py b/src/pymax/api/self/__init__.py index e35e06a..e63e6a6 100644 --- a/src/pymax/api/self/__init__.py +++ b/src/pymax/api/self/__init__.py @@ -1,2 +1,3 @@ -from .enums import AvatarType, SelfPayloadKey +from .enums import AvatarType, PrivacyAccess, SelfPayloadKey +from .payloads import PrivacySettingsUpdate from .service import SelfService diff --git a/src/pymax/api/self/enums.py b/src/pymax/api/self/enums.py index 165aa1a..46cbb98 100644 --- a/src/pymax/api/self/enums.py +++ b/src/pymax/api/self/enums.py @@ -9,3 +9,12 @@ class SelfPayloadKey(str, Enum): PROFILE = "profile" URL = "url" TOKEN = "token" + HASH = "hash" + + +class PrivacyAccess(str, Enum): + """Уровень доступа к данным и действиям аккаунта.""" + + ALL = "ALL" + CONTACTS = "CONTACTS" + NOBODY = "_NONE_" diff --git a/src/pymax/api/self/payloads.py b/src/pymax/api/self/payloads.py index 47d3c89..f52904c 100644 --- a/src/pymax/api/self/payloads.py +++ b/src/pymax/api/self/payloads.py @@ -1,8 +1,10 @@ from typing import Any +from pydantic import BaseModel, Field + from pymax.api.models import CamelModel -from .enums import AvatarType +from .enums import AvatarType, PrivacyAccess class UploadPayload(CamelModel): @@ -39,3 +41,40 @@ class UpdateFolderPayload(CamelModel): class DeleteFolderPayload(CamelModel): folder_ids: list[str] + + +class PrivacySettingsUpdate(BaseModel): + """Изменяемые настройки приватности аккаунта. + + Все поля необязательны: в запрос попадают только переданные настройки. + + Args: + search_by_phone: Кто может найти аккаунт по номеру телефона. + incoming_calls: Кто может звонить аккаунту. + chat_invites: Кто может добавлять аккаунт в чаты. + phone_number_visibility: Кто может видеть номер телефона. + hide_online_status: Скрывать ли статус присутствия. + safe_content_only: Показывать только безопасный контент. + """ + + search_by_phone: PrivacyAccess | None = Field( + default=None, serialization_alias="SEARCH_BY_PHONE" + ) + incoming_calls: PrivacyAccess | None = Field(default=None, serialization_alias="INCOMING_CALL") + chat_invites: PrivacyAccess | None = Field(default=None, serialization_alias="CHATS_INVITE") + phone_number_visibility: PrivacyAccess | None = Field( + default=None, serialization_alias="PHONE_NUMBER_PRIVACY" + ) + + hide_online_status: bool | None = Field(default=None, serialization_alias="HIDDEN") + safe_content_only: bool | None = Field( + default=None, serialization_alias="CONTENT_LEVEL_ACCESS" + ) + + +class ChangeProfileSettings(CamelModel): + user: PrivacySettingsUpdate + + +class ChangeProfileSettingsPayload(CamelModel): + settings: ChangeProfileSettings diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 166046e..8b9ce77 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -18,9 +18,12 @@ from .enums import SelfPayloadKey from .payloads import ( ChangeProfilePayload, + ChangeProfileSettings, + ChangeProfileSettingsPayload, CreateFolderPayload, DeleteFolderPayload, GetFolderPayload, + PrivacySettingsUpdate, UpdateFolderPayload, UploadPayload, ) @@ -154,3 +157,33 @@ async def logout(self) -> bool: def set_presence(self, online: bool) -> None: logger.info("setting presence to %s", "online" if online else "offline") self.app.config.interactive = online + + async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool: + logger.info("changing profile settings") + + frame = ChangeProfileSettingsPayload(settings=ChangeProfileSettings(user=settings)) + + response = await self.app.invoke( + Opcode.CONFIG, + frame.to_payload(), + ) + session = self.app.session + + if not session: + logger.warning("no session found, skipping sync update") + return True + + sync = session.sync.model_copy( + update={"config_hash": require_payload_item(response, SelfPayloadKey.HASH)} + ) + + updated = session.model_copy( + update={ + "mt_instance_id": self.app.config.device.mt_instance_id, + "sync": sync, + }, + ) + self.app.session = updated + await self.app.store.save_session(updated) + + return True diff --git a/src/pymax/infra/self.py b/src/pymax/infra/self.py index a1ea3e2..903a41c 100644 --- a/src/pymax/infra/self.py +++ b/src/pymax/infra/self.py @@ -1,5 +1,6 @@ from typing import Any +from pymax.api.self import PrivacySettingsUpdate from pymax.files import Photo from pymax.types import FolderList, FolderUpdate @@ -145,3 +146,14 @@ def set_presence(self, *, online: bool) -> None: ``False``. """ self._app.api.account.set_presence(online) + + async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool: + """Обновляет настройки приватности текущего аккаунта. + + Args: + settings: Объект с новыми настройками приватности. + + Returns: + ``True``, если сервер принял запрос. + """ + return await self._app.api.account.change_profile_settings(settings=settings) diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 3b3f3ca..6d47a2b 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -2,6 +2,7 @@ import pytest +from pymax import PrivacyAccess, PrivacySettingsUpdate from pymax.api.session.enums import DeviceType from pymax.exceptions import PyMaxError from pymax.protocol import Opcode @@ -365,6 +366,40 @@ async def test_self_service_change_profile_and_close_all_sessions() -> None: ] +@pytest.mark.asyncio +async def test_change_profile_settings_updates_privacy_and_saved_config_hash() -> None: + app = FakeApp([frame({"hash": "new-config-hash"})]) + app.session = SessionInfo(token="token", device_id="dev", phone="+7") + + result = await app.api.account.change_profile_settings( + PrivacySettingsUpdate( + search_by_phone=PrivacyAccess.CONTACTS, + incoming_calls=PrivacyAccess.ALL, + chat_invites=PrivacyAccess.NOBODY, + phone_number_visibility=PrivacyAccess.CONTACTS, + hide_online_status=True, + safe_content_only=True, + ) + ) + + assert result is True + assert app.calls[0].opcode == Opcode.CONFIG + assert app.calls[0].payload == { + "settings": { + "user": { + "SEARCH_BY_PHONE": "CONTACTS", + "INCOMING_CALL": "ALL", + "CHATS_INVITE": "_NONE_", + "PHONE_NUMBER_PRIVACY": "CONTACTS", + "HIDDEN": True, + "CONTENT_LEVEL_ACCESS": True, + } + } + } + assert app.session.sync.config_hash == "new-config-hash" + assert app.store.saved_sessions == [app.session] + + @pytest.mark.asyncio async def test_close_all_sessions_returns_false_without_session_or_token() -> None: app = FakeApp() From b1468fd071e150ac5e10154a503ed7588ca8abdc Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:27:21 +0300 Subject: [PATCH 32/35] chore: update client fingerprints --- src/pymax/_data/apk_fingerprints.json | 136 ++++++++++++++++++++++++++ src/pymax/config.py | 19 +++- tests/api/test_auth_service.py | 27 +++++ 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/pymax/_data/apk_fingerprints.json b/src/pymax/_data/apk_fingerprints.json index 31d9fc7..8456b7b 100644 --- a/src/pymax/_data/apk_fingerprints.json +++ b/src/pymax/_data/apk_fingerprints.json @@ -542,5 +542,141 @@ "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" }, "build_number": 6763 + }, + "26.22.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "2cb7a73826370a9d19687d2c431335a4f512336fe5878db018746def973b2501", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6770 + }, + "26.22.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "4f043b856af584c701a42ffd28e6b6f8a78fdb6d266e907a12d45de2d9596fdd", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6772 + }, + "26.22.2": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "91e5fa68c7b36ac910e51b77d7d96fb82d16fcab38f41adbafd3e0926797f178", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6773 + }, + "26.23.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "49f6cba7368ce7be0179278429c658de043ee94a347d83bb4a0f0163e4a10bff", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6777 + }, + "26.23.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "53f15791a9ff7ac75a45b9df132c910eb2e3081c66f06fb97f0d7c1c976fab78", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6778 + }, + "26.23.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "38cff46f392dc1734c308be011c2f0d8da152a390b41063dbb2c913e3032f4b3", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6779 + }, + "26.24.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "73e8434d6524c3b6d7a90b63598befcf21f2d3b304e9ef3da153ac5bcbebdd99", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6784 + }, + "26.25.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "8db68fcc0e85e8f041fe4a875c0a9bcfe542a8f679603728c651ed81b64dd684", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6790 } } diff --git a/src/pymax/config.py b/src/pymax/config.py index a591dcb..6af6ed6 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -1,4 +1,4 @@ -from random import choice, randint +from random import choice, randint, random from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field @@ -11,7 +11,16 @@ from pymax.session import StoreProtocol from pymax.types.domain.sync import SyncOverrides +MIN_PREFERRED_BUILD = 6712 APP_VERSIONS: tuple[tuple[str, int], ...] = ( + ("26.25.0", 6790), + ("26.24.0", 6784), + ("26.23.2", 6779), + ("26.23.1", 6778), + ("26.23.0", 6777), + ("26.22.2", 6773), + ("26.22.1", 6772), + ("26.22.0", 6770), ("26.21.1", 6763), ("26.20.2", 6758), ("26.20.1", 6740), @@ -95,9 +104,12 @@ ("ru", "Asia/Yakutsk"), ("ru", "Asia/Vladivostok"), ) -WEB_APP_VERSION = "26.5.5" +WEB_APP_VERSION = "26.7.15" WEB_SCREEN = "1080x1920 1.0x" +PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD] +LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD] + class DeviceConfig(BaseModel): mt_instance_id: str @@ -225,7 +237,8 @@ def generate_user_agent(self) -> MobileUserAgentPayload: Returns: Случайная, но правдоподобная конфигурация Android-клиента Max. """ - app_version, build_number = choice(APP_VERSIONS) + 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/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index b4f4b1d..67bdb18 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -2,8 +2,12 @@ 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.protocol import Opcode from pymax.session.models import SessionInfo from pymax.types.domain import HandshakeResponse, Login2Flags @@ -29,6 +33,29 @@ async def get_code(self, email: str) -> str: return self.code +def test_supported_app_versions_match_packaged_fingerprints() -> None: + fingerprints = FingerprintGenerator().data + + assert set(fingerprints) == {version for version, _ in APP_VERSIONS} + assert all( + fingerprints[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) + + user_agent = ExtraConfig().generate_user_agent() + + assert (user_agent.build_number >= MIN_PREFERRED_BUILD) is preferred + + @pytest.mark.asyncio async def test_request_and_send_code_parse_auth_responses() -> None: app = FakeApp( From 4b82729b19adfbd64c07e6fe42e6df0ea2cfcfec Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:27:38 +0300 Subject: [PATCH 33/35] docs: finalize 2.4.0 release notes --- docs/release-2-4-0.rst | 262 ++++++++++++++++++++++++++++++++--------- 1 file changed, 207 insertions(+), 55 deletions(-) diff --git a/docs/release-2-4-0.rst b/docs/release-2-4-0.rst index 1748f3b..6055d8d 100644 --- a/docs/release-2-4-0.rst +++ b/docs/release-2-4-0.rst @@ -1,58 +1,210 @@ PyMax 2.4.0 =========== -Изменения относительно ``2.3.1``. - -Добавлено ---------- - -* Создание и отправка опросов через ``Poll``, ``PollAnswer`` и ``PollFlags``. - Опрос можно отправить как единственное вложение, без текста. -* Голосование через ``vote_poll()``. Входящие опросы доступны как - ``PollAttachment`` с типизированными ``PollState``, результатами и голосами. -* Голосовые сообщения через ``Voice``. Поддерживается только формат OGG; - библиотека не конвертирует другие аудиоформаты автоматически. -* Круглые видеосообщения через ``VideoNote``. Для совместимости рекомендуется - MP4 с H.264-видео 480x480, 30 FPS и AAC-аудио. ``Voice`` и ``VideoNote`` - экспортируются из ``pymax`` и принимаются в ``attachments``. -* ``get_chat_members()`` с пагинацией и ``add_admin()`` с явным набором - ``ChannelPermissions``. -* ``set_presence()`` для управления статусом аккаунта и - ``is_update_available()`` для проверки результата handshake. -* Автоматическая генерация fingerprint для поддерживаемых Android-версий и - двухэтапный mobile login через LOGIN/LOGIN2. - -Исправлено ----------- - -* ``stop()`` штатно завершает ожидающий ``start()`` без утечки - ``CancelledError``; внешняя отмена задачи по-прежнему распространяется. -* После отзыва текущей сессии на другом устройстве клиент удаляет только - недействительный локальный token и запускает авторизацию заново. Зависший - TLS shutdown больше не блокирует остановку клиента бесконечно. -* Binary WebSocket-фреймы, выбор лучшего MP4 URL и частичные события реакций. -* Startup при handshake без ``callsSeed`` теперь завершается контролируемой - ошибкой вместо некорректного внутреннего состояния. -* Очистка upload-waiter-ов и обработка ошибок при загрузке голоса и видео. -* ``StickerAttachment.set_id`` и ряд публичных type hints, включая - ``send_message()``, ``forward_message()``, ``edit_message()`` и bound-методы - ``Message``/``Chat``. -* ``get_bot_init_data()`` теперь допускает запуск без ``chat_id``; - ``change_profile()`` принимает типизированный ``Photo``. - -Изменилось ----------- - -* ``send_message()``, ``forward_message()`` и их bound-варианты возвращают - ``Message``: отсутствие обязательного сообщения в ответе считается ошибкой. -* ``fetch_history()`` всегда возвращает ``list[Message]``; отсутствие сообщений - представлено пустым списком. -* ``send_message()``, ``Message.reply()``, ``Message.answer()`` и - ``Chat.answer()`` поддерживают сообщения только с вложениями без текста. - -Зависимости ------------ - -* Добавлен необязательный extra ``video`` с ``tinytag`` для автоматического - определения длительности ``VideoNote``. Если ``duration`` передан вручную, - extra не требуется. +Версия 2.4.0 добавляет опросы, голосовые сообщения, кружки, настройки +приватности и новые методы управления чатами. Одновременно уточнены контракты +существующих методов и исправлены проблемы с lifecycle клиента, повторной +авторизацией и WebSocket. + +Ниже перечислены изменения относительно ``2.3.1``, которые важны при +использовании библиотеки и обновлении существующего кода. + +Новый публичный API +------------------- + +Опросы +~~~~~~ + +Добавлены модели ``Poll``, ``PollAnswer`` и ``PollFlags`` для создания +опросов. Опрос отправляется обычным ``send_message()`` и может быть +единственным вложением без текста: + +.. code-block:: python + + from pymax.types import Poll, PollAnswer, PollFlags + + message = await client.send_message( + chat_id=123456, + attachments=[ + Poll( + title="Выберите вариант", + answers=[ + PollAnswer(text="Первый"), + PollAnswer(text="Второй"), + ], + settings=PollFlags.FLAG_SETTINGS_REVOTE, + ) + ], + ) + +Для голосования добавлен метод: + +``await client.vote_poll(chat_id, message_id, poll_id, answer_ids) -> PollState`` + +Входящий опрос представлен моделью ``PollAttachment``. Она содержит +``poll_id``, варианты ответа и текущее состояние ``PollState`` с +типизированными результатами и голосами. + +Голосовые сообщения и кружки +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Добавлены два типа отправляемых вложений: + +``Voice`` + Голосовое сообщение. Принимает только готовый OGG-файл. PyMax не + перекодирует MP3, WAV и другие форматы. + +``VideoNote`` + Круглое видеосообщение. Рекомендуемый формат — MP4 с H.264-видео 480x480, + 30 FPS и AAC-аудио. Длительность передается в миллисекундах через + ``duration``. + +Оба класса экспортируются из ``pymax`` и передаются в ``attachments``: + +.. code-block:: python + + from pymax import VideoNote, Voice + + await client.send_message( + chat_id=123456, + attachments=[Voice(path="voice.ogg")], + ) + await client.send_message( + chat_id=123456, + attachments=[VideoNote(path="circle.mp4", duration=4200)], + ) + +Если ``duration`` для ``VideoNote`` не указан, PyMax может определить его +автоматически с необязательной зависимостью ``video``:: + + uv add "maxapi-python[video]" + +Чаты и состояние аккаунта +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: Новые методы + :header-rows: 1 + :widths: 35 40 25 + + * - Метод + - Назначение + - Результат + * - ``get_chat_members(chat_id, marker=None, count=50)`` + - Получить страницу участников чата. Ненулевой ``marker`` передается в + следующий вызов. + - ``tuple[list[Member], int]`` + * - ``add_admin(chat_id, user_id, permissions)`` + - Назначить администратора канала с явным списком + ``ChannelPermissions``. + - ``None`` + * - ``set_presence(online=...)`` + - Изменить статус, используемый при следующем login или ping. + - ``None`` + * - ``is_update_available()`` + - Проверить флаг обновления, полученный при handshake. + - ``bool`` + +Настройки приватности +~~~~~~~~~~~~~~~~~~~~~ + +Добавлен метод +``await client.change_profile_settings(settings) -> bool``. Он принимает +``PrivacySettingsUpdate`` и изменяет только заполненные поля: + +.. code-block:: python + + from pymax import PrivacyAccess, PrivacySettingsUpdate + + await client.change_profile_settings( + PrivacySettingsUpdate( + search_by_phone=PrivacyAccess.CONTACTS, + incoming_calls=PrivacyAccess.ALL, + chat_invites=PrivacyAccess.NOBODY, + phone_number_visibility=PrivacyAccess.CONTACTS, + hide_online_status=True, + safe_content_only=True, + ) + ) + +Для настроек доступа используются ``PrivacyAccess.ALL``, +``PrivacyAccess.CONTACTS`` и ``PrivacyAccess.NOBODY``. После успешного запроса +PyMax сохраняет новый ``config_hash`` в текущей сессии, поэтому следующая +авторизация продолжает синхронизацию с актуального состояния. + +Изменения существующего API +--------------------------- + +.. list-table:: Изменившиеся контракты + :header-rows: 1 + :widths: 30 30 40 + + * - API + - Было в 2.3.1 + - Стало в 2.4.0 + * - ``send_message()`` и ``forward_message()`` + - Возвращаемый тип допускал ``Message | None``. + - Возвращают ``Message``. Если сервер не прислал обязательный объект + сообщения, вызов завершается ошибкой. + * - ``Message.reply()``, ``Message.answer()``, ``Message.forward()`` и + ``Chat.answer()`` + - Bound-методы также имели необязательный возвращаемый тип. + - Возвращают ``Message`` без лишней проверки на ``None``. + * - ``send_message()``, ``edit_message()``, ``Message.reply()``, + ``Message.answer()`` и ``Chat.answer()`` + - Для type checker требовался текст. + - ``text`` может быть ``None``, если переданы вложения. Если нет ни + текста, ни вложений, выбрасывается ``ValueError``. + * - ``fetch_history()`` + - При отсутствии сообщений мог вернуть ``None``. + - Всегда возвращает ``list[Message]``; пустая история — ``[]``. + * - ``get_bot_init_data()`` + - ``chat_id`` был обязательным. + - ``chat_id`` необязателен, поэтому метод можно вызвать вне чата. + * - ``change_profile()`` + - ``photo`` был типизирован как ``Any``. + - ``photo`` принимает ``Photo | None``. + +Также исправлены типы частичных моделей протокола. В частности, +``StickerAttachment.set_id`` теперь имеет тип ``int | None``, а поля неполных +событий реакций больше не считаются безусловно заполненными. + +Breaking changes +---------------- + +Методы и классы из 2.3.1 не удалялись и не переименовывались. Проверить перед +обновлением нужно два наблюдаемых изменения поведения: + +* Код, который отличал ``None`` от пустого списка после ``fetch_history()``, + должен проверять пустоту списка: ``if not history``. +* Попытка отправить или отредактировать сообщение без текста и без вложений + теперь сразу завершается ``ValueError``. Сообщения только с вложением + поддерживаются штатно. + +Изменения ``Message | None`` на ``Message`` у методов отправки и пересылки — +уточнение публичного контракта, а не новое успешное значение в runtime. +Пользователи mypy или Pyright могут удалить лишние проверки результата на +``None``. При работе со ``StickerAttachment.set_id`` проверка на ``None``, +наоборот, теперь требуется. + +Исправления стабильности и совместимости +---------------------------------------- + +* Если текущую сессию отозвали с другого устройства, ответы + ``FAIL_LOGIN_TOKEN`` и ``FAIL_LOGOUT_ALL`` больше не оставляют клиент в + цикле переподключений. PyMax удаляет недействительный локальный token и + запускает авторизацию заново. Закрытие TLS-соединения ограничено timeout, + поэтому зависший shutdown не блокирует клиент бесконечно. +* ``stop()`` штатно завершает ожидающий ``start()``. Внешняя отмена задачи при + этом не поглощается и продолжает распространяться вызывающему коду. +* WebSocket-клиент использует актуальный endpoint и корректно обрабатывает + бинарные protocol frames. +* Mobile login поддерживает двухэтапный LOGIN/LOGIN2. Fingerprint устройства + выбирается автоматически из данных Android-версий от 26.9.1 до 26.25.0. +* Handshake без обязательного ``callsSeed`` завершается контролируемой ошибкой, + а не оставляет клиент в частично инициализированном состоянии. +* Исправлены очистка ожиданий при ошибках upload, выбор MP4 URL по качеству и + разбор частичных событий реакций и голосов в опросах. + +Базовый набор зависимостей не изменился. Extra ``video`` нужен только для +автоматического определения длительности ``VideoNote``; при явном +``duration`` он не требуется. From f73fec6e2cd8bacbfc20bc63b8c1bfa79f89bda5 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:31:25 +0300 Subject: [PATCH 34/35] fix: remove useless test --- .../connection/test_readers_and_transports.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 5104fc5..6f558a0 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -111,26 +111,6 @@ async def open_connection(*args, **kwargs): assert writer.closed is True -@pytest.mark.asyncio -async def test_tcp_transport_aborts_when_tls_close_stalls( - monkeypatch: pytest.MonkeyPatch, -) -> None: - writer = FakeStreamWriter() - - async def wait_closed() -> None: - await asyncio.Event().wait() - - writer.wait_closed = wait_closed - monkeypatch.setattr("pymax.transport.tcp._CLOSE_TIMEOUT", 0.01) - transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) - transport._writer = writer - - await transport.close() - - assert writer.closed is True - assert writer.transport.aborted is True - - @pytest.mark.asyncio async def test_tcp_transport_ignores_tls_close_error() -> None: writer = FakeStreamWriter() From 3bcc6971d2fecb13fd13920c70815ad5e3f6b525 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:00:38 +0300 Subject: [PATCH 35/35] fix: minor fixes before release --- src/pymax/api/uploads/service.py | 2 +- src/pymax/transport/websocket.py | 2 +- src/pymax/types/domain/message.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index cc1e441..e79d504 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -438,7 +438,7 @@ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttach if future is None: raise ValueError( "Unexpected internal state: " - + "future is missing in UplpadService.upload_video." + + "future is missing in UploadService.upload_video." + f" video type = {type(uploadable_video)}" ) logger.debug( diff --git a/src/pymax/transport/websocket.py b/src/pymax/transport/websocket.py index 726065c..7e0baff 100644 --- a/src/pymax/transport/websocket.py +++ b/src/pymax/transport/websocket.py @@ -24,7 +24,7 @@ async def connect(self) -> None: ) else: self.ws = await client.connect( - self.url, origin=Origin("https://web.max.ru") + self.url, origin=Origin("https://web.max.ru"), max_size=1024 * 1024 * 10 ) # TODO: origin should be configurable async def close(self) -> None: diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index fe84a28..ca636fc 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -292,13 +292,13 @@ async def pin(self, notify_pin: bool = True) -> bool: async def edit( self, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения этого сообщения. :param text: Новый текст сообщения с поддержкой markdown. - :type text: str + :type text: str | None :param attachments: Новые файлы, фотографии или видео для сообщения. :type attachments: SendAttachments :returns: Отредактированное сообщение.