From d1435b62d9006ac3dd57691ed9283429647700df Mon Sep 17 00:00:00 2001 From: MikeMike88 <149670168+MikeMike88@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:51:41 +0500 Subject: [PATCH 1/4] fix: parse bot account profiles MAX bots send `gender` as a numeric code and `web_app` as a URL string instead of an object, so `User.model_validate` raised a ValidationError and the bot user failed to load. Widen both field types (gender: str | int, web_app: dict | str) and add regression tests. Co-Authored-By: Claude Opus 4.8 --- src/pymax/types/domain/user.py | 10 +++++---- tests/domain/test_user_models.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 tests/domain/test_user_models.py diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index 5059428..2c04b60 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -49,11 +49,11 @@ class User(CamelModel): :ivar description: Описание профиля. :vartype description: str | None :ivar gender: Пол пользователя. - :vartype gender: str | None + :vartype gender: str | int | None :ivar link: Ссылка на профиль. :vartype link: str | None :ivar web_app: Данные связанного web-приложения, если есть. - :vartype web_app: dict[str, Any] | None + :vartype web_app: dict[str, Any] | str | None :ivar menu_button: Данные кнопки меню профиля, если есть. :vartype menu_button: dict[str, Any] | None """ @@ -71,9 +71,11 @@ class User(CamelModel): phone: int | None = None status: str | None = None description: str | None = None - gender: 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 - web_app: dict[str, Any] | None = None + web_app: dict[str, Any] | str | None = None menu_button: dict[str, Any] | None = None _actions: UserService | None = PrivateAttr(default=None) diff --git a/tests/domain/test_user_models.py b/tests/domain/test_user_models.py new file mode 100644 index 0000000..27a1b58 --- /dev/null +++ b/tests/domain/test_user_models.py @@ -0,0 +1,35 @@ +from pymax.types.domain import User + + +def test_user_parses_bot_gender_int_and_web_app_url() -> None: + """Bot accounts send ``gender`` as a numeric code and ``web_app`` as a URL + string (observed for the "Алиса AI" bot); the profile must still parse.""" + payload = { + "id": 6738397, + "names": [{"name": "Алиса AI", "type": "NICK"}], + "gender": 1, + "webApp": "https://alice.yandex.ru/max_onboarding", + } + + user = User.model_validate(payload) + + assert user.gender == 1 + assert user.web_app == "https://alice.yandex.ru/max_onboarding" + + +def test_user_parses_human_without_optional_fields() -> None: + """Regular users come without ``gender`` and ``web_app``.""" + payload = {"id": 1, "names": [{"name": "Test User", "type": "NICK"}]} + + user = User.model_validate(payload) + + assert user.gender is None + assert user.web_app is None + + +def test_user_still_accepts_dict_web_app() -> None: + """The dict type for ``web_app`` is kept from the original PyMax schema + (no real example of this format was seen, but we keep compatibility).""" + user = User.model_validate({"id": 2, "webApp": {}}) + + assert user.web_app == {} From c1a31045e3396876d83f17aef13c8a69adaf6e18 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:14:39 +0300 Subject: [PATCH 2/4] fix: decode LZ4 and Zstd TCP payloads --- pyproject.toml | 1 + src/pymax/protocol/tcp/compression.py | 18 ++++++ src/pymax/protocol/tcp/payload.py | 24 +++++-- src/pymax/protocol/tcp/protocol.py | 6 +- tests/protocol/test_protocols.py | 46 +++++++++++++- uv.lock | 92 +++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 08d2dd3..3013dcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "python-socks[asyncio]>=2.8.1", "qrcode>=8.2", "websockets>=16.0", + "zstandard>=0.25.0", ] [project.urls] diff --git a/src/pymax/protocol/tcp/compression.py b/src/pymax/protocol/tcp/compression.py index 3de8e18..792cc27 100644 --- a/src/pymax/protocol/tcp/compression.py +++ b/src/pymax/protocol/tcp/compression.py @@ -1,3 +1,8 @@ +from io import BytesIO + +import zstandard + + class Lz4BlockCompression: def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes: dst = bytearray() @@ -95,3 +100,16 @@ def compress(self, src: bytes) -> bytes: dst.extend(src[lit_start : lit_start + lit_len]) return bytes(dst) + + +class ZstdCompression: + def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes: + try: + with zstandard.ZstdDecompressor().stream_reader(BytesIO(src)) as reader: + result = reader.read(max_output + 1) + except zstandard.ZstdError as e: + raise ValueError("Zstd: failed to decompress payload") from e + + if len(result) > max_output: + raise ValueError("Zstd: output too large") + return result diff --git a/src/pymax/protocol/tcp/payload.py b/src/pymax/protocol/tcp/payload.py index b814b1a..3d94012 100644 --- a/src/pymax/protocol/tcp/payload.py +++ b/src/pymax/protocol/tcp/payload.py @@ -5,7 +5,7 @@ from pymax.logging import get_logger -from .compression import Lz4BlockCompression +from .compression import Lz4BlockCompression, ZstdCompression logger = get_logger(__name__) @@ -70,9 +70,11 @@ def __init__( *, serializer: MsgpackPayloadCodec, compression: Lz4BlockCompression | None = None, + zstd_compression: ZstdCompression | None = None, ) -> None: self.serializer = serializer self.compression = compression + self.zstd_compression = zstd_compression def _normalize_keys(self, obj: Any) -> Any: if isinstance(obj, dict): @@ -97,12 +99,26 @@ def decode(self, payload_bytes: bytes, flags: int = 0) -> dict[str, Any]: if not payload_bytes: return {} - if flags & 0x03 and self.compression: + if flags == 0xFF: + if self.zstd_compression is None: + raise ValueError("Zstd-compressed TCP payload without a decoder") + try: + payload_bytes = self.zstd_compression.decompress(payload_bytes) + logger.debug("tcp payload decompressed with Zstd") + except ValueError: + logger.debug("tcp Zstd payload decompression failed", exc_info=True) + raise + elif flags > 0x7F: + raise ValueError(f"invalid TCP compression factor: {flags}") + elif flags > 0: + if self.compression is None: + raise ValueError("LZ4-compressed TCP payload without a decoder") try: payload_bytes = self.compression.decompress(payload_bytes) - logger.debug("tcp payload decompressed flags=%s", flags) + logger.debug("tcp payload decompressed cof=%s", flags) except ValueError: - logger.debug("tcp payload decompress skipped flags=%s", flags) + logger.debug("tcp payload decompression failed cof=%s", flags, exc_info=True) + raise result = self.serializer.decode(payload_bytes) return self._normalize_keys(result) diff --git a/src/pymax/protocol/tcp/protocol.py b/src/pymax/protocol/tcp/protocol.py index 29a59dd..cf1bb2b 100644 --- a/src/pymax/protocol/tcp/protocol.py +++ b/src/pymax/protocol/tcp/protocol.py @@ -7,6 +7,7 @@ Lz4BlockCompression, MsgpackPayloadCodec, TcpPayloadDecoder, + ZstdCompression, ) logger = get_logger(__name__) @@ -20,8 +21,11 @@ def __init__(self) -> None: self.framer = TcpPacketFramer() self.serializer = MsgpackPayloadCodec() self.compression = Lz4BlockCompression() + self.zstd_compression = ZstdCompression() self.payload_decoder = TcpPayloadDecoder( - serializer=self.serializer, compression=self.compression + serializer=self.serializer, + compression=self.compression, + zstd_compression=self.zstd_compression, ) def encode(self, frame: OutboundFrame) -> bytes: diff --git a/tests/protocol/test_protocols.py b/tests/protocol/test_protocols.py index 9a4ff53..76ae14c 100644 --- a/tests/protocol/test_protocols.py +++ b/tests/protocol/test_protocols.py @@ -2,10 +2,11 @@ import msgpack import pytest +import zstandard from pymax.api.messages.enums import ItemType from pymax.protocol import Command, InboundFrame, Opcode, OutboundFrame -from pymax.protocol.tcp.compression import Lz4BlockCompression +from pymax.protocol.tcp.compression import Lz4BlockCompression, ZstdCompression from pymax.protocol.tcp.framing import TcpPacketFramer from pymax.protocol.tcp.payload import MsgpackPayloadCodec, TcpPayloadDecoder from pymax.protocol.tcp.protocol import TcpProtocol @@ -128,6 +129,49 @@ def test_msgpack_codec_uses_first_dict_when_stream_has_extra_data() -> None: assert codec.decode(encoded) == {"ok": True} +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 + # incorrectly skipped for cof=4. + compressed = bytes.fromhex( + "f40a84a6707265666978a27878a464617461b0664a73436c4b437508008f" + "a47461696cd92a79010016dfa6726570656174d9684142434404004c5044" + "41424344" + ) + decoder = TcpPayloadDecoder( + serializer=MsgpackPayloadCodec(), + compression=Lz4BlockCompression(), + ) + + decoded = decoder.decode(compressed, flags=4) + + assert decoded == { + "prefix": "xx", + "data": "fJsClKCufJsClKCu", + "tail": "y" * 42, + "repeat": "ABCD" * 26, + } + + +def test_tcp_payload_decoder_decompresses_zstd() -> None: + expected = {"error": "FAIL_LOGIN_TOKEN", "message": "Token expired"} + compressed = zstandard.ZstdCompressor().compress(msgpack.packb(expected, use_bin_type=True)) + decoder = TcpPayloadDecoder( + serializer=MsgpackPayloadCodec(), + compression=Lz4BlockCompression(), + zstd_compression=ZstdCompression(), + ) + + assert decoder.decode(compressed, flags=0xFF) == expected + + +def test_zstd_decompression_rejects_oversized_output() -> None: + compressed = zstandard.ZstdCompressor().compress(b"x" * 128) + + with pytest.raises(ValueError, match="output too large"): + ZstdCompression().decompress(compressed, max_output=64) + + def test_lz4_decompresses_literals_and_rejects_invalid_blocks() -> None: compression = Lz4BlockCompression() diff --git a/uv.lock b/uv.lock index 62ed956..a9e507b 100644 --- a/uv.lock +++ b/uv.lock @@ -1028,6 +1028,7 @@ dependencies = [ { name = "python-socks", extra = ["asyncio"] }, { name = "qrcode" }, { name = "websockets" }, + { name = "zstandard" }, ] [package.dev-dependencies] @@ -1070,6 +1071,7 @@ requires-dist = [ { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.1" }, { name = "qrcode", specifier = ">=8.2" }, { name = "websockets", specifier = ">=16.0" }, + { name = "zstandard", specifier = ">=0.25.0" }, ] [package.metadata.requires-dev] @@ -2487,3 +2489,93 @@ sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0 wheels = [ { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From 0e2ae2360078ed641d174a6a52ccd9b70393950d Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:36:00 +0300 Subject: [PATCH 3/4] feat: forward messages --- docs/client.rst | 6 ++--- docs/messages.rst | 12 +++++++++ src/pymax/api/messages/payloads.py | 22 +++++++++++++++- src/pymax/api/messages/service.py | 39 +++++++++++++++++++++++++++ src/pymax/infra/message.py | 27 +++++++++++++++++++ src/pymax/types/domain/message.py | 31 ++++++++++++++++++++-- tests/api/test_message_service.py | 42 ++++++++++++++++++++++++++++++ tests/domain/test_bound_models.py | 19 +++++++++++--- 8 files changed, 188 insertions(+), 10 deletions(-) diff --git a/docs/client.rst b/docs/client.rst index 31bb977..be51009 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -300,9 +300,9 @@ Debug-логи показывают handshake, login, входящие собы Клиент собирает несколько API-направлений: Сообщения - ``send_message()``, ``fetch_history()``, ``delete_message()``, - ``pin_message()``, ``read_message()``, реакции и получение URL для входящих - файлов/видео. + ``send_message()``, ``forward_message()``, ``fetch_history()``, + ``delete_message()``, ``pin_message()``, ``read_message()``, реакции и + получение URL для входящих файлов/видео. Чаты ``get_chat()``, ``fetch_chats()``, создание групп, invite-ссылки, diff --git a/docs/messages.rst b/docs/messages.rst index acacba0..ff84f88 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -70,6 +70,18 @@ Messages async def on_message(message: Message, client: Client) -> None: await message.answer("Ответ в тот же чат") await message.reply("Ответ реплаем") + await message.forward(chat_id=654321) + +Переслать сообщение напрямую через клиент можно с указанием исходного и +целевого чатов: + +.. code-block:: python + + await client.forward_message( + chat_id=654321, + message_id=987654, + source_chat_id=123456, + ) Ответ, реакции, удаление и прочтение ---------------------------------------- diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 8c8f460..0302007 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from pydantic import Field @@ -46,6 +46,26 @@ class SendMessagePayload(CamelModel): notify: bool = False +class ForwardLink(CamelModel): + type: Literal["FORWARD"] = "FORWARD" + message_id: str + chat_id: int + + +class ForwardMessagePayloadMessage(CamelModel): + cid: int + link: ForwardLink + attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field( + default_factory=list + ) + + +class ForwardMessagePayload(CamelModel): + chat_id: int + message: ForwardMessagePayloadMessage + notify: bool = True + + class ChatHistoryPayload(CamelModel): chat_id: int forward: int diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index b3689c2..602c350 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -36,6 +36,9 @@ ChatHistoryPayload, DeleteMessagePayload, EditMessagePayload, + ForwardLink, + ForwardMessagePayload, + ForwardMessagePayloadMessage, GetFilePayload, GetMessagesPayload, GetReactionsPayload, @@ -139,6 +142,42 @@ async def send_message( logger.info("message sent chat_id=%s", chat_id) return message + async def forward_message( + self, + chat_id: int, + message_id: int | str, + source_chat_id: int | None = None, + *, + notify: bool = True, + ) -> Message | None: + 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", + source_chat_id, + chat_id, + message_id, + ) + + frame = ForwardMessagePayload( + chat_id=chat_id, + message=ForwardMessagePayloadMessage( + cid=-self._next_cid(), + link=ForwardLink( + message_id=str(message_id), + chat_id=source_chat_id, + ), + ), + notify=notify, + ) + + response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload()) + message = bind_api_model( + self.app, + require_payload_model(response, Message), + ) + logger.info("message forwarded source_chat_id=%s chat_id=%s", source_chat_id, chat_id) + return message + async def get_messages( self, chat_id: int, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 9fe70db..ea1d3f1 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -62,6 +62,33 @@ async def get_message( message_id=message_id, ) + async def forward_message( + self, + chat_id: int, + message_id: int | str, + source_chat_id: int | None = None, + *, + notify: bool = True, + ) -> Message | None: + """Пересылает существующее сообщение в чат. + + Args: + chat_id: ID целевого чата. + message_id: ID пересылаемого сообщения. + source_chat_id: ID исходного чата. Если не указан, используется + целевой чат. + notify: Отправить ли получателям push-уведомление. + + Returns: + Пересланное сообщение или ``None``, если сервер не вернул его. + """ + return await self._app.api.messages.forward_message( + chat_id=chat_id, + message_id=message_id, + source_chat_id=source_chat_id, + notify=notify, + ) + async def get_messages( self, chat_id: int, diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 0960c23..ec4fc84 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -93,8 +93,9 @@ class Message(CamelModel): Сообщения, полученные через клиент, обычно уже привязаны к сервису сообщений. После этого можно вызывать удобные методы объекта: - :meth:`reply`, :meth:`answer`, :meth:`edit`, :meth:`pin`, :meth:`delete`, - :meth:`read`, :meth:`react`, :meth:`unreact` и :meth:`get_reactions`. + :meth:`reply`, :meth:`answer`, :meth:`forward`, :meth:`edit`, :meth:`pin`, + :meth:`delete`, :meth:`read`, :meth:`react`, :meth:`unreact` и + :meth:`get_reactions`. Используйте ``Message`` в обработчиках ``on_message`` и при работе с историей. Некоторые поля могут быть ``None``, потому что Max присылает @@ -244,6 +245,32 @@ async def answer( notify=notify, ) + async def forward( + self, + chat_id: int, + *, + notify: bool = True, + ) -> Message | None: + """Пересылает это сообщение в другой чат. + + :param chat_id: ID целевого чата. + :type chat_id: int + :param notify: Отправить ли получателям push-уведомление. + :type notify: bool + :returns: Пересланное сообщение или ``None``, если сервер его не вернул. + :rtype: Message | None + :raises RuntimeError: Если сообщение не привязано к сервису или не + содержит ``chat_id``. + """ + actions, source_chat_id = self._bound() + + return await actions.forward_message( + chat_id=chat_id, + message_id=self.id, + source_chat_id=source_chat_id, + notify=notify, + ) + async def pin(self, notify_pin: bool = True) -> bool: """Закрепляет это сообщение в чате. diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 300e64a..019ee25 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -56,6 +56,48 @@ async def test_send_message_raises_when_attachment_upload_fails() -> None: assert app.calls == [] +@pytest.mark.asyncio +async def test_forward_message_builds_payload_and_binds_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 1000.0) + app = FakeApp([frame(message_payload(55, 200, "forwarded"))]) + + result = await app.api.messages.forward_message( + chat_id=200, + message_id=116742887450236083, + source_chat_id=100, + notify=False, + ) + + assert result is not None + assert result.id == 55 + assert result._actions is app.api.messages + assert app.calls[0].opcode == Opcode.MSG_SEND + assert app.calls[0].payload == { + "chatId": 200, + "message": { + "cid": -1000001, + "link": { + "type": "FORWARD", + "messageId": "116742887450236083", + "chatId": 100, + }, + "attaches": [], + }, + "notify": False, + } + + +@pytest.mark.asyncio +async def test_forward_message_defaults_source_to_target_chat() -> None: + app = FakeApp([frame(message_payload(55, 200, "forwarded"))]) + + await app.api.messages.forward_message(chat_id=200, message_id="55") + + assert app.calls[0].payload["message"]["link"]["chatId"] == 200 + + @pytest.mark.asyncio async def test_upload_attachments_handles_file_video_and_empty_lists() -> None: app = FakeApp() diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 5d8ca6d..83a626b 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -14,6 +14,10 @@ async def send_message(self, *args, **kwargs): self.calls.append(("send_message", args, kwargs)) return "sent" + async def forward_message(self, *args, **kwargs): + self.calls.append(("forward_message", args, kwargs)) + return "forwarded" + async def get_message(self, *args, **kwargs): self.calls.append(("get_message", args, kwargs)) return "message" @@ -106,6 +110,7 @@ async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> Non assert await message.reply("reply") == "sent" assert await message.answer("answer", reply_to=9) == "sent" + assert await message.forward(200, notify=False) == "forwarded" assert ( await message.edit( "edited", @@ -122,10 +127,16 @@ async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> Non assert actions.calls[0][2]["reply_to"] == 10 assert actions.calls[1][2]["reply_to"] == 9 - assert actions.calls[2][2]["message_id"] == 10 - assert actions.calls[2][2]["attachments"] == ["file"] - assert actions.calls[4][2]["message_ids"] == [10] - assert actions.calls[6][2]["message_id"] == "10" + assert actions.calls[2][2] == { + "chat_id": 200, + "message_id": 10, + "source_chat_id": 100, + "notify": False, + } + assert actions.calls[3][2]["message_id"] == 10 + assert actions.calls[3][2]["attachments"] == ["file"] + assert actions.calls[5][2]["message_ids"] == [10] + assert actions.calls[7][2]["message_id"] == "10" @pytest.mark.asyncio From c712108a1f648205044e538cafc45c7d7abd275f Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:38:02 +0300 Subject: [PATCH 4/4] chore: prepare release 2.3.1 --- docs/index.rst | 1 + docs/release-2-3-1.rst | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- src/pymax/__init__.py | 2 +- uv.lock | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 docs/release-2-3-1.rst diff --git a/docs/index.rst b/docs/index.rst index 969b5d4..955401e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-3-1 release-2-3-0 release-2-2-0 release-2-1-3 diff --git a/docs/release-2-3-1.rst b/docs/release-2-3-1.rst new file mode 100644 index 0000000..28024b0 --- /dev/null +++ b/docs/release-2-3-1.rst @@ -0,0 +1,23 @@ +PyMax 2.3.1 +=========== + +Изменения относительно ``2.3.0``. + +Добавлено +--------- + +* ``forward_message()`` на клиенте и ``Message.forward()`` на bound-объекте + сообщения. Для пересылки между разными чатами укажите ``source_chat_id``. + +Исправлено +---------- + +* Декодирование сжатых TCP payload-ов: коэффициенты LZ4 теперь обрабатываются + корректно, а payload-ы с флагом ``0xFF`` декодируются через Zstandard. +* Разбор профилей bot-аккаунтов, в которых ``gender`` приходит числом, а + ``web_app`` — URL-строкой. + +Зависимости +----------- + +* Добавлена runtime-зависимость ``zstandard`` для декодирования TCP payload-ов. diff --git a/pyproject.toml b/pyproject.toml index 3013dcb..7cffd59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.3.0" +version = "2.3.1" 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 9bc33c0..182048c 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.0" +__version__ = "2.3.1" from .auth import ( diff --git a/uv.lock b/uv.lock index a9e507b..840292f 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.3.0" +version = "2.3.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },