From bb8a3f003990abdf3973759bb322f71761912e58 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 29 May 2026 12:47:32 +0300 Subject: [PATCH 01/18] feat: add join_channel method and update read_message signature to accept str or int --- src/pymax/api/chats/service.py | 72 +++++++------------ src/pymax/api/messages/payloads.py | 2 +- src/pymax/api/messages/service.py | 43 +++-------- src/pymax/infra/chat.py | 12 ++++ src/pymax/infra/message.py | 2 +- .../test_chat_user_self_session_services.py | 22 ++++++ 6 files changed, 73 insertions(+), 80 deletions(-) diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 620489b..e130279 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -72,15 +72,19 @@ def _remove_cached_chat(self, chat_id: int) -> None: if self.app.chats is None: return - self.app.chats = [ - chat for chat in self.app.chats if chat.id != chat_id - ] + self.app.chats = [chat for chat in self.app.chats if chat.id != chat_id] @staticmethod def _process_chat_join_link(link: str) -> str | None: idx = link.find(ChatLinkPrefix.JOIN) return link[idx:] if idx != -1 else None + async def _join_chat(self, link: str) -> Chat: + frame = JoinChatPayload(link=link) + response = await self.app.invoke(Opcode.CHAT_JOIN, frame.to_payload()) + chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) + return self._cache_chat(chat) + async def create_group( self, name: str, @@ -112,9 +116,7 @@ async def create_group( return None chat = self._cache_chat(chat) - message = require_payload_model(response, Message).bind( - self.app.api.messages - ) + message = require_payload_model(response, Message).bind(self.app.api.messages) return chat, message async def invite_users_to_group( @@ -145,9 +147,7 @@ async def invite_users_to_channel( user_ids: list[int], show_history: bool = True, ) -> Chat | None: - return await self.invite_users_to_group( - chat_id, user_ids, show_history - ) + return await self.invite_users_to_group(chat_id, user_ids, show_history) async def remove_users_from_group( self, @@ -191,9 +191,7 @@ async def change_group_settings( ), ) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -210,9 +208,7 @@ async def change_group_profile( description=description, ) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -222,10 +218,12 @@ async def join_group(self, link: str) -> Chat: if proceed_link is None: raise ValueError("Invalid group link") - frame = JoinChatPayload(link=proceed_link) - response = await self.app.invoke(Opcode.CHAT_JOIN, frame.to_payload()) - chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) - return self._cache_chat(chat) + return await self._join_chat(proceed_link) + + async def join_channel(self, link: str) -> Chat: + proceed_link = self._process_chat_join_link(link) + + return await self._join_chat(proceed_link or link) async def resolve_group_by_link(self, link: str) -> Chat | None: proceed_link = self._process_chat_join_link(link) @@ -242,9 +240,7 @@ async def resolve_group_by_link(self, link: str) -> Chat | None: async def rework_invite_link(self, chat_id: int) -> Chat: frame = ReworkInviteLinkPayload(chat_id=chat_id) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) return self._cache_chat(chat) @@ -254,18 +250,12 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: for chat_id in chat_ids if (chat := self._get_cached_chat(chat_id)) is not None } - missed_chat_ids = [ - chat_id for chat_id in chat_ids if chat_id not in cached - ] + missed_chat_ids = [chat_id for chat_id in chat_ids if chat_id not in cached] if missed_chat_ids: frame = GetChatInfoPayload(chat_ids=missed_chat_ids) - response = await self.app.invoke( - Opcode.CHAT_INFO, frame.to_payload() - ) - for chat in parse_payload_list( - response, ChatPayloadKey.CHATS, Chat - ): + response = await self.app.invoke(Opcode.CHAT_INFO, frame.to_payload()) + for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat): chat = self._cache_chat(chat) cached[chat.id] = chat @@ -292,20 +282,14 @@ async def fetch_chats(self, marker: int | None = None) -> list[Chat]: chats = [ self._cache_chat(chat) - for chat in parse_payload_list( - response, ChatPayloadKey.CHATS, Chat - ) + for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat) ] return chats - async def get_join_requests( - self, chat_id: int, count: int = 100 - ) -> list[Member]: + async def get_join_requests(self, chat_id: int, count: int = 100) -> list[Member]: frame = FetchJoinRequests(chat_id=chat_id, count=count) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) return parse_payload_list(response, ChatPayloadKey.MEMBERS, Member) @@ -322,9 +306,7 @@ async def confirm_join_requests( operation=ChatMemberOperation.ADD, ) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: @@ -356,9 +338,7 @@ async def decline_join_requests( operation=ChatMemberOperation.REMOVE, ) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 9aef2e0..d0c606b 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -92,5 +92,5 @@ class RemoveReactionPayload(CamelModel): class ReadMessagesPayload(CamelModel): type: ReadAction chat_id: int - message_id: str + message_id: str | int # Сокет просит int а вс str mark: int diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 1bc3a4d..a76a8a0 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -69,17 +69,13 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[ - AttachPhotoPayload | VideoAttachPayload | AttachFilePayload - ] = [] + result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] if not attachments: return result for attachment in attachments: if isinstance(attachment, Photo): - upload_result = await self.app.api.uploads.upload_photo( - attachment - ) + upload_result = await self.app.api.uploads.upload_photo(attachment) if not upload_result: logger.error("Photo uploading failed") raise UploadError("Photo uploading failed") @@ -87,9 +83,7 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, Video): - upload_result = await self.app.api.uploads.upload_video( - attachment - ) + upload_result = await self.app.api.uploads.upload_video(attachment) if not upload_result: logger.error("Video uploading failed") raise UploadError("Video uploading failed") @@ -97,9 +91,7 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, File): - upload_result = await self.app.api.uploads.upload_file( - attachment - ) + upload_result = await self.app.api.uploads.upload_file(attachment) if not upload_result: logger.error("File uploading failed") raise UploadError("File uploading failed") @@ -117,9 +109,7 @@ async def send_message( *, 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)) clean_text, elements = Formatter.format_markdown(text) @@ -171,10 +161,7 @@ async def fetch_history( Opcode.CHAT_HISTORY, payload=frame.to_payload(), ) - return ( - parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) - or None - ) + return parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) or None async def delete_message( self, @@ -195,9 +182,7 @@ async def delete_message( ) await self.app.invoke(Opcode.MSG_DELETE, frame.to_payload()) - logger.info( - "messages deleted chat_id=%s count=%s", chat_id, len(message_ids) - ) + logger.info("messages deleted chat_id=%s count=%s", chat_id, len(message_ids)) return True async def pin_message( @@ -219,9 +204,7 @@ async def pin_message( ) await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) - logger.info( - "message pinned chat_id=%s message_id=%s", chat_id, message_id - ) + logger.info("message pinned chat_id=%s message_id=%s", chat_id, message_id) return True async def get_video_by_id( @@ -263,9 +246,7 @@ async def get_file_by_id( file_id=file_id, ) - response = await self.app.invoke( - Opcode.FILE_DOWNLOAD, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FILE_DOWNLOAD, frame.to_payload()) return parse_payload_model(response, FileRequest) async def add_reaction( @@ -286,9 +267,7 @@ async def add_reaction( reaction=ReactionInfoPayload(id=reaction), ) - response = await self.app.invoke( - Opcode.MSG_REACTION, frame.to_payload() - ) + response = await self.app.invoke(Opcode.MSG_REACTION, frame.to_payload()) reaction_info = payload_item(response, MessagePayloadKey.REACTION_INFO) if reaction_info: return ReactionInfo.model_validate(reaction_info) @@ -345,7 +324,7 @@ async def remove_reaction( return None - async def read_message(self, message_id: int, chat_id: int) -> ReadState: + async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: logger.info( "marking message as read chat_id=%s message_id=%s", chat_id, diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 5ca34cb..05f449c 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -339,3 +339,15 @@ async def decline_join_request( chat_id=chat_id, user_id=user_id, ) + + async def join_channel(self, link: str) -> Chat: + """Вступает в канал по ссылке. + + Args: + link: Полная ссылка на канал, invite-ссылка или ее часть с + join-токеном Max. + + Returns: + Канал, в который вступил клиент. + """ + return await self._app.api.chats.join_channel(link=link) diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index e52e6b4..1b148f3 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -236,7 +236,7 @@ async def remove_reaction( message_id=message_id, ) - async def read_message(self, message_id: int, chat_id: int) -> ReadState: + async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: """Отмечает сообщение как прочитанное. Args: diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index f93dd5d..c379fe6 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -75,6 +75,28 @@ async def test_join_group_validates_link_and_caches_joined_chat() -> None: assert app.calls[0].payload["link"] == "join/abc" +@pytest.mark.asyncio +async def test_join_channel_accepts_raw_or_invite_links() -> None: + app = FakeApp( + [ + frame({"chat": chat_payload(11, "CHANNEL")}), + frame({"chat": chat_payload(12, "CHANNEL")}), + ] + ) + + raw_chat = await app.api.chats.join_channel("https://max.ru/channel/news") + invite_chat = await app.api.chats.join_channel("https://max.ru/join/abc") + + assert raw_chat.id == 11 + assert invite_chat.id == 12 + assert [call.opcode for call in app.calls] == [ + Opcode.CHAT_JOIN, + Opcode.CHAT_JOIN, + ] + assert app.calls[0].payload["link"] == "https://max.ru/channel/news" + assert app.calls[1].payload["link"] == "join/abc" + + @pytest.mark.asyncio async def test_create_group_returns_chat_and_message_and_updates_cache() -> ( None From d474c5d8e1ef2fbbfdd5f106911dfb8a21d4b678 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 29 May 2026 17:19:27 +0300 Subject: [PATCH 02/18] feat: update ci --- .github/workflows/publish.yml | 72 +++++++++++++---------------------- .github/workflows/tests.yml | 66 ++++++++++++++++++++++++++++++++ pyproject.toml | 23 +++-------- src/pymax/api/models.py | 12 +++--- 4 files changed, 104 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67a71db..6128efa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Upload Python Package +name: Publish on: release: @@ -8,77 +8,57 @@ on: permissions: contents: read -jobs: - release-checks: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Set up uv - uses: astral-sh/setup-uv@v4 - with: - python-version: "3.10" - enable-cache: true +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false - - name: Check lint - run: uv run ruff check src tests - - - name: Check formatting - run: uv run ruff format --check src tests - - - name: Run tests - run: uv run pytest - - - name: Build docs - run: uv run sphinx-build -b html docs /tmp/pymax-docs - - release-build: +jobs: + package: + name: Build package runs-on: ubuntu-latest - needs: release-checks steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false + - name: Checkout repository + uses: actions/checkout@v6 - - name: Set up uv - uses: astral-sh/setup-uv@v4 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 with: - python-version: "3.10" enable-cache: true - - name: Build release distributions + - name: Build package distributions run: uv build - - name: Check distributions + - name: Validate package distributions run: uv run twine check dist/* - - name: Upload distributions + - name: Upload package distributions uses: actions/upload-artifact@v4 with: - name: release-dists + name: package-distributions path: dist/ - pypi-publish: + publish: + name: Publish to PyPI runs-on: ubuntu-latest - needs: release-build + needs: [package] + environment: name: pypi - url: https://pypi.org/project/maxapi-python/ permissions: contents: read id-token: write steps: - - name: Retrieve release distributions + - name: Download package distributions uses: actions/download-artifact@v4 with: - name: release-dists + name: package-distributions path: dist/ - - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Publish package to PyPI + run: uv publish diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..36b2715 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: Tests + +on: + pull_request: + push: + branches: [main, "dev/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Install development dependencies + run: uv sync --group dev + + - name: Check code formatting + run: uv run ruff format --check . + + - name: Run Ruff linting + run: uv run ruff check . + + tests: + name: Tests / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Install development dependencies + run: uv sync --group dev + + - name: Run tests with coverage + run: | + uv run pytest \ + --cov=src/pymax \ + --cov-report=term-missing:skip-covered \ + --cov-report=markdown-append:$GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f33c1f3..d44883e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "aiofiles>=25.1.0", @@ -36,20 +37,8 @@ dependencies = [ Homepage = "https://github.com/MaxApiTeam/PyMax" Repository = "https://github.com/MaxApiTeam/PyMax" Issues = "https://github.com/MaxApiTeam/PyMax/issues" -Documentation = "https://maxapiteam.github.io/PyMax/" +Documentation = "https://docs.pymax.org" -[project.optional-dependencies] -docs = [ - "furo>=2025.12.19", - "sphinx>=8.1.3", - "sphinx-copybutton>=0.5.2", -] -test = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.24.0", - "pytest-cov>=5.0.0", - "pytest-timeout>=2.1.0", -] [build-system] requires = ["hatchling"] @@ -88,27 +77,27 @@ test = [ dev = [ {include-group = "docs"}, {include-group = "test"}, - "build>=1.2.0", "pre-commit>=4.0.0", "twine>=5.0.0", "pyright>=1.1.390", "ruff>=0.8.0", ] +[tool.uv] +package = true + [tool.pyright] venv = ".venv" venvPath = "." [tool.ruff] line-length = 79 -target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I"] -ignore = ["E501"] +ignore = [] [tool.ruff.lint.per-file-ignores] -"src/pymax/**/__init__.py" = ["F401", "F403"] "tests/**" = ["F401"] [tool.ruff.format] diff --git a/src/pymax/api/models.py b/src/pymax/api/models.py index cd64c91..0a358bb 100644 --- a/src/pymax/api/models.py +++ b/src/pymax/api/models.py @@ -1,13 +1,13 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel class CamelModel(BaseModel): - model_config = { - "alias_generator": to_camel, - "populate_by_name": True, - "arbitrary_types_allowed": True, - } + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + arbitrary_types_allowed=True + ) def to_payload(self) -> dict: return self.model_dump(by_alias=True, exclude_none=True) From 0f497a6263e7ee6e505687bdadb7b5216ac71936 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 29 May 2026 17:59:48 +0300 Subject: [PATCH 03/18] style: refactor code for improved readability and formatting consistency --- .github/workflows/publish.yml | 3 -- src/pymax/api/chats/service.py | 56 +++++++++++++++++++++++-------- src/pymax/api/messages/service.py | 45 +++++++++++++++++++------ src/pymax/infra/message.py | 4 ++- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67a71db..63344c6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,9 +26,6 @@ jobs: - name: Check lint run: uv run ruff check src tests - - name: Check formatting - run: uv run ruff format --check src tests - - name: Run tests run: uv run pytest diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index e130279..2dfc903 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -72,7 +72,9 @@ def _remove_cached_chat(self, chat_id: int) -> None: if self.app.chats is None: return - self.app.chats = [chat for chat in self.app.chats if chat.id != chat_id] + self.app.chats = [ + chat for chat in self.app.chats if chat.id != chat_id + ] @staticmethod def _process_chat_join_link(link: str) -> str | None: @@ -116,7 +118,9 @@ async def create_group( return None chat = self._cache_chat(chat) - message = require_payload_model(response, Message).bind(self.app.api.messages) + message = require_payload_model(response, Message).bind( + self.app.api.messages + ) return chat, message async def invite_users_to_group( @@ -147,7 +151,9 @@ async def invite_users_to_channel( user_ids: list[int], show_history: bool = True, ) -> Chat | None: - return await self.invite_users_to_group(chat_id, user_ids, show_history) + return await self.invite_users_to_group( + chat_id, user_ids, show_history + ) async def remove_users_from_group( self, @@ -191,7 +197,9 @@ async def change_group_settings( ), ) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -208,7 +216,9 @@ async def change_group_profile( description=description, ) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -240,7 +250,9 @@ async def resolve_group_by_link(self, link: str) -> Chat | None: async def rework_invite_link(self, chat_id: int) -> Chat: frame = ReworkInviteLinkPayload(chat_id=chat_id) - response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_UPDATE, frame.to_payload() + ) chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) return self._cache_chat(chat) @@ -250,12 +262,18 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: for chat_id in chat_ids if (chat := self._get_cached_chat(chat_id)) is not None } - missed_chat_ids = [chat_id for chat_id in chat_ids if chat_id not in cached] + missed_chat_ids = [ + chat_id for chat_id in chat_ids if chat_id not in cached + ] if missed_chat_ids: frame = GetChatInfoPayload(chat_ids=missed_chat_ids) - response = await self.app.invoke(Opcode.CHAT_INFO, frame.to_payload()) - for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat): + response = await self.app.invoke( + Opcode.CHAT_INFO, frame.to_payload() + ) + for chat in parse_payload_list( + response, ChatPayloadKey.CHATS, Chat + ): chat = self._cache_chat(chat) cached[chat.id] = chat @@ -282,14 +300,20 @@ async def fetch_chats(self, marker: int | None = None) -> list[Chat]: chats = [ self._cache_chat(chat) - for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat) + for chat in parse_payload_list( + response, ChatPayloadKey.CHATS, Chat + ) ] return chats - async def get_join_requests(self, chat_id: int, count: int = 100) -> list[Member]: + async def get_join_requests( + self, chat_id: int, count: int = 100 + ) -> list[Member]: frame = FetchJoinRequests(chat_id=chat_id, count=count) - response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_MEMBERS, frame.to_payload() + ) return parse_payload_list(response, ChatPayloadKey.MEMBERS, Member) @@ -306,7 +330,9 @@ async def confirm_join_requests( operation=ChatMemberOperation.ADD, ) - response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: @@ -338,7 +364,9 @@ async def decline_join_requests( operation=ChatMemberOperation.REMOVE, ) - response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) + response = await self.app.invoke( + Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() + ) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index a76a8a0..72cb9c6 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -69,13 +69,17 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] + result: list[ + AttachPhotoPayload | VideoAttachPayload | AttachFilePayload + ] = [] if not attachments: return result for attachment in attachments: if isinstance(attachment, Photo): - upload_result = await self.app.api.uploads.upload_photo(attachment) + upload_result = await self.app.api.uploads.upload_photo( + attachment + ) if not upload_result: logger.error("Photo uploading failed") raise UploadError("Photo uploading failed") @@ -83,7 +87,9 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, Video): - upload_result = await self.app.api.uploads.upload_video(attachment) + upload_result = await self.app.api.uploads.upload_video( + attachment + ) if not upload_result: logger.error("Video uploading failed") raise UploadError("Video uploading failed") @@ -91,7 +97,9 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, File): - upload_result = await self.app.api.uploads.upload_file(attachment) + upload_result = await self.app.api.uploads.upload_file( + attachment + ) if not upload_result: logger.error("File uploading failed") raise UploadError("File uploading failed") @@ -109,7 +117,9 @@ async def send_message( *, 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) + ) clean_text, elements = Formatter.format_markdown(text) @@ -161,7 +171,10 @@ async def fetch_history( Opcode.CHAT_HISTORY, payload=frame.to_payload(), ) - return parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) or None + return ( + parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) + or None + ) async def delete_message( self, @@ -182,7 +195,9 @@ async def delete_message( ) await self.app.invoke(Opcode.MSG_DELETE, frame.to_payload()) - logger.info("messages deleted chat_id=%s count=%s", chat_id, len(message_ids)) + logger.info( + "messages deleted chat_id=%s count=%s", chat_id, len(message_ids) + ) return True async def pin_message( @@ -204,7 +219,9 @@ async def pin_message( ) await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) - logger.info("message pinned chat_id=%s message_id=%s", chat_id, message_id) + logger.info( + "message pinned chat_id=%s message_id=%s", chat_id, message_id + ) return True async def get_video_by_id( @@ -246,7 +263,9 @@ async def get_file_by_id( file_id=file_id, ) - response = await self.app.invoke(Opcode.FILE_DOWNLOAD, frame.to_payload()) + response = await self.app.invoke( + Opcode.FILE_DOWNLOAD, frame.to_payload() + ) return parse_payload_model(response, FileRequest) async def add_reaction( @@ -267,7 +286,9 @@ async def add_reaction( reaction=ReactionInfoPayload(id=reaction), ) - response = await self.app.invoke(Opcode.MSG_REACTION, frame.to_payload()) + response = await self.app.invoke( + Opcode.MSG_REACTION, frame.to_payload() + ) reaction_info = payload_item(response, MessagePayloadKey.REACTION_INFO) if reaction_info: return ReactionInfo.model_validate(reaction_info) @@ -324,7 +345,9 @@ async def remove_reaction( return None - async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: + async def read_message( + self, message_id: int | str, chat_id: int + ) -> ReadState: logger.info( "marking message as read chat_id=%s message_id=%s", chat_id, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 1b148f3..ca5572b 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -236,7 +236,9 @@ async def remove_reaction( message_id=message_id, ) - async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: + async def read_message( + self, message_id: int | str, chat_id: int + ) -> ReadState: """Отмечает сообщение как прочитанное. Args: From 75fc3d8024acb3885b414b3c92e179ed678d0c50 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 29 May 2026 19:32:37 +0300 Subject: [PATCH 04/18] docs: update API documentation for Client and WebClient, add device type details --- docs/api/client-client.rst | 18 +++++++++ docs/api/client-config.rst | 11 +++++ docs/api/client-web.rst | 17 ++++++++ docs/api/client.rst | 30 +++++++++----- docs/auth.rst | 29 +++++++++++++- docs/client.rst | 73 +++++++++++++++++++++++++++++++++- src/pymax/api/chats/service.py | 56 +++++++------------------- 7 files changed, 178 insertions(+), 56 deletions(-) create mode 100644 docs/api/client-client.rst create mode 100644 docs/api/client-config.rst create mode 100644 docs/api/client-web.rst diff --git a/docs/api/client-client.rst b/docs/api/client-client.rst new file mode 100644 index 0000000..72d0fb5 --- /dev/null +++ b/docs/api/client-client.rst @@ -0,0 +1,18 @@ +Client +====== + +.. currentmodule:: pymax + +TCP-клиент с SMS-авторизацией. Это основной клиент для long-running +подключения, обработчиков событий и mobile API Max. + +.. note:: + + ``Client`` поддерживает ``ExtraConfig.device_type`` со значениями + ``ANDROID``, ``IOS`` и ``DESKTOP``. Для :meth:`Client.authorize_qr_login` + используйте ``ANDROID`` или ``IOS``: с ``DESKTOP`` подтверждение QR-входа + не работает. + +.. autoclass:: Client + :members: + :inherited-members: diff --git a/docs/api/client-config.rst b/docs/api/client-config.rst new file mode 100644 index 0000000..816b8f5 --- /dev/null +++ b/docs/api/client-config.rst @@ -0,0 +1,11 @@ +Client Config +============= + +.. currentmodule:: pymax + +Настройки, которые используются ``Client`` и ``WebClient``. + +.. autoclass:: ExtraConfig + :members: + +.. autofunction:: configure_logging diff --git a/docs/api/client-web.rst b/docs/api/client-web.rst new file mode 100644 index 0000000..991dea9 --- /dev/null +++ b/docs/api/client-web.rst @@ -0,0 +1,17 @@ +WebClient +========= + +.. currentmodule:: pymax + +WebSocket-клиент с QR-авторизацией. Он подходит, когда нужно подключаться как +web-клиент Max. + +.. note:: + + В штатной конфигурации ``WebClient`` использует только ``DeviceType.WEB``. + Параметр ``ExtraConfig.device_type`` предназначен для ``Client`` и не + меняет тип устройства ``WebClient``. + +.. autoclass:: WebClient + :members: + :inherited-members: diff --git a/docs/api/client.rst b/docs/api/client.rst index 53aea92..5713d44 100644 --- a/docs/api/client.rst +++ b/docs/api/client.rst @@ -1,17 +1,25 @@ -Client API -========== +Clients API +=========== .. currentmodule:: pymax -.. autoclass:: Client - :members: - :inherited-members: +``Client`` и ``WebClient`` используют общий набор высокоуровневых методов, +но отличаются транспортом и способом авторизации: -.. autoclass:: WebClient - :members: - :inherited-members: +``Client`` + TCP-клиент с SMS-авторизацией. Поддерживает mobile ``device_type``: + ``ANDROID``, ``IOS`` и ``DESKTOP``. Для подтверждения QR-входа через + :meth:`Client.authorize_qr_login` используйте ``ANDROID`` или ``IOS``: + с ``DESKTOP`` этот метод не работает. -.. autoclass:: ExtraConfig - :members: +``WebClient`` + WebSocket-клиент с QR-авторизацией. В штатной конфигурации всегда + использует ``DeviceType.WEB``; ``ExtraConfig.device_type`` на него не + влияет. -.. autofunction:: configure_logging +.. toctree:: + :maxdepth: 1 + + client-client + client-web + client-config diff --git a/docs/auth.rst b/docs/auth.rst index bb5650d..c1cf03c 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -91,7 +91,7 @@ Provider нужен, когда код приходит не из консоли Кастомный QR handler -------------------- -``QrHandler`` не должен подтверждать QR сам. Его задача - показать ссылку +Обычно ``QrHandler`` не подтверждает QR сам. Его задача - показать ссылку пользователю: вывести в терминал, отправить в web UI или положить в лог. .. code-block:: python @@ -110,6 +110,33 @@ Provider нужен, когда код приходит не из консоли qr_provider=PrintQrUrl(), ) +Если у вас уже есть запущенный и авторизованный ``Client``, QR-ссылку можно +подтвердить программно через ``authorize_qr_login()``. Это удобно, когда +``WebClient`` получает QR, а основной mobile-клиент должен разрешить вход: + +.. code-block:: python + + from pymax import Client, WebClient + + + class ConfirmQrWithClient: + def __init__(self, client: Client) -> None: + self.client = client + + async def show_qr(self, qr_url: str) -> None: + await self.client.authorize_qr_login(qr_url) + + + mobile_client = Client(phone="+79990000000", work_dir="cache") + # mobile_client должен уже пройти start/login в вашей программе. + web_client = WebClient(qr_provider=ConfirmQrWithClient(mobile_client)) + +``mobile_client`` должен быть уже авторизован к моменту вызова +``show_qr()``. Для такого подтверждения используйте ``Client`` с +``device_type`` ``ANDROID`` или ``IOS``; при ``DESKTOP`` метод +``authorize_qr_login()`` не работает. ``WebClient`` в штатной конфигурации +всегда использует ``WEB``. + Полный кастомный AuthFlow ------------------------- diff --git a/docs/client.rst b/docs/client.rst index 5e619a1..afd151f 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -16,9 +16,11 @@ Client ``Client`` TCP-клиент с авторизацией по телефону и SMS-коду. Это основной вариант. + Поддерживает ``device_type`` ``ANDROID``, ``IOS`` и ``DESKTOP``. ``WebClient`` - WebSocket-клиент с QR-авторизацией. + WebSocket-клиент с QR-авторизацией. В штатной конфигурации работает как + ``WEB``-устройство. Жизненный цикл -------------- @@ -85,6 +87,32 @@ Client ``extra_config`` Настройки соединения, логов, reconnect, token, device/user-agent и sync. +Тип устройства +--------------- + +``Client`` по умолчанию использует ``DeviceType.ANDROID``. Если нужно +поменять тип устройства, передайте ``device_type`` через ``ExtraConfig``: + +.. code-block:: python + + from pymax import Client, ExtraConfig + from pymax.api.session.enums import DeviceType + + client = Client( + phone="+79990000000", + work_dir="cache", + extra_config=ExtraConfig(device_type=DeviceType.IOS), + ) + +Для ``Client`` доступны ``ANDROID``, ``IOS`` и ``DESKTOP``. Практический +нюанс: ``authorize_qr_login()`` подтверждает QR-вход только из mobile-режима, +поэтому для него используйте ``ANDROID`` или ``IOS``. С ``DESKTOP`` этот метод +не работает. + +``WebClient`` сам использует ``DeviceType.WEB``. Параметр +``ExtraConfig.device_type`` относится к ``Client`` и не меняет тип устройства +``WebClient``. + Авторизация ----------- @@ -119,6 +147,47 @@ Client extra_config=ExtraConfig(token="TOKEN"), ) +Подтверждение QR-входа +---------------------- + +``Client`` может подтверждать QR-вход по ссылке через +``authorize_qr_login()``. Это полезно, когда QR-ссылку показал другой клиент +или ваш UI получил ее от ``WebClient``: + +.. code-block:: python + + async def confirm_qr(client: Client, qr_link: str) -> None: + ok = await client.authorize_qr_login(qr_link) + print("QR подтвержден:", ok) + +``Client`` для такого подтверждения должен быть уже авторизован. Также +проверьте ``device_type``: используйте ``ANDROID`` или ``IOS``, не +``DESKTOP``. + +Если QR создает ``WebClient``, ссылку можно перехватить в своем +``QrHandler`` и подтвердить ее уже запущенным ``Client``: + +.. code-block:: python + + from pymax import Client, WebClient + + + class ConfirmQrWithClient: + def __init__(self, client: Client) -> None: + self.client = client + + async def show_qr(self, qr_url: str) -> None: + await self.client.authorize_qr_login(qr_url) + + + mobile_client = Client(phone="+79990000000", work_dir="cache") + # mobile_client должен уже пройти start/login в вашей программе. + web_client = WebClient( + work_dir="cache", + session_name="web.db", + qr_provider=ConfirmQrWithClient(mobile_client), + ) + Сессия и sync-state ------------------- @@ -226,7 +295,7 @@ Debug-логи показывают handshake, login, входящие собы ``close_all_sessions()``. Подробнее: :doc:`account`. Auth - ``set_2fa()`` и ``remove_2fa()`` для управления паролем 2FA. Подробнее: + ``set_2fa()``, ``remove_2fa()`` и ``authorize_qr_login()``. Подробнее: :doc:`auth`. Частые ошибки diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 2dfc903..e130279 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -72,9 +72,7 @@ def _remove_cached_chat(self, chat_id: int) -> None: if self.app.chats is None: return - self.app.chats = [ - chat for chat in self.app.chats if chat.id != chat_id - ] + self.app.chats = [chat for chat in self.app.chats if chat.id != chat_id] @staticmethod def _process_chat_join_link(link: str) -> str | None: @@ -118,9 +116,7 @@ async def create_group( return None chat = self._cache_chat(chat) - message = require_payload_model(response, Message).bind( - self.app.api.messages - ) + message = require_payload_model(response, Message).bind(self.app.api.messages) return chat, message async def invite_users_to_group( @@ -151,9 +147,7 @@ async def invite_users_to_channel( user_ids: list[int], show_history: bool = True, ) -> Chat | None: - return await self.invite_users_to_group( - chat_id, user_ids, show_history - ) + return await self.invite_users_to_group(chat_id, user_ids, show_history) async def remove_users_from_group( self, @@ -197,9 +191,7 @@ async def change_group_settings( ), ) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -216,9 +208,7 @@ async def change_group_profile( description=description, ) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: self._cache_chat(chat) @@ -250,9 +240,7 @@ async def resolve_group_by_link(self, link: str) -> Chat | None: async def rework_invite_link(self, chat_id: int) -> Chat: frame = ReworkInviteLinkPayload(chat_id=chat_id) - response = await self.app.invoke( - Opcode.CHAT_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) chat = require_payload_item_model(response, ChatPayloadKey.CHAT, Chat) return self._cache_chat(chat) @@ -262,18 +250,12 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: for chat_id in chat_ids if (chat := self._get_cached_chat(chat_id)) is not None } - missed_chat_ids = [ - chat_id for chat_id in chat_ids if chat_id not in cached - ] + missed_chat_ids = [chat_id for chat_id in chat_ids if chat_id not in cached] if missed_chat_ids: frame = GetChatInfoPayload(chat_ids=missed_chat_ids) - response = await self.app.invoke( - Opcode.CHAT_INFO, frame.to_payload() - ) - for chat in parse_payload_list( - response, ChatPayloadKey.CHATS, Chat - ): + response = await self.app.invoke(Opcode.CHAT_INFO, frame.to_payload()) + for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat): chat = self._cache_chat(chat) cached[chat.id] = chat @@ -300,20 +282,14 @@ async def fetch_chats(self, marker: int | None = None) -> list[Chat]: chats = [ self._cache_chat(chat) - for chat in parse_payload_list( - response, ChatPayloadKey.CHATS, Chat - ) + for chat in parse_payload_list(response, ChatPayloadKey.CHATS, Chat) ] return chats - async def get_join_requests( - self, chat_id: int, count: int = 100 - ) -> list[Member]: + async def get_join_requests(self, chat_id: int, count: int = 100) -> list[Member]: frame = FetchJoinRequests(chat_id=chat_id, count=count) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) return parse_payload_list(response, ChatPayloadKey.MEMBERS, Member) @@ -330,9 +306,7 @@ async def confirm_join_requests( operation=ChatMemberOperation.ADD, ) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: @@ -364,9 +338,7 @@ async def decline_join_requests( operation=ChatMemberOperation.REMOVE, ) - response = await self.app.invoke( - Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) chat = parse_payload_item_model(response, ChatPayloadKey.CHAT, Chat) if chat: From e58a8c695aaa0740796d447c4938b753b3c74be1 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 29 May 2026 20:11:37 +0300 Subject: [PATCH 05/18] feat: implement binding for API models to enhance data handling across services --- src/pymax/api/auth/service.py | 11 +++- src/pymax/api/binding.py | 57 +++++++++++++++++++ src/pymax/api/chats/service.py | 13 ++++- src/pymax/api/messages/service.py | 13 +++-- src/pymax/api/self/service.py | 12 ++-- src/pymax/api/users/service.py | 4 +- src/pymax/dispatch/mapping.py | 20 +++---- src/pymax/types/domain/chat.py | 4 ++ tests/api/test_auth_service.py | 25 +++++++- .../test_chat_user_self_session_services.py | 5 ++ tests/api/test_message_service.py | 2 + tests/dispatch/test_dispatcher.py | 27 +++++++-- tests/domain/test_bound_models.py | 17 ++++++ 13 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 src/pymax/api/binding.py diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index 8bee7b1..c3a4eab 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from pymax.api.binding import bind_api_model from pymax.api.response import ( payload_item, payload_keys, @@ -128,7 +129,10 @@ async def mobile_login(self) -> LoginResponse: logger.debug("login response payload_keys=%s", payload_keys(response)) - login_response = require_payload_model(response, LoginResponse) + login_response = bind_api_model( + self.app, + require_payload_model(response, LoginResponse), + ) await self._update_session(login_response) return login_response @@ -149,7 +153,10 @@ async def web_login(self) -> LoginResponse: logger.debug("login response payload_keys=%s", payload_keys(response)) - login_response = require_payload_model(response, LoginResponse) + login_response = bind_api_model( + self.app, + require_payload_model(response, LoginResponse), + ) await self._update_session(login_response) return login_response diff --git a/src/pymax/api/binding.py b/src/pymax/api/binding.py new file mode 100644 index 0000000..c4cef0f --- /dev/null +++ b/src/pymax/api/binding.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from pydantic import BaseModel + +from pymax.types.domain import Chat, Message, User +from pymax.types.events import MessageDeleteEvent + +if TYPE_CHECKING: + from pymax.app import App + + +T = TypeVar("T") + + +def bind_api_model(app: App, value: T) -> T: + _bind_api_value(app, value, set()) + return value + + +def bind_api_models(app: App, values: Iterable[T]) -> list[T]: + return [bind_api_model(app, value) for value in values] + + +def _bind_api_value(app: App, value: Any, seen: set[int]) -> None: + if value is None or isinstance(value, (str, bytes)): + return + + value_id = id(value) + if value_id in seen: + return + + seen.add(value_id) + + if isinstance(value, Message): + value.bind(app.api.messages) + elif isinstance(value, Chat): + value.bind(app.api.messages, app.api.chats) + elif isinstance(value, User): + value.bind(app.api.users) + elif isinstance(value, MessageDeleteEvent): + value.bind(app.api.messages) + + if isinstance(value, BaseModel): + for field_name in value.__class__.model_fields: + _bind_api_value(app, getattr(value, field_name, None), seen) + + for extra_value in (value.model_extra or {}).values(): + _bind_api_value(app, extra_value, seen) + elif isinstance(value, Mapping): + for item in value.values(): + _bind_api_value(app, item, seen) + elif isinstance(value, Iterable): + for item in value: + _bind_api_value(app, item, seen) diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index e130279..48fc54f 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -3,6 +3,7 @@ import time from typing import TYPE_CHECKING +from pymax.api.binding import bind_api_model from pymax.api.response import ( parse_payload_item_model, parse_payload_list, @@ -46,7 +47,7 @@ def __init__(self, app: App) -> None: self.app = app def _bind_chat(self, chat: Chat) -> Chat: - return chat.bind(self.app.api.messages, self) + return bind_api_model(self.app, chat) def _cache_chat(self, chat: Chat) -> Chat: chat = self._bind_chat(chat) @@ -116,7 +117,10 @@ async def create_group( return None chat = self._cache_chat(chat) - message = require_payload_model(response, Message).bind(self.app.api.messages) + message = bind_api_model( + self.app, + require_payload_model(response, Message), + ) return chat, message async def invite_users_to_group( @@ -291,7 +295,10 @@ async def get_join_requests(self, chat_id: int, count: int = 100) -> list[Member response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) - return parse_payload_list(response, ChatPayloadKey.MEMBERS, Member) + return bind_api_model( + self.app, + parse_payload_list(response, ChatPayloadKey.MEMBERS, Member), + ) async def confirm_join_requests( self, diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 72cb9c6..b8af9c5 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -3,6 +3,7 @@ import time from typing import TYPE_CHECKING, TypeAlias +from pymax.api.binding import bind_api_model, bind_api_models from pymax.api.response import ( parse_payload_list, parse_payload_model, @@ -137,7 +138,10 @@ async def send_message( response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload()) - message = require_payload_model(response, Message).bind(self) + message = bind_api_model( + self.app, + require_payload_model(response, Message), + ) logger.info("message sent chat_id=%s", chat_id) return message @@ -171,10 +175,11 @@ async def fetch_history( Opcode.CHAT_HISTORY, payload=frame.to_payload(), ) - return ( - parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) - or None + messages = bind_api_models( + self.app, + parse_payload_list(response, MessagePayloadKey.MESSAGES, Message), ) + return messages or None async def delete_message( self, diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 11596c4..39d6eb2 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any from uuid import uuid4 +from pymax.api.binding import bind_api_model from pymax.api.response import ( payload_item, require_payload_item, @@ -70,10 +71,13 @@ async def change_profile( photo_token=photo_token, ) response = await self.app.invoke(Opcode.PROFILE, frame.to_payload()) - profile = require_payload_item_model( - response, - SelfPayloadKey.PROFILE, - Profile, + profile = bind_api_model( + self.app, + require_payload_item_model( + response, + SelfPayloadKey.PROFILE, + Profile, + ), ) self.app.me = profile self.app.users[profile.contact.id] = profile.contact diff --git a/src/pymax/api/users/service.py b/src/pymax/api/users/service.py index 3bf0b3a..8b81934 100644 --- a/src/pymax/api/users/service.py +++ b/src/pymax/api/users/service.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Literal +from pymax.api.binding import bind_api_model from pymax.api.response import ( parse_payload_list, require_payload_dict, @@ -30,13 +31,14 @@ def __init__(self, app: App) -> None: self.app = app def _cache_user(self, user: User) -> User: + user = bind_api_model(self.app, user) self.app.users[user.id] = user return user def get_cached_user(self, user_id: int) -> User | None: user = self.app.users.get(user_id) logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user)) - return user + return bind_api_model(self.app, user) if user is not None else None async def get_users(self, user_ids: list[int]) -> list[User]: cached = { diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index 397e331..bc05261 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING +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 @@ -58,21 +59,20 @@ def map(self, event_type: EventType, frame: InboundFrame): if frame.payload: if event_type in (EventType.MESSAGE_NEW, EventType.MESSAGE_EDIT): - return Message.model_validate(frame.payload).bind( - self.app.api.messages + return bind_api_model( + self.app, + Message.model_validate(frame.payload), ) elif event_type == EventType.CHAT_UPDATE: - return Chat.model_validate(frame.payload["chat"]).bind( - self.app.api.messages, - self.app.api.chats, + return bind_api_model( + self.app, + Chat.model_validate(frame.payload["chat"]), ) elif event_type == EventType.MESSAGE_DELETE: - model = MessageDeleteEvent.model_validate(frame.payload) - model.chat.bind( - self.app.api.messages, - self.app.api.chats, + return bind_api_model( + self.app, + MessageDeleteEvent.model_validate(frame.payload), ) - return model elif event_type == EventType.VIDEO_READY: return VideoUploadSignal.model_validate(frame.payload) elif event_type == EventType.FILE_READY: diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index 6efd245..62c23e1 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -147,6 +147,10 @@ def bind( """ self._message_actions = message_actions self._chat_actions = chat_actions + if self.last_message is not None: + self.last_message.bind(message_actions) + if self.pinned_message is not None: + self.pinned_message.bind(message_actions) return self async def answer( diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index af33875..b3030be 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -7,7 +7,15 @@ from pymax.protocol import Opcode from pymax.session.models import SessionInfo from pymax.types.domain.sync import SyncState -from tests.conftest import FakeApp, frame, mobile_user_agent, profile_payload +from tests.conftest import ( + FakeApp, + chat_payload, + frame, + message_payload, + mobile_user_agent, + profile_payload, + user_payload, +) class StaticEmailProvider: @@ -59,6 +67,14 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> frame( { "profile": profile_payload(42), + "chats": [ + { + **chat_payload(100), + "pinnedMessage": message_payload(10, 100), + } + ], + "messages": {"100": [message_payload(11, 100)]}, + "contacts": [user_payload(43)], "token": "server-token", "time": 777, "config": {"hash": "cfg-hash"}, @@ -91,6 +107,13 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert app.session.sync.presence_sync == 777 assert app.session.sync.config_hash == "cfg-hash" assert app.store.saved_sessions == [app.session] + assert response.profile.contact._actions is app.api.users + assert response.chats[0]._message_actions is app.api.messages + assert response.chats[0].pinned_message is not None + assert response.chats[0].pinned_message._actions is app.api.messages + assert response.messages[100][0]._actions is app.api.messages + assert response.contacts[0] is not None + assert response.contacts[0]._actions is app.api.users @pytest.mark.asyncio diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index c379fe6..de9d1ca 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -208,6 +208,7 @@ async def test_join_request_methods_fetch_confirm_decline_and_update_cache() -> declined = await app.api.chats.decline_join_request(10, 5) assert [request.contact.id for request in requests] == [2] + assert requests[0].contact._actions is app.api.users assert confirmed is not None assert confirmed_one is not None assert declined is not None @@ -254,6 +255,7 @@ async def test_user_service_fetches_caches_searches_and_removes_contacts() -> ( assert [user.id for user in users] == [1, 2, 3] assert found.id == 4 + assert found._actions is app.api.users assert removed is True assert 2 not in app.users assert [call.opcode for call in app.calls] == [ @@ -281,7 +283,9 @@ async def test_user_service_get_user_add_contact_sessions_and_chat_id() -> ( assert user is not None assert user.id == 5 + assert user._actions is app.api.users assert added.id == 6 + assert added._actions is app.api.users assert sessions[0].device_id == "device" assert app.api.users.get_chat_id(10, 3) == 9 assert [call.opcode for call in app.calls] == [ @@ -307,6 +311,7 @@ async def test_self_service_change_profile_and_close_all_sessions() -> None: ) assert app.me is not None assert app.me.contact.id == 9 + assert app.me.contact._actions is app.api.users assert app.users[9].id == 9 assert await app.api.account.close_all_sessions() is True diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index f95f54b..1ad5a6b 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -97,6 +97,8 @@ async def test_fetch_history_builds_payload_and_parses_messages( ) assert [message.id for message in messages or []] == [1, 2] + assert messages is not None + assert all(message._actions is app.api.messages for message in messages) assert app.calls[0].opcode == Opcode.CHAT_HISTORY assert app.calls[0].payload["from"] == 123 assert app.calls[0].payload["itemType"] == ItemType.DELAYED diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index a61abc0..9a8130a 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -56,23 +56,34 @@ async def test_dispatcher_maps_chat_delete_and_internal_attach_events() -> ( router.include_router(child) dispatcher: Dispatcher[str] = Dispatcher(app, router) dispatcher.bind_client("client") - seen: list[tuple[str, object]] = [] + seen: list[tuple[str, object, object | None]] = [] @child.on_chat_update() async def on_chat(chat, _client): - seen.append(("chat", chat.id)) + seen.append( + ( + "chat", + chat.id, + chat.pinned_message._actions is app.api.messages, + ) + ) @router.on_message_delete() async def on_delete(event, _client): - seen.append(("delete", tuple(event.message_ids))) + seen.append(("delete", tuple(event.message_ids), None)) @dispatcher.on_internal(EventType.FILE_READY) async def on_file(signal, _client): - seen.append(("file", signal.file_id)) + seen.append(("file", signal.file_id, None)) await dispatcher.dispatch( frame( - {"chat": chat_payload(5)}, + { + "chat": { + **chat_payload(5), + "pinnedMessage": message_payload(9, 5), + } + }, opcode=Opcode.NOTIF_CHAT, cmd=Command.REQUEST, ) @@ -88,7 +99,11 @@ async def on_file(signal, _client): frame({"fileId": 99}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) ) - assert seen == [("chat", 5), ("delete", (1, 2)), ("file", 99)] + assert seen == [ + ("chat", 5, True), + ("delete", (1, 2), None), + ("file", 99, None), + ] @pytest.mark.asyncio diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index d0f9c27..8b5f843 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -148,6 +148,23 @@ async def test_chat_bound_methods_delegate_by_chat_type() -> None: assert channel.is_channel is True +def test_chat_bind_also_binds_nested_messages() -> None: + messages = MessageActions() + chats = ChatActions() + payload = { + **chat_payload(100, "CHAT"), + "lastMessage": message_payload(10, 100), + "pinnedMessage": message_payload(11, 100), + } + + chat = Chat.model_validate(payload).bind(messages, chats) + + assert chat.last_message is not None + assert chat.pinned_message is not None + assert chat.last_message._actions is messages + assert chat.pinned_message._actions is messages + + @pytest.mark.asyncio async def test_dialog_leave_and_unbound_chat_raise_errors() -> None: dialog = Chat.model_validate(chat_payload(1, "DIALOG")).bind( From c9ad0cebc3231af0171fd4dcd8adb9230afed6ba Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:11:03 +0300 Subject: [PATCH 06/18] feat: support SMS registration confirmation --- src/pymax/__init__.py | 3 +- src/pymax/api/auth/payloads.py | 7 +++++ src/pymax/api/auth/service.py | 52 ++++++++++++++++------------------ src/pymax/auth/sms.py | 38 ++++++++++++++++++------- src/pymax/base.py | 1 + src/pymax/config.py | 33 +++++++++++++++++++-- src/pymax/types/domain/auth.py | 10 +++++++ 7 files changed, 101 insertions(+), 43 deletions(-) diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 7ac6eb8..d8ccec0 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -14,7 +14,7 @@ ) from .client import Client from .client_web import WebClient -from .config import ExtraConfig +from .config import ExtraConfig, RegistrationConfig from .dispatch import EventType, Router from .exceptions import ApiError, PyMaxError, UploadError from .files import File, Photo, Video @@ -43,6 +43,7 @@ "PyMaxError", "QrAuthFlow", "QrHandler", + "RegistrationConfig", "Router", "SmsAuthFlow", "SmsCodeProvider", diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index f515f2a..d17f644 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -131,3 +131,10 @@ class RemoveTwoFactorPayload(CamelModel): class ApproveQrLoginPayload(CamelModel): qr_link: str + + +class ConfirmRegistrationPayload(CamelModel): + first_name: str + last_name: str | None = None + token: str + token_type: AuthType = AuthType.REGISTER diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index c3a4eab..af92e24 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -17,6 +17,7 @@ CheckCodeResponse, CheckPasswordResponse, CheckQrResponse, + ConfirmRegistrationResponse, RequestQrResponse, StartAuthResponse, ) @@ -28,6 +29,7 @@ CheckPasswordChallengePayload, CheckQrPayload, ConfirmQrPayload, + ConfirmRegistrationPayload, CreateAuthTrackPayload, MobileUserAgentPayload, RemoveTwoFactorPayload, @@ -57,18 +59,14 @@ 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) - response = await self.app.invoke( - Opcode.AUTH_REQUEST, frame.to_payload() - ) + response = await self.app.invoke(Opcode.AUTH_REQUEST, frame.to_payload()) logger.debug( "sms code request accepted payload_keys=%s", payload_keys(response), ) return require_payload_model(response, StartAuthResponse) - async def send_code( - self, token: str, verify_code: str - ) -> CheckCodeResponse: + async def send_code(self, token: str, verify_code: str) -> CheckCodeResponse: logger.info( "sending sms code token_set=%s code_set=%s", bool(token), @@ -168,18 +166,14 @@ async def request_qr(self) -> RequestQrResponse: async def check_qr(self, track_id: str) -> CheckQrResponse: frame = CheckQrPayload(track_id=track_id) - response = await self.app.invoke( - Opcode.GET_QR_STATUS, frame.to_payload() - ) + response = await self.app.invoke(Opcode.GET_QR_STATUS, frame.to_payload()) return require_payload_model(response, CheckQrResponse) async def confirm_qr(self, track_id: str) -> CheckCodeResponse: frame = ConfirmQrPayload(track_id=track_id) - response = await self.app.invoke( - Opcode.LOGIN_BY_QR, frame.to_payload() - ) + response = await self.app.invoke(Opcode.LOGIN_BY_QR, frame.to_payload()) return require_payload_model(response, CheckCodeResponse) @@ -202,15 +196,11 @@ async def _get_track_id(self) -> str | None: logger.debug("creating auth track") frame = CreateAuthTrackPayload() - response = await self.app.invoke( - Opcode.AUTH_CREATE_TRACK, frame.to_payload() - ) + response = await self.app.invoke(Opcode.AUTH_CREATE_TRACK, frame.to_payload()) return payload_item(response, "trackId", str) - async def _set_email( - self, track_id: str, email: str, provider: EmailCodeProvider - ) -> bool: + async def _set_email(self, track_id: str, email: str, provider: EmailCodeProvider) -> bool: logger.info("setting 2fa email email_set=%s", bool(email)) frame = RequestEmailCodePayload( @@ -249,9 +239,7 @@ async def _set_password(self, track_id: str, password: str) -> bool: track_id=track_id, password=password, ) - await self.app.invoke( - Opcode.AUTH_VALIDATE_PASSWORD, frame.to_payload() - ) + await self.app.invoke(Opcode.AUTH_VALIDATE_PASSWORD, frame.to_payload()) return True @@ -351,14 +339,9 @@ async def check_2fa(self) -> bool: if not self.app.me or not self.app.me.profile_options: return False - return ( - ProfileOptions.SECOND_FACTOR_PASSWORD_ENABLED - in self.app.me.profile_options - ) + return ProfileOptions.SECOND_FACTOR_PASSWORD_ENABLED in self.app.me.profile_options - async def change_password( - self, password_old: str, password_new: str - ) -> bool: + async def change_password(self, password_old: str, password_new: str) -> bool: track_id = await self._get_track_id() if not track_id: @@ -381,3 +364,16 @@ async def change_password( await self.app.invoke(Opcode.AUTH_SET_2FA, frame.to_payload()) logger.info("2fa password set successfully") return True + + async def confirm_registration( + self, first_name: str, last_name: str | None, token: str + ) -> ConfirmRegistrationResponse: + frame = ConfirmRegistrationPayload( + first_name=first_name, + last_name=last_name, + token=token, + ) + + response = await self.app.invoke(Opcode.AUTH_CONFIRM, frame.to_payload()) + + return require_payload_model(response, ConfirmRegistrationResponse) diff --git a/src/pymax/auth/sms.py b/src/pymax/auth/sms.py index 89a6f0d..2920b12 100644 --- a/src/pymax/auth/sms.py +++ b/src/pymax/auth/sms.py @@ -75,13 +75,35 @@ async def authenticate(self, app: App) -> AuthResult: logger.debug("sms code provider returned code_set=%s", bool(code)) result = await app.api.auth.send_code(start.token, code) - token = result.login_token - if not token and result.password_challenge: + if result.login_token: + token = result.login_token + elif not result.login_token and result.password_challenge: token = await self._authenticate_with_password( app, track_id=result.password_challenge.track_id, hint=result.password_challenge.hint, ) + elif result.register_token: + if not app.config.registration_config: + logger.error( + "Registration token received, but registration config is missing " + "(client.extra_config.registration_config)" + ) + token = None + else: + registration_config = app.config.registration_config + response = await app.api.auth.confirm_registration( + first_name=registration_config.first_name, + last_name=registration_config.last_name, + token=result.register_token, + ) + token = response.token + else: + logger.error( + "Authentication failed: server returned no login token, " + "password challenge, or registration token" + ) + token = None logger.info( "sms authentication completed token_set=%s", @@ -109,23 +131,17 @@ async def _authenticate_with_password( continue try: - response = await app.api.auth.check_password( - track_id, password - ) + response = await app.api.auth.check_password(track_id, password) except ApiError as e: logger.error("2fa password check failed: %s", e) continue if response.error: - logger.error( - "2fa password check failed error=%s", response.error - ) + logger.error("2fa password check failed error=%s", response.error) continue if response.login_token: logger.info("2fa password authentication completed") return response.login_token - logger.error( - "2fa password response did not contain login token; retrying" - ) + logger.error("2fa password response did not contain login token; retrying") diff --git a/src/pymax/base.py b/src/pymax/base.py index af22960..1d5f4ba 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -83,6 +83,7 @@ def _build_config( sync=self.extra_config.sync, store=self.extra_config.store, proxy=self.extra_config.proxy, + registration_config=self.extra_config.registration_config, device=DeviceConfig( mt_instance_id=self.extra_config.mt_instance_id, device_id=self.extra_config.device_id or str(uuid4()), diff --git a/src/pymax/config.py b/src/pymax/config.py index 27677cf..f66c972 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -84,6 +84,11 @@ class DeviceConfig(BaseModel): client_session_id: int = Field(default_factory=lambda: randint(1, 70)) +class RegistrationConfig(BaseModel): + first_name: str + last_name: str | None = None + + class ClientConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) @@ -93,6 +98,7 @@ class ClientConfig(BaseModel): device: DeviceConfig token: str | None = None proxy: str | None = None + registration_config: RegistrationConfig | None = None host: str = "api.oneme.ru" port: int = 443 @@ -109,9 +115,7 @@ class ClientConfig(BaseModel): def ensure_config(self) -> None: if not self.phone: - raise ValueError( - "Phone must be provided when no saved session exists." - ) + raise ValueError("Phone must be provided when no saved session exists.") class ExtraConfig(BaseModel): @@ -156,6 +160,7 @@ class ExtraConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) token: str | None = None + registration_config: RegistrationConfig | None = None host: str = "api.oneme.ru" port: int = 443 @@ -221,3 +226,25 @@ def generate_web_user_agent(self) -> MobileUserAgentPayload: device_locale=locale, header_user_agent=DEFAULT_WEB_HEADER_USER_AGENT, ) + + +# ignore. for future upd + +# class TcpOptions(BaseModel): +# host: str = "api.oneme.ru" +# port: int = 443 +# use_ssl: bool = True +# proxy: str | None = None + + +# class RuntimeOptions(BaseModel): +# request_timeout: float = 30.0 +# reconnect: bool = True +# reconnect_delay: float = 1.0 + + +# class DeviceOptions(BaseModel): +# device_id: str | None = None +# device_type: DeviceType = DeviceType.ANDROID +# user_agent: MobileUserAgentPayload | None = None +# mt_instance_id: str = Field(default_factory=lambda: str(uuid4())) diff --git a/src/pymax/types/domain/auth.py b/src/pymax/types/domain/auth.py index a171d33..38f14e6 100644 --- a/src/pymax/types/domain/auth.py +++ b/src/pymax/types/domain/auth.py @@ -1,7 +1,10 @@ from pydantic import BaseModel, Field +from pymax.api.auth.enums import AuthType from pymax.api.models import CamelModel +from .profile import Profile + class StartAuthResponse(CamelModel): """Ответ на начало авторизации. @@ -159,3 +162,10 @@ class CheckQrResponse(CamelModel): """ status: QrStatus + + +class ConfirmRegistrationResponse(CamelModel): + user_token: int + profile: Profile + token_type: AuthType + token: str From acecfce27485a265ecf55c8ad215b1630ad37955 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:11:09 +0300 Subject: [PATCH 07/18] fix: clarify read receipt message id types --- docs/messages.rst | 7 +++++ pyproject.toml | 2 +- src/pymax/api/messages/service.py | 40 ++++++------------------ src/pymax/app.py | 2 ++ src/pymax/client.py | 3 +- src/pymax/connection/connection.py | 2 ++ src/pymax/infra/message.py | 10 +++--- src/pymax/telemetry/service.py | 22 +++---------- uv.lock | 50 ------------------------------ 9 files changed, 35 insertions(+), 103 deletions(-) diff --git a/docs/messages.rst b/docs/messages.rst index d757a5c..7070e56 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -69,6 +69,13 @@ Messages elif message.text == "/delete": await message.delete(for_me=False) +.. note:: + + У низкоуровневого ``client.read_message(...)`` есть особенность Max: + для отметки прочтения TCP-клиент ожидает ``message_id`` как ``int``, а + WebSocket-клиент - как ``str``. Если вызываете метод напрямую, выбирайте + тип по клиенту. + История сообщений ----------------- diff --git a/pyproject.toml b/pyproject.toml index 0d434d4..9841ac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ venv = ".venv" venvPath = "." [tool.ruff] -line-length = 79 +line-length = 99 [tool.ruff.lint] select = ["E", "F", "I"] diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index b8af9c5..396cd8b 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -70,17 +70,13 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[ - AttachPhotoPayload | VideoAttachPayload | AttachFilePayload - ] = [] + result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] if not attachments: return result for attachment in attachments: if isinstance(attachment, Photo): - upload_result = await self.app.api.uploads.upload_photo( - attachment - ) + upload_result = await self.app.api.uploads.upload_photo(attachment) if not upload_result: logger.error("Photo uploading failed") raise UploadError("Photo uploading failed") @@ -88,9 +84,7 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, Video): - upload_result = await self.app.api.uploads.upload_video( - attachment - ) + upload_result = await self.app.api.uploads.upload_video(attachment) if not upload_result: logger.error("Video uploading failed") raise UploadError("Video uploading failed") @@ -98,9 +92,7 @@ async def _upload_attachments( result.append(upload_result) elif isinstance(attachment, File): - upload_result = await self.app.api.uploads.upload_file( - attachment - ) + upload_result = await self.app.api.uploads.upload_file(attachment) if not upload_result: logger.error("File uploading failed") raise UploadError("File uploading failed") @@ -118,9 +110,7 @@ async def send_message( *, 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)) clean_text, elements = Formatter.format_markdown(text) @@ -200,9 +190,7 @@ async def delete_message( ) await self.app.invoke(Opcode.MSG_DELETE, frame.to_payload()) - logger.info( - "messages deleted chat_id=%s count=%s", chat_id, len(message_ids) - ) + logger.info("messages deleted chat_id=%s count=%s", chat_id, len(message_ids)) return True async def pin_message( @@ -224,9 +212,7 @@ async def pin_message( ) await self.app.invoke(Opcode.CHAT_UPDATE, frame.to_payload()) - logger.info( - "message pinned chat_id=%s message_id=%s", chat_id, message_id - ) + logger.info("message pinned chat_id=%s message_id=%s", chat_id, message_id) return True async def get_video_by_id( @@ -268,9 +254,7 @@ async def get_file_by_id( file_id=file_id, ) - response = await self.app.invoke( - Opcode.FILE_DOWNLOAD, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FILE_DOWNLOAD, frame.to_payload()) return parse_payload_model(response, FileRequest) async def add_reaction( @@ -291,9 +275,7 @@ async def add_reaction( reaction=ReactionInfoPayload(id=reaction), ) - response = await self.app.invoke( - Opcode.MSG_REACTION, frame.to_payload() - ) + response = await self.app.invoke(Opcode.MSG_REACTION, frame.to_payload()) reaction_info = payload_item(response, MessagePayloadKey.REACTION_INFO) if reaction_info: return ReactionInfo.model_validate(reaction_info) @@ -350,9 +332,7 @@ async def remove_reaction( return None - async def read_message( - self, message_id: int | str, chat_id: int - ) -> ReadState: + async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: logger.info( "marking message as read chat_id=%s message_id=%s", chat_id, diff --git a/src/pymax/app.py b/src/pymax/app.py index 18d27cf..acb9db8 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -205,8 +205,10 @@ async def invoke( payload_keys, ) logger.debug("Request data=%s", frame.model_dump()) + request_timeout = self.config.request_timeout if timeout is None else timeout response = await self.connection.request(frame, timeout=request_timeout) + response_keys = sorted(response.payload.keys()) if response.payload else [] logger.debug( "response opcode=%s cmd=%s seq=%s payload_keys=%s", diff --git a/src/pymax/client.py b/src/pymax/client.py index 20d66b0..8ae277f 100644 --- a/src/pymax/client.py +++ b/src/pymax/client.py @@ -39,7 +39,7 @@ class Client(BaseClient["Client"]): password_provider: Провайдер пароля 2FA, если аккаунт его требует. """ - def __init__( + def __init__( # noqa: PLR0913 self, phone: str, session_name: str = "session.db", @@ -49,6 +49,7 @@ def __init__( sms_code_provider: SmsCodeProvider | None = None, password_provider: PasswordProvider | None = None, ) -> None: + self.phone = phone self.extra_config = extra_config or ExtraConfig() self.session_name = session_name diff --git a/src/pymax/connection/connection.py b/src/pymax/connection/connection.py index 0495e5f..f194d6a 100644 --- a/src/pymax/connection/connection.py +++ b/src/pymax/connection/connection.py @@ -183,7 +183,9 @@ async def _recv_loop(self) -> None: except Exception as e: exc = ConnectionError(f"Connection error: {e}") logger.exception("connection receive loop failed") + self.requests.cancel_all(exc=exc) + self._connection_lost = True self._mark_closed(exc) raise e diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index ca5572b..3ad77dc 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -236,13 +236,15 @@ async def remove_reaction( message_id=message_id, ) - async def read_message( - self, message_id: int | str, chat_id: int - ) -> ReadState: + async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: """Отмечает сообщение как прочитанное. + У Max различается wire-формат ``message_id`` для отметки прочтения: + TCP-клиент ожидает ``int``, WebSocket-клиент - ``str``. + Args: - message_id: ID сообщения. + message_id: ID сообщения. Передавайте ``int`` для ``Client`` и + ``str`` для ``WebClient``. chat_id: ID чата. Returns: diff --git a/src/pymax/telemetry/service.py b/src/pymax/telemetry/service.py index ddc3b7f..101cd5d 100644 --- a/src/pymax/telemetry/service.py +++ b/src/pymax/telemetry/service.py @@ -82,20 +82,14 @@ async def stop(self) -> None: async def _run(self) -> None: try: await asyncio.sleep(self._between(self._timing.startup_delay)) - await self._send_events( - [self._payloads.login(self._user_id, self._session_id)] - ) + await self._send_events([self._payloads.login(self._user_id, self._session_id)]) while True: self._session_id += 1 - events = await self._collect_session_events( - self._planner.new_profile() - ) + events = await self._collect_session_events(self._planner.new_profile()) await self._send_events(events) self._planner.reset_to_background() - await asyncio.sleep( - self._between(self._timing.session_idle_delay) - ) + await asyncio.sleep(self._between(self._timing.session_idle_delay)) except asyncio.CancelledError: raise @@ -163,9 +157,7 @@ async def _send_events(self, events: list[TelemetryEvent]) -> None: except Exception: logger.debug("telemetry send failed", exc_info=True) - def _nav_event( - self, screen_from: Screen, screen_to: Screen - ) -> TelemetryEvent: + def _nav_event(self, screen_from: Screen, screen_to: Screen) -> TelemetryEvent: event = self._payloads.navigation( user_id=self._user_id, session_id=self._session_id, @@ -212,11 +204,7 @@ def _between(self, value: tuple[float, float]) -> float: @property def _ready(self) -> bool: - return ( - self.app.started - and self.app.me is not None - and self.app.connection.is_open - ) + return self.app.started and self.app.me is not None and self.app.connection.is_open @property def _user_id(self) -> int: diff --git a/uv.lock b/uv.lock index e06fa79..6d299fe 100644 --- a/uv.lock +++ b/uv.lock @@ -255,22 +255,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] -[[package]] -name = "build" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, - { name = "packaging" }, - { name = "pyproject-hooks" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, -] - [[package]] name = "certifi" version = "2026.4.22" @@ -1046,24 +1030,8 @@ dependencies = [ { name = "websockets" }, ] -[package.optional-dependencies] -docs = [ - { name = "furo" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinx-copybutton" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-timeout" }, -] - [package.dev-dependencies] dev = [ - { name = "build" }, { name = "furo" }, { name = "pre-commit" }, { name = "pyright" }, @@ -1097,24 +1065,15 @@ requires-dist = [ { name = "aiofiles", specifier = ">=25.1.0" }, { name = "aiohttp", specifier = ">=3.13.5" }, { name = "aiosqlite", specifier = ">=0.22.1" }, - { name = "furo", marker = "extra == 'docs'", specifier = ">=2025.12.19" }, { name = "msgpack", specifier = ">=1.1.2" }, { name = "pydantic", specifier = ">=2.10.0" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24.0" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0.0" }, - { name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.1.0" }, { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.1" }, { name = "qrcode", specifier = ">=8.2" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=8.1.3" }, - { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.2" }, { name = "websockets", specifier = ">=16.0" }, ] -provides-extras = ["docs", "test"] [package.metadata.requires-dev] dev = [ - { name = "build", specifier = ">=1.2.0" }, { name = "furo", specifier = ">=2025.12.19" }, { name = "pre-commit", specifier = ">=4.0.0" }, { name = "pyright", specifier = ">=1.1.390" }, @@ -1705,15 +1664,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - [[package]] name = "pyright" version = "1.1.409" From d7dabd3d9ad8ca69791cda76fe2658946bc69e60 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:18:53 +0300 Subject: [PATCH 08/18] chore: align ruff formatting for CI --- pyproject.toml | 1 + src/pymax/api/models.py | 4 +- src/pymax/api/self/service.py | 31 ++-- src/pymax/api/session/payloads.py | 11 +- src/pymax/api/session/service.py | 4 +- src/pymax/api/uploads/payloads.py | 12 +- src/pymax/api/uploads/service.py | 132 +++++------------- src/pymax/api/users/service.py | 20 +-- src/pymax/auth/qr.py | 12 +- src/pymax/client_web.py | 3 +- src/pymax/connection/readers/tcp.py | 4 +- src/pymax/dispatch/dispatcher.py | 24 +--- src/pymax/formatting/markdown.py | 5 +- src/pymax/protocol/tcp/compression.py | 4 +- src/pymax/protocol/tcp/framing.py | 4 +- src/pymax/protocol/ws/protocol.py | 12 +- src/pymax/session/protocol.py | 8 +- src/pymax/session/store.py | 32 ++--- src/pymax/telemetry/navigation.py | 4 +- src/pymax/transport/tcp.py | 4 +- src/pymax/types/domain/attachments/unknown.py | 4 +- src/pymax/types/domain/sync.py | 26 +--- tests/api/test_auth_service.py | 16 +-- .../test_chat_user_self_session_services.py | 82 +++-------- tests/api/test_message_service.py | 12 +- tests/api/test_upload_service.py | 41 ++---- tests/app/test_app_runtime.py | 20 +-- tests/auth/test_auth_flows.py | 8 +- tests/conftest.py | 28 +--- tests/connection/test_connection.py | 36 ++--- .../connection/test_readers_and_transports.py | 12 +- tests/dispatch/test_dispatcher.py | 8 +- tests/domain/test_bound_models.py | 22 +-- tests/files/test_files_and_formatting.py | 4 +- tests/protocol/test_protocols.py | 4 +- 35 files changed, 163 insertions(+), 491 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9841ac3..a1e05cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ ignore = [] [tool.ruff.lint.per-file-ignores] "tests/**" = ["F401"] +"**/__init__.py" = ["F401", "F403"] [tool.ruff.format] quote-style = "double" diff --git a/src/pymax/api/models.py b/src/pymax/api/models.py index 0a358bb..5ed67bc 100644 --- a/src/pymax/api/models.py +++ b/src/pymax/api/models.py @@ -4,9 +4,7 @@ class CamelModel(BaseModel): model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - arbitrary_types_allowed=True + alias_generator=to_camel, populate_by_name=True, arbitrary_types_allowed=True ) def to_payload(self) -> dict: diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 39d6eb2..3dc2164 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -39,9 +39,7 @@ def __init__(self, app: App) -> None: async def request_profile_photo_upload_url(self) -> str: logger.info("requesting profile photo upload url") frame = UploadPayload(profile=True) - response = await self.app.invoke( - Opcode.PHOTO_UPLOAD, frame.to_payload() - ) + response = await self.app.invoke(Opcode.PHOTO_UPLOAD, frame.to_payload()) return str(require_payload_item(response, SelfPayloadKey.URL)) async def change_profile( @@ -54,12 +52,11 @@ async def change_profile( photo_token: str | None = None, ) -> bool: if photo is not None: - attach = await self.app.api.uploads.upload_photo( - photo, profile=True - ) + attach = await self.app.api.uploads.upload_photo(photo, profile=True) if photo_token: logger.warning( - "photo_token argument was provided but will be overridden by the uploaded photo token" + "photo_token argument was provided but will be overridden by " + "the uploaded photo token" ) photo_token = attach.photo_token @@ -96,17 +93,13 @@ async def create_folder( include=chat_include, filters=filters or [], ) - response = await self.app.invoke( - Opcode.FOLDERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FOLDERS_UPDATE, frame.to_payload()) return require_payload_model(response, FolderUpdate) async def get_folders(self, folder_sync: int = 0) -> FolderList: logger.info("fetching folders") frame = GetFolderPayload(folder_sync=folder_sync) - response = await self.app.invoke( - Opcode.FOLDERS_GET, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FOLDERS_GET, frame.to_payload()) return require_payload_model(response, FolderList) async def update_folder( @@ -125,17 +118,13 @@ async def update_folder( filters=filters or [], options=options or [], ) - response = await self.app.invoke( - Opcode.FOLDERS_UPDATE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FOLDERS_UPDATE, frame.to_payload()) return require_payload_model(response, FolderUpdate) async def delete_folder(self, folder_id: str) -> FolderUpdate: logger.info("deleting folder") frame = DeleteFolderPayload(folder_ids=[folder_id]) - response = await self.app.invoke( - Opcode.FOLDERS_DELETE, frame.to_payload() - ) + response = await self.app.invoke(Opcode.FOLDERS_DELETE, frame.to_payload()) return require_payload_model(response, FolderUpdate) async def close_all_sessions(self) -> bool: @@ -149,9 +138,7 @@ async def close_all_sessions(self) -> bool: token = payload_item(response, SelfPayloadKey.TOKEN, str) if not token: - logger.warning( - "no token received after closing sessions, skipping token update" - ) + logger.warning("no token received after closing sessions, skipping token update") return False await self.app.store.update_token(self.app.session.token, token) diff --git a/src/pymax/api/session/payloads.py b/src/pymax/api/session/payloads.py index 2bdf64f..eab2938 100644 --- a/src/pymax/api/session/payloads.py +++ b/src/pymax/api/session/payloads.py @@ -52,17 +52,10 @@ def to_web_payload(self) -> dict: by_alias=True, exclude_none=True, ) - if ( - self.device_type == DeviceType.WEB - and "headerUserAgent" not in payload - ): + if self.device_type == DeviceType.WEB and "headerUserAgent" not in payload: payload["headerUserAgent"] = DEFAULT_WEB_HEADER_USER_AGENT - return { - alias: payload[alias] - for alias in WEB_USER_AGENT_ALIASES - if alias in payload - } + return {alias: payload[alias] for alias in WEB_USER_AGENT_ALIASES if alias in payload} class MobileHandshakePayload(CamelModel): diff --git a/src/pymax/api/session/service.py b/src/pymax/api/session/service.py index 0abce45..626dbff 100644 --- a/src/pymax/api/session/service.py +++ b/src/pymax/api/session/service.py @@ -55,9 +55,7 @@ async def mobile_handshake( 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: + async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str) -> None: logger.debug( "web handshake device_id=%s app_version=%s browser=%s", device_id, diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 909be22..5dc7794 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -5,24 +5,18 @@ class AttachPhotoPayload(CamelModel): - type: AttachmentType = Field( - default=AttachmentType.PHOTO, serialization_alias="_type" - ) + type: AttachmentType = Field(default=AttachmentType.PHOTO, serialization_alias="_type") photo_token: str class VideoAttachPayload(CamelModel): - type: AttachmentType = Field( - default=AttachmentType.VIDEO, serialization_alias="_type" - ) + type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type") video_id: int token: str class AttachFilePayload(CamelModel): - type: AttachmentType = Field( - default=AttachmentType.FILE, serialization_alias="_type" - ) + type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type") file_id: int diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index 4cc9910..9ae3751 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -38,22 +38,12 @@ class UploadService: 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.app.dispatcher.on_internal(EventType.VIDEO_READY)( - self.on_video_attach - ) - self.app.dispatcher.on_internal(EventType.FILE_READY)( - self.on_file_attach - ) + self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {} + self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {} + self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach) + self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach) - async def upload_photo( - self, photo: Photo, profile: bool = False - ) -> AttachPhotoPayload: + async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload: logger.info("Uploading photo") logger.debug("Preparing photo upload payload") @@ -72,9 +62,7 @@ async def upload_photo( url = payload_item(data, "url", str) # TODO: ENUM!!!! except Exception as e: logger.exception("Failed to parse photo upload URL from response") - raise UploadError( - "Failed to parse photo upload URL from response" - ) from e + raise UploadError("Failed to parse photo upload URL from response") from e if not url: logger.error("No upload URL received") @@ -92,15 +80,11 @@ async def upload_photo( except (KeyError, IndexError) as e: logger.exception("Photo upload URL does not contain photoIds") logger.debug("Invalid photo upload URL=%s", url) - raise UploadError( - "Photo upload URL does not contain photoIds" - ) from e + raise UploadError("Photo upload URL does not contain photoIds") from e except Exception as e: logger.exception("Failed to parse photo id from upload URL") logger.debug("Invalid photo upload URL=%s", url) - raise UploadError( - "Failed to parse photo id from upload URL" - ) from e + raise UploadError("Failed to parse photo id from upload URL") from e logger.debug("Photo upload id parsed photo_id=%s", photo_id) @@ -144,27 +128,17 @@ async def upload_photo( data=form, ) as response, ): - logger.debug( - "Photo upload HTTP response status=%s", response.status - ) + logger.debug("Photo upload HTTP response status=%s", response.status) if response.status != HTTPStatus.OK: - logger.error( - "Photo upload failed with status %s", response.status - ) - raise UploadError( - f"Photo upload failed with status {response.status}" - ) + logger.error("Photo upload failed with status %s", response.status) + raise UploadError(f"Photo upload failed with status {response.status}") try: result = await response.json() except Exception as e: - logger.exception( - "Failed to decode photo upload response JSON" - ) - raise UploadError( - "Failed to decode photo upload response JSON" - ) from e + logger.exception("Failed to decode photo upload response JSON") + raise UploadError("Failed to decode photo upload response JSON") from e except UploadError: raise @@ -280,9 +254,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: async with aiohttp.ClientSession( timeout=timeout, proxy=self.app.config.proxy ) as session: - logger.debug( - "Starting video upload HTTP request video_id=%s", video_id - ) + logger.debug("Starting video upload HTTP request video_id=%s", video_id) async with session.post( url=upload_info.url, @@ -327,26 +299,14 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: except UploadError: raise except aiohttp.ClientError as e: - logger.exception( - "HTTP error during video upload video_id=%s", video_id - ) - raise UploadError( - f"HTTP error during video upload video_id={video_id}" - ) from e + logger.exception("HTTP error during video upload video_id=%s", video_id) + raise UploadError(f"HTTP error during video upload video_id={video_id}") from e except asyncio.TimeoutError as e: - logger.exception( - "Timed out during video upload video_id=%s", video_id - ) - raise UploadError( - f"Timed out during video upload video_id={video_id}" - ) from e + logger.exception("Timed out during video upload video_id=%s", video_id) + raise UploadError(f"Timed out during video upload video_id={video_id}") from e except Exception as e: - logger.exception( - "Unexpected error during video upload video_id=%s", video_id - ) - raise UploadError( - f"Unexpected error during video upload video_id={video_id}" - ) from e + logger.exception("Unexpected error during video upload video_id=%s", video_id) + raise UploadError(f"Unexpected error during video upload video_id={video_id}") from e finally: self.video_upload_waiters.pop(video_id, None) logger.debug("Video upload waiter removed video_id=%s", video_id) @@ -458,70 +418,44 @@ async def upload_file(self, file: File) -> AttachFilePayload: except UploadError: raise except aiohttp.ClientError as e: - logger.exception( - "HTTP error during file upload file_id=%s", file_id - ) - raise UploadError( - f"HTTP error during file upload file_id={file_id}" - ) from e + logger.exception("HTTP error during file upload file_id=%s", file_id) + raise UploadError(f"HTTP error during file upload file_id={file_id}") from e except asyncio.TimeoutError as e: - logger.exception( - "Timed out during file upload file_id=%s", file_id - ) - raise UploadError( - f"Timed out during file upload file_id={file_id}" - ) from e + logger.exception("Timed out during file upload file_id=%s", file_id) + raise UploadError(f"Timed out during file upload file_id={file_id}") from e except Exception as e: - logger.exception( - "Unexpected error during file upload file_id=%s", file_id - ) - raise UploadError( - f"Unexpected error during file upload file_id={file_id}" - ) from e + logger.exception("Unexpected error during file upload file_id=%s", file_id) + raise UploadError(f"Unexpected error during file upload file_id={file_id}") from e finally: self.file_upload_waiters.pop(file_id, None) logger.debug("File upload waiter removed file=%s", file_id) - async def on_video_attach( - self, attach: VideoUploadSignal, _: Client - ) -> None: + async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None: logger.debug("Received attach event video_id=%s", attach.video_id) future = self.video_upload_waiters.pop(attach.video_id, None) if not future: - logger.debug( - "No video upload waiter found video_id=%s", attach.video_id - ) + logger.debug("No video upload waiter found video_id=%s", attach.video_id) return if future.done(): - logger.debug( - "Video upload waiter already done video_id=%s", attach.video_id - ) + logger.debug("Video upload waiter already done video_id=%s", attach.video_id) return future.set_result(attach) - logger.debug( - "Video upload waiter resolved video_id=%s", attach.video_id - ) + logger.debug("Video upload waiter resolved video_id=%s", attach.video_id) - async def on_file_attach( - self, attach: FileUploadSignal, _: Client - ) -> None: + async def on_file_attach(self, attach: FileUploadSignal, _: Client) -> None: logger.debug("Received attach event file_id=%s", attach.file_id) future = self.file_upload_waiters.pop(attach.file_id, None) if not future: - logger.debug( - "No file upload waiter found file_id=%s", attach.file_id - ) + logger.debug("No file upload waiter found file_id=%s", attach.file_id) return if future.done(): - logger.debug( - "File upload waiter already done file_id=%s", attach.file_id - ) + logger.debug("File upload waiter already done file_id=%s", attach.file_id) return future.set_result(attach) diff --git a/src/pymax/api/users/service.py b/src/pymax/api/users/service.py index 8b81934..70e577b 100644 --- a/src/pymax/api/users/service.py +++ b/src/pymax/api/users/service.py @@ -46,9 +46,7 @@ async def get_users(self, user_ids: list[int]) -> list[User]: for user_id in user_ids if (user := self.get_cached_user(user_id)) is not None } - missing_ids = [ - user_id for user_id in user_ids if user_id not in cached - ] + missing_ids = [user_id for user_id in user_ids if user_id not in cached] if missing_ids: for user in await self.fetch_users(missing_ids): @@ -66,15 +64,11 @@ async def get_user(self, user_id: int) -> User | None: async def fetch_users(self, user_ids: list[int]) -> list[User]: logger.info("fetching users count=%s", len(user_ids)) frame = FetchContactsPayload(contact_ids=user_ids) - response = await self.app.invoke( - Opcode.CONTACT_INFO, frame.to_payload() - ) + response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload()) users = [ self._cache_user(user) - for user in parse_payload_list( - response, UserPayloadKey.CONTACTS, User - ) + for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User) ] logger.debug("fetched users count=%s", len(users)) return users @@ -99,12 +93,8 @@ async def get_sessions(self) -> list[Session]: response = await self.app.invoke(Opcode.SESSIONS_INFO, {}) return parse_payload_list(response, UserPayloadKey.SESSIONS, Session) - async def _contact_action( - self, payload: ContactActionPayload - ) -> InboundFrame: - response = await self.app.invoke( - Opcode.CONTACT_UPDATE, payload.to_payload() - ) + async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame: + response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload()) require_payload_dict(response) return response diff --git a/src/pymax/auth/qr.py b/src/pymax/auth/qr.py index c8f15d0..d4afcfb 100644 --- a/src/pymax/auth/qr.py +++ b/src/pymax/auth/qr.py @@ -118,23 +118,17 @@ async def _authenticate_with_password( continue try: - response = await app.api.auth.check_password( - track_id, password - ) + response = await app.api.auth.check_password(track_id, password) except ApiError as e: logger.error("2fa password check failed: %s", e) continue if response.error: - logger.error( - "2fa password check failed error=%s", response.error - ) + logger.error("2fa password check failed error=%s", response.error) continue if response.login_token: logger.info("2fa password authentication completed") return response.login_token - logger.error( - "2fa password response did not contain login token; retrying" - ) + logger.error("2fa password response did not contain login token; retrying") diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index 22c8c8d..dfe9c3e 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -54,8 +54,7 @@ def __init__( self._config = self._build_config( phone=None, user_agent=( - self.extra_config.user_agent - or self.extra_config.generate_web_user_agent() + self.extra_config.user_agent or self.extra_config.generate_web_user_agent() ), ) diff --git a/src/pymax/connection/readers/tcp.py b/src/pymax/connection/readers/tcp.py index d6bbf39..5e41f59 100644 --- a/src/pymax/connection/readers/tcp.py +++ b/src/pymax/connection/readers/tcp.py @@ -8,9 +8,7 @@ class TCPReader(BaseReader): - def __init__( - self, transport: TCPTransport, framer: TcpPacketFramer - ) -> None: + def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None: super().__init__() self.transport = transport self.framer = framer diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index a994d9b..1f6f803 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -33,9 +33,7 @@ class Dispatcher(Generic[ClientT]): - def __init__( - self, app: App, root_router: Router[ClientT] | None = None - ) -> None: + def __init__(self, app: App, root_router: Router[ClientT] | None = None) -> None: self.root_router: Router[ClientT] = root_router or Router() self.internal_router: Router[ClientT] = Router() self.resolver = EventResolver() @@ -71,9 +69,7 @@ def on( event: EventType, *filters: FilterCallback[Any], ) -> HandlerDecorator[Any, ClientT]: - logger.debug( - "registering handler event=%s filters=%s", event, len(filters) - ) + logger.debug("registering handler event=%s filters=%s", event, len(filters)) return self.root_router.on(event, *filters) def on_message( @@ -87,9 +83,7 @@ def on_message_edit( self, *filters: FilterCallback[Message], ) -> HandlerDecorator[Message, ClientT]: - logger.debug( - "registering message edit handler filters=%s", len(filters) - ) + logger.debug("registering message edit handler filters=%s", len(filters)) return self.root_router.on_message_edit(*filters) def on_message_delete( @@ -116,9 +110,7 @@ def on_start(self) -> StartDecorator[ClientT]: def iter_routers(self) -> Generator[Router[ClientT], Any, None]: yield from self._iter_router(self.root_router) - def _iter_router( - self, router: Router[ClientT] - ) -> Generator[Router[ClientT], Any, None]: + def _iter_router(self, router: Router[ClientT]) -> Generator[Router[ClientT], Any, None]: yield router for child in router.children: @@ -161,9 +153,7 @@ async def dispatch(self, frame: InboundFrame) -> None: if event_type is not None: logger.debug("dispatching event type=%s", event_type) event = self.mapper.map(event_type, frame) - await self._dispatch_to_router( - self.internal_router, event_type, event - ) + await self._dispatch_to_router(self.internal_router, event_type, event) await self._dispatch_to_router(self.root_router, event_type, event) else: logger.debug( @@ -209,9 +199,7 @@ async def _matches( return False return True - async def _call( - self, callback: HandlerCallback[Any, ClientT], event: Any - ) -> Any: + async def _call(self, callback: HandlerCallback[Any, ClientT], event: Any) -> Any: if self.client is None: raise RuntimeError("client is not bound") diff --git a/src/pymax/formatting/markdown.py b/src/pymax/formatting/markdown.py index f228688..b6480be 100644 --- a/src/pymax/formatting/markdown.py +++ b/src/pymax/formatting/markdown.py @@ -151,10 +151,7 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: if marker == "```": closing_index = text.find(marker, i + marker_len) - if ( - closing_index == -1 - or closing_index == i + marker_len - ): + if closing_index == -1 or closing_index == i + marker_len: clean_text += marker clean_pos += marker_len i += marker_len diff --git a/src/pymax/protocol/tcp/compression.py b/src/pymax/protocol/tcp/compression.py index 1a40a8a..3de8e18 100644 --- a/src/pymax/protocol/tcp/compression.py +++ b/src/pymax/protocol/tcp/compression.py @@ -1,7 +1,5 @@ class Lz4BlockCompression: - def decompress( - self, src: bytes, max_output: int = 5 * 1024 * 1024 - ) -> bytes: + def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes: dst = bytearray() pos = 0 diff --git a/src/pymax/protocol/tcp/framing.py b/src/pymax/protocol/tcp/framing.py index 4d114ad..444fb71 100644 --- a/src/pymax/protocol/tcp/framing.py +++ b/src/pymax/protocol/tcp/framing.py @@ -31,9 +31,7 @@ def unpack(self, data: bytes) -> PackedPacket | None: if len(data) < self.HEADER_SIZE: return None - ver, cmd, seq, opcode, packed_len = self.HEADER_STRUCT.unpack_from( - data, 0 - ) + ver, cmd, seq, opcode, packed_len = self.HEADER_STRUCT.unpack_from(data, 0) flags = (packed_len >> 24) & 0xFF payload_len = packed_len & 0x00FFFFFF diff --git a/src/pymax/protocol/ws/protocol.py b/src/pymax/protocol/ws/protocol.py index 9344b4b..47a30c0 100644 --- a/src/pymax/protocol/ws/protocol.py +++ b/src/pymax/protocol/ws/protocol.py @@ -20,14 +20,8 @@ def decode(self, raw: bytes | str) -> InboundFrame: data = json.loads(raw) return InboundFrame.model_validate(data) except json.JSONDecodeError: - logger.debug( - "failed to decode websocket frame json", exc_info=True - ) - return InboundFrame( - opcode=0, cmd=0, seq=None, payload=None, raw=None - ) + logger.debug("failed to decode websocket frame json", exc_info=True) + return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None) except ValidationError: logger.debug("failed to validate websocket frame", exc_info=True) - return InboundFrame( - opcode=0, cmd=0, seq=None, payload=None, raw=None - ) + return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None) diff --git a/src/pymax/session/protocol.py b/src/pymax/session/protocol.py index 42a0b00..c529608 100644 --- a/src/pymax/session/protocol.py +++ b/src/pymax/session/protocol.py @@ -8,11 +8,7 @@ class StoreProtocol(Protocol): async def save_session(self, session_info: SessionInfo) -> None: ... async def update_token(self, old_token: str, new_token: str) -> None: ... async def load_session(self) -> SessionInfo | None: ... - async def load_session_by_device_id( - self, device_id: str - ) -> SessionInfo | None: ... - async def load_session_by_phone( - self, phone: str - ) -> SessionInfo | None: ... + async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ... + async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ... async def delete_session(self, token: str) -> None: ... async def close(self) -> None: ... diff --git a/src/pymax/session/store.py b/src/pymax/session/store.py index ec1e1ba..6299d8a 100644 --- a/src/pymax/session/store.py +++ b/src/pymax/session/store.py @@ -55,24 +55,12 @@ async def _initialize_db(self, conn: aiosqlite.Connection) -> None: ) """ ) - await self._ensure_column( - conn, "mt_instance_id", "TEXT NOT NULL DEFAULT ''" - ) - await self._ensure_column( - conn, "chats_sync", "INTEGER NOT NULL DEFAULT -1" - ) - await self._ensure_column( - conn, "contacts_sync", "INTEGER NOT NULL DEFAULT -1" - ) - await self._ensure_column( - conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1" - ) - await self._ensure_column( - conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1" - ) - await self._ensure_column( - conn, "config_hash", "TEXT NOT NULL DEFAULT ''" - ) + await self._ensure_column(conn, "mt_instance_id", "TEXT NOT NULL DEFAULT ''") + await self._ensure_column(conn, "chats_sync", "INTEGER NOT NULL DEFAULT -1") + await self._ensure_column(conn, "contacts_sync", "INTEGER NOT NULL DEFAULT -1") + await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1") + await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1") + await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''") await conn.execute( """ UPDATE sessions @@ -93,9 +81,7 @@ async def _ensure_column( columns = {row["name"] for row in await cursor.fetchall()} if name not in columns: - await conn.execute( - f"ALTER TABLE sessions ADD COLUMN {name} {definition}" - ) + await conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {definition}") async def save_session(self, session_info: SessionInfo) -> None: conn = await self._get_connection() @@ -158,9 +144,7 @@ async def load_session(self) -> SessionInfo | None: ) return self._row_to_session(row) - async def load_session_by_device_id( - self, device_id: str - ) -> SessionInfo | None: + async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: conn = await self._get_connection() logger.debug("loading session by device_id=%s", device_id) async with conn.execute( diff --git a/src/pymax/telemetry/navigation.py b/src/pymax/telemetry/navigation.py index bec275b..8f444f1 100644 --- a/src/pymax/telemetry/navigation.py +++ b/src/pymax/telemetry/navigation.py @@ -155,9 +155,7 @@ def next_screen(self, profile: RouteProfile) -> Screen: self.current_screen = self.history.pop() return self.current_screen - next_screen = self._weighted_choice( - self.rules.graph[self.current_screen] - ) + next_screen = self._weighted_choice(self.rules.graph[self.current_screen]) if next_screen != self.current_screen: self.history.append(self.current_screen) if len(self.history) > 4: diff --git a/src/pymax/transport/tcp.py b/src/pymax/transport/tcp.py index 7090d49..a0eb24b 100644 --- a/src/pymax/transport/tcp.py +++ b/src/pymax/transport/tcp.py @@ -10,9 +10,7 @@ class TCPTransport(Transport): - def __init__( - self, host: str, port: int, proxy: str | None, use_ssl: bool = True - ) -> None: + def __init__(self, host: str, port: int, proxy: str | None, use_ssl: bool = True) -> None: self._host = host self._port = port self._proxy = proxy diff --git a/src/pymax/types/domain/attachments/unknown.py b/src/pymax/types/domain/attachments/unknown.py index 2670c5b..e3e6446 100644 --- a/src/pymax/types/domain/attachments/unknown.py +++ b/src/pymax/types/domain/attachments/unknown.py @@ -30,8 +30,6 @@ def reject_known_attachment_type(cls, value: Any) -> Any: attachment_type = value.get("_type", value.get("type")) if attachment_type in KNOWN_ATTACHMENT_TYPES: - raise ValueError( - "Known attachment type should be parsed by its own model" - ) + raise ValueError("Known attachment type should be parsed by its own model") return value diff --git a/src/pymax/types/domain/sync.py b/src/pymax/types/domain/sync.py index 57b4992..e182109 100644 --- a/src/pymax/types/domain/sync.py +++ b/src/pymax/types/domain/sync.py @@ -68,29 +68,13 @@ def resolve(self, saved: SyncState) -> SyncState: :rtype: SyncState """ return SyncState( - chats_sync=( - self.chats_sync - if self.chats_sync is not None - else saved.chats_sync - ), + chats_sync=(self.chats_sync if self.chats_sync is not None else saved.chats_sync), contacts_sync=( - self.contacts_sync - if self.contacts_sync is not None - else saved.contacts_sync - ), - drafts_sync=( - self.drafts_sync - if self.drafts_sync is not None - else saved.drafts_sync + self.contacts_sync if self.contacts_sync is not None else saved.contacts_sync ), + drafts_sync=(self.drafts_sync if self.drafts_sync is not None else saved.drafts_sync), presence_sync=( - self.presence_sync - if self.presence_sync is not None - else saved.presence_sync - ), - config_hash=( - self.config_hash - if self.config_hash is not None - else saved.config_hash + self.presence_sync if self.presence_sync is not None else saved.presence_sync ), + config_hash=(self.config_hash if self.config_hash is not None else saved.config_hash), ) diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index b3030be..59e6c64 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -59,9 +59,7 @@ async def test_request_and_send_code_parse_auth_responses() -> None: @pytest.mark.asyncio -async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> ( - None -): +async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> None: app = FakeApp( [ frame( @@ -86,9 +84,7 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> token="local-token", device_id="device-test", phone="+79990000000", - sync=SyncState( - chats_sync=1, contacts_sync=2, drafts_sync=3, presence_sync=4 - ), + sync=SyncState(chats_sync=1, contacts_sync=2, drafts_sync=3, presence_sync=4), ) response = await app.api.auth.mobile_login() @@ -96,9 +92,7 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert response.token == "server-token" 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["userAgent"]["deviceType"] == DeviceType.ANDROID assert app.session is not None assert app.session.mt_instance_id == "mt-test" assert app.session.sync.chats_sync == 777 @@ -209,9 +203,7 @@ async def test_remove_2fa_checks_password_then_removes_factor() -> None: Opcode.AUTH_SET_2FA, ] assert app.calls[2].payload["remove2fa"] is True - assert app.calls[2].payload["expectedCapabilities"] == [ - TwoFactorAction.REMOVE_2FA - ] + assert app.calls[2].payload["expectedCapabilities"] == [TwoFactorAction.REMOVE_2FA] @pytest.mark.asyncio diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 9bb9b20..b04430b 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -33,17 +33,11 @@ def test_base_mixin_exposes_chat_join_request_and_bot_methods() -> None: @pytest.mark.asyncio -async def test_get_chats_uses_cache_fetches_misses_and_preserves_order() -> ( - None -): +async def test_get_chats_uses_cache_fetches_misses_and_preserves_order() -> None: app = FakeApp([frame({"chats": [chat_payload(2), chat_payload(3)]})]) from pymax.types.domain import Chat - app.chats = [ - Chat.model_validate(chat_payload(1)).bind( - app.api.messages, app.api.chats - ) - ] + app.chats = [Chat.model_validate(chat_payload(1)).bind(app.api.messages, app.api.chats)] chats = await app.api.chats.get_chats([1, 2, 3]) @@ -98,12 +92,8 @@ async def test_join_channel_accepts_raw_or_invite_links() -> None: @pytest.mark.asyncio -async def test_create_group_returns_chat_and_message_and_updates_cache() -> ( - None -): - app = FakeApp( - [frame({**message_payload(7, 10), "chat": chat_payload(10)})] - ) +async def test_create_group_returns_chat_and_message_and_updates_cache() -> None: + app = FakeApp([frame({**message_payload(7, 10), "chat": chat_payload(10)})]) result = await app.api.chats.create_group("Team", [1, 2], notify=False) @@ -123,12 +113,8 @@ async def test_leave_group_removes_cached_chat() -> None: app = FakeApp([frame({})]) app.chats = [ - Chat.model_validate(chat_payload(10)).bind( - app.api.messages, app.api.chats - ), - Chat.model_validate(chat_payload(11)).bind( - app.api.messages, app.api.chats - ), + Chat.model_validate(chat_payload(10)).bind(app.api.messages, app.api.chats), + Chat.model_validate(chat_payload(11)).bind(app.api.messages, app.api.chats), ] await app.api.chats.leave_group(10) @@ -138,9 +124,7 @@ async def test_leave_group_removes_cached_chat() -> None: @pytest.mark.asyncio -async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> ( - None -): +async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> None: app = FakeApp( [ frame({"chat": chat_payload(10, "CHAT")}), @@ -156,14 +140,10 @@ async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> assert await app.api.chats.invite_users_to_group(10, [1, 2]) is not None assert await app.api.chats.invite_users_to_channel(10, [3]) is not None - assert await app.api.chats.remove_users_from_group( - 10, [2], clean_msg_period=0 - ) + assert await app.api.chats.remove_users_from_group(10, [2], clean_msg_period=0) await app.api.chats.change_group_settings(10, all_can_pin_message=True) await app.api.chats.change_group_profile(10, "New title", "Description") - resolved = await app.api.chats.resolve_group_by_link( - "https://max.ru/join/abc" - ) + resolved = await app.api.chats.resolve_group_by_link("https://max.ru/join/abc") reworked = await app.api.chats.rework_invite_link(10) fetched = await app.api.chats.fetch_chats(marker=123) @@ -186,9 +166,7 @@ async def test_group_mutation_methods_update_cache_and_parse_optional_chats() -> @pytest.mark.asyncio -async def test_join_request_methods_fetch_confirm_decline_and_update_cache() -> ( - None -): +async def test_join_request_methods_fetch_confirm_decline_and_update_cache() -> None: app = FakeApp( [ frame({"members": [member_payload(2)]}), @@ -235,9 +213,7 @@ async def test_join_request_methods_fetch_confirm_decline_and_update_cache() -> @pytest.mark.asyncio -async def test_user_service_fetches_caches_searches_and_removes_contacts() -> ( - None -): +async def test_user_service_fetches_caches_searches_and_removes_contacts() -> None: app = FakeApp( [ frame({"contacts": [user_payload(2), user_payload(3)]}), @@ -245,9 +221,9 @@ async def test_user_service_fetches_caches_searches_and_removes_contacts() -> ( frame({"contact": user_payload(2)}), ] ) - app.users[1] = __import__( - "pymax.types.domain", fromlist=["User"] - ).User.model_validate(user_payload(1)) + app.users[1] = __import__("pymax.types.domain", fromlist=["User"]).User.model_validate( + user_payload(1) + ) users = await app.api.users.get_users([1, 2, 3]) found = await app.api.users.search_by_phone("+79990000004") @@ -266,9 +242,7 @@ async def test_user_service_fetches_caches_searches_and_removes_contacts() -> ( @pytest.mark.asyncio -async def test_user_service_get_user_add_contact_sessions_and_chat_id() -> ( - None -): +async def test_user_service_get_user_add_contact_sessions_and_chat_id() -> None: app = FakeApp( [ frame({"contacts": [user_payload(5)]}), @@ -305,10 +279,7 @@ async def test_self_service_change_profile_and_close_all_sessions() -> None: ) app.session = SessionInfo(token="old-token", device_id="dev", phone="+7") - assert ( - await app.api.account.change_profile("Ink", description="hello") - is True - ) + assert await app.api.account.change_profile("Ink", description="hello") is True assert app.me is not None assert app.me.contact.id == 9 assert app.me.contact._actions is app.api.users @@ -323,9 +294,7 @@ async def test_self_service_change_profile_and_close_all_sessions() -> None: @pytest.mark.asyncio -async def test_close_all_sessions_returns_false_without_session_or_token() -> ( - None -): +async def test_close_all_sessions_returns_false_without_session_or_token() -> None: app = FakeApp() assert await app.api.account.close_all_sessions() is False @@ -352,18 +321,13 @@ async def test_self_service_profile_photo_folders_and_logout() -> None: "folderSync": 2, } ), - frame( - {"folder": {"id": "folder-1", "title": "New"}, "folderSync": 3} - ), + frame({"folder": {"id": "folder-1", "title": "New"}, "folderSync": 3}), frame({"foldersOrder": [], "folderSync": 4}), frame({}), ] ) - assert ( - await app.api.account.request_profile_photo_upload_url() - == "https://upload.profile" - ) + assert await app.api.account.request_profile_photo_upload_url() == "https://upload.profile" created = await app.api.account.create_folder("Work", [10]) folders = await app.api.account.get_folders(folder_sync=1) updated = await app.api.account.update_folder("folder-1", "New", [11]) @@ -387,9 +351,7 @@ 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 -): +async def test_session_handshake_switches_between_mobile_and_web_payloads() -> None: mobile_app = FakeApp([frame({})]) await mobile_app.api.session.handshake( "mt", @@ -407,9 +369,7 @@ async def test_session_handshake_switches_between_mobile_and_web_payloads() -> ( assert mobile_app.calls[0].opcode == Opcode.SESSION_INIT assert mobile_app.calls[0].payload["mt_instanceid"] == "mt" assert web_app.calls[0].payload["deviceId"] == "web-device" - assert ( - web_app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.WEB - ) + assert web_app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.WEB assert "mt_instanceid" not in web_app.calls[0].payload diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 1ad5a6b..8ba1225 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -61,9 +61,7 @@ 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")] - ) + result = await app.api.messages._upload_attachments([File(raw=b"abc", name="doc.txt")]) assert result[0].file_id == 30 assert app.api.uploads.calls[0][0] == "file" @@ -110,13 +108,9 @@ async def test_delete_pin_and_read_message_send_expected_opcodes( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 3000.0) - app = FakeApp( - [frame({}), frame({}), frame({"unread": 0, "mark": 3000000})] - ) + app = FakeApp([frame({}), frame({}), frame({"unread": 0, "mark": 3000000})]) - assert ( - await app.api.messages.delete_message(100, [1, 2], for_me=True) is True - ) + assert await app.api.messages.delete_message(100, [1, 2], for_me=True) is True assert await app.api.messages.pin_message(100, 2, notify_pin=False) is True read_state = await app.api.messages.read_message(2, 100) diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py index 3485dab..4441a0c 100644 --- a/tests/api/test_upload_service.py +++ b/tests/api/test_upload_service.py @@ -10,9 +10,7 @@ class FakeHttpResponse: - def __init__( - self, status: int, json_data: dict | None = None, on_enter=None - ) -> None: + def __init__(self, status: int, json_data: dict | None = None, on_enter=None) -> None: self.status = status self.json_data = json_data or {} self.on_enter = on_enter @@ -31,9 +29,7 @@ async def json(self) -> dict: class FakeHttpSession: posts: list[dict] = [] - response = FakeHttpResponse( - 200, {"photos": {"photo-1": {"token": "uploaded"}}} - ) + response = FakeHttpResponse(200, {"photos": {"photo-1": {"token": "uploaded"}}}) def __init__(self, *args, **kwargs) -> None: self.args = args @@ -54,9 +50,7 @@ def post(self, **kwargs): async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( monkeypatch: pytest.MonkeyPatch, ) -> None: - app = FakeApp( - [frame({"url": "https://upload.test/path?photoIds=photo-1"})] - ) + app = FakeApp([frame({"url": "https://upload.test/path?photoIds=photo-1"})]) service = UploadService(app) monkeypatch.setattr( "pymax.api.uploads.service.aiohttp.ClientSession", @@ -68,22 +62,15 @@ async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( {"photos": {"photo-1": {"token": "uploaded"}}}, ) - result = await service.upload_photo( - Photo(raw=b"image-bytes", name="image.jpg") - ) + result = await service.upload_photo(Photo(raw=b"image-bytes", name="image.jpg")) assert result.photo_token == "uploaded" assert app.calls[0].opcode == Opcode.PHOTO_UPLOAD - assert ( - FakeHttpSession.posts[0]["url"] - == "https://upload.test/path?photoIds=photo-1" - ) + assert FakeHttpSession.posts[0]["url"] == "https://upload.test/path?photoIds=photo-1" @pytest.mark.asyncio -async def test_upload_waiters_resolve_video_and_file_processing_signals() -> ( - None -): +async def test_upload_waiters_resolve_video_and_file_processing_signals() -> None: app = FakeApp() service = UploadService(app) loop = __import__("asyncio").get_running_loop() @@ -123,14 +110,10 @@ async def test_upload_video_posts_chunks_waits_for_processing_and_cleans_waiter( service = UploadService(app) def resolve_processing() -> None: - service.video_upload_waiters[10].set_result( - VideoUploadSignal(video_id=10) - ) + service.video_upload_waiters[10].set_result(VideoUploadSignal(video_id=10)) FakeHttpSession.posts = [] - FakeHttpSession.response = FakeHttpResponse( - 200, on_enter=resolve_processing - ) + FakeHttpSession.response = FakeHttpResponse(200, on_enter=resolve_processing) monkeypatch.setattr( "pymax.api.uploads.service.aiohttp.ClientSession", FakeHttpSession, @@ -167,14 +150,10 @@ async def test_upload_file_posts_chunks_waits_for_processing_and_cleans_waiter( service = UploadService(app) def resolve_processing() -> None: - service.file_upload_waiters[11].set_result( - FileUploadSignal(file_id=11) - ) + service.file_upload_waiters[11].set_result(FileUploadSignal(file_id=11)) FakeHttpSession.posts = [] - FakeHttpSession.response = FakeHttpResponse( - 200, on_enter=resolve_processing - ) + FakeHttpSession.response = FakeHttpResponse(200, on_enter=resolve_processing) monkeypatch.setattr( "pymax.api.uploads.service.aiohttp.ClientSession", FakeHttpSession, diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index f65b3e0..8e114c9 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -90,9 +90,7 @@ async def idle_ping_loop(self): monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) store = RuntimeStore() - config = make_config().model_copy( - update={"token": "config-token", "store": store} - ) + config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ frame({}), @@ -127,9 +125,7 @@ async def idle_ping_loop(self): @pytest.mark.asyncio async def test_app_invoke_turns_error_frames_into_api_error() -> None: - store = RuntimeStore( - SessionInfo(token="token", device_id="dev", phone="+7") - ) + store = RuntimeStore(SessionInfo(token="token", device_id="dev", phone="+7")) config = make_config().model_copy(update={"store": store}) connection = RuntimeConnection( [ @@ -158,12 +154,8 @@ async def test_app_invoke_turns_error_frames_into_api_error() -> None: @pytest.mark.asyncio async def test_app_invoke_uses_config_timeout_and_allows_override() -> None: - store = RuntimeStore( - SessionInfo(token="token", device_id="dev", phone="+7") - ) - config = make_config().model_copy( - update={"request_timeout": 12.5, "store": store} - ) + store = RuntimeStore(SessionInfo(token="token", device_id="dev", phone="+7")) + config = make_config().model_copy(update={"request_timeout": 12.5, "store": store}) connection = RuntimeConnection([frame({}), frame({})]) app: App[object] = App(connection, config, StaticAuthFlow()) @@ -183,9 +175,7 @@ async def idle_ping_loop(self): monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) store = RuntimeStore() - config = make_config().model_copy( - update={"token": "config-token", "store": store} - ) + config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ frame({}), diff --git a/tests/auth/test_auth_flows.py b/tests/auth/test_auth_flows.py index 96e81dc..2a2cecc 100644 --- a/tests/auth/test_auth_flows.py +++ b/tests/auth/test_auth_flows.py @@ -135,15 +135,11 @@ async def check_qr(self, track_id: str): async def confirm_qr(self, track_id: str): self.calls.append(("confirm_qr", (track_id,))) - return CheckCodeResponse.model_validate( - {"tokenAttrs": {"LOGIN": {"token": "qr-token"}}} - ) + return CheckCodeResponse.model_validate({"tokenAttrs": {"LOGIN": {"token": "qr-token"}}}) @pytest.mark.asyncio -async def test_qr_auth_flow_shows_qr_polls_until_available_and_confirms() -> ( - None -): +async def test_qr_auth_flow_shows_qr_polls_until_available_and_confirms() -> None: provider = QrProvider() auth_api = QrAuthApi() app = SimpleNamespace(api=SimpleNamespace(auth=auth_api)) diff --git a/tests/conftest.py b/tests/conftest.py index f53778e..de27e0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,9 +57,7 @@ def __init__(self) -> None: def on_internal(self, event: Any, *filters: Any): def decorator(handler: Any) -> Any: - self.internal_handlers.setdefault(event, []).append( - (handler, filters) - ) + self.internal_handlers.setdefault(event, []).append((handler, filters)) return handler return decorator @@ -77,9 +75,7 @@ def __init__(self) -> None: video_id=20, token="video-token", ) - self.file_result: AttachFilePayload | None = AttachFilePayload( - file_id=30 - ) + self.file_result: AttachFilePayload | None = AttachFilePayload(file_id=30) self.calls: list[tuple[str, Any]] = [] async def upload_photo(self, photo: Any) -> AttachPhotoPayload | None: @@ -101,15 +97,11 @@ def mobile_user_agent( kwargs: dict[str, Any] = { "device_type": device_type, "app_version": "26.14.1", - "os_version": ( - "Android 14" if device_type != DeviceType.WEB else "Linux" - ), + "os_version": ("Android 14" if device_type != DeviceType.WEB else "Linux"), "timezone": "Europe/Moscow", "screen": "1080x2400", "locale": "ru", - "device_name": ( - "Pixel Test" if device_type != DeviceType.WEB else "Chrome" - ), + "device_name": ("Pixel Test" if device_type != DeviceType.WEB else "Chrome"), "device_locale": "ru", } if device_type != DeviceType.WEB: @@ -197,24 +189,18 @@ def frame( cmd: int = Command.RESPONSE, seq: int | None = 1, ) -> InboundFrame: - return InboundFrame( - opcode=opcode, cmd=cmd, seq=seq, payload=payload, raw=payload - ) + return InboundFrame(opcode=opcode, cmd=cmd, seq=seq, payload=payload, raw=payload) def user_payload(user_id: int = 1, name: str = "Test User") -> dict[str, Any]: return {"id": user_id, "names": [{"name": name, "type": "NICK"}]} -def profile_payload( - user_id: int = 1, options: list[int] | None = None -) -> dict[str, Any]: +def profile_payload(user_id: int = 1, options: list[int] | None = None) -> dict[str, Any]: return {"contact": user_payload(user_id), "profileOptions": options} -def chat_payload( - chat_id: int = 100, chat_type: str = "CHAT" -) -> dict[str, Any]: +def chat_payload(chat_id: int = 100, chat_type: str = "CHAT") -> dict[str, Any]: return { "id": chat_id, "type": chat_type, diff --git a/tests/connection/test_connection.py b/tests/connection/test_connection.py index 8f161d3..b176d00 100644 --- a/tests/connection/test_connection.py +++ b/tests/connection/test_connection.py @@ -65,9 +65,7 @@ async def test_pending_requests_resolve_reject_discard_and_cancel() -> None: pending = PendingRequests() first = pending.create(1) - assert pending.resolve( - 1, InboundFrame(opcode=1, seq=1, payload={"ok": True}) - ) + assert pending.resolve(1, InboundFrame(opcode=1, seq=1, payload={"ok": True})) assert (await first).payload == {"ok": True} assert not pending.resolve(1, InboundFrame(opcode=1, seq=1)) @@ -101,25 +99,19 @@ async def test_connection_next_seq_wraps_after_uint16_max() -> None: @pytest.mark.asyncio -async def test_connection_request_resolves_when_matching_response_arrives() -> ( - None -): +async def test_connection_request_resolves_when_matching_response_arrives() -> None: transport = FakeTransport() manager = ConnectionManager( reader=QueueReader([]), transport=transport, protocol=FakeProtocol(), ) - frame = OutboundFrame( - ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={} - ) + frame = OutboundFrame(ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={}) task = asyncio.create_task(manager.request(frame, timeout=1)) await asyncio.sleep(0) await manager._handle_inbound( - InboundFrame( - opcode=99, cmd=Command.RESPONSE, seq=7, payload={"done": True} - ) + InboundFrame(opcode=99, cmd=Command.RESPONSE, seq=7, payload={"done": True}) ) response = await task @@ -129,17 +121,13 @@ async def test_connection_request_resolves_when_matching_response_arrives() -> ( @pytest.mark.asyncio -async def test_connection_request_discards_pending_future_when_send_fails() -> ( - None -): +async def test_connection_request_discards_pending_future_when_send_fails() -> None: manager = ConnectionManager( reader=QueueReader([]), transport=FakeTransport(send_error=ConnectionError("closed")), protocol=FakeProtocol(), ) - frame = OutboundFrame( - ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={} - ) + frame = OutboundFrame(ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={}) with pytest.raises(ConnectionError, match="closed"): await manager.request(frame, timeout=1) @@ -148,17 +136,13 @@ async def test_connection_request_discards_pending_future_when_send_fails() -> ( @pytest.mark.asyncio -async def test_connection_request_discards_pending_future_when_cancelled() -> ( - None -): +async def test_connection_request_discards_pending_future_when_cancelled() -> None: manager = ConnectionManager( reader=QueueReader([]), transport=FakeTransport(), protocol=FakeProtocol(), ) - frame = OutboundFrame( - ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={} - ) + frame = OutboundFrame(ver=1, opcode=99, cmd=Command.REQUEST, seq=7, payload={}) task = asyncio.create_task(manager.request(frame, timeout=10)) await asyncio.sleep(0) @@ -171,9 +155,7 @@ async def test_connection_request_discards_pending_future_when_cancelled() -> ( @pytest.mark.asyncio -async def test_connection_open_recv_loop_dispatches_events_and_closes() -> ( - None -): +async def test_connection_open_recv_loop_dispatches_events_and_closes() -> None: events: list[InboundFrame] = [] closed: list[Exception | None] = [] transport = FakeTransport() diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 5e70574..62153d3 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -31,9 +31,7 @@ async def test_tcp_reader_reads_header_then_payload() -> None: flags=0, payload_bytes=b"abc", ) - transport = ChunkTransport( - [packet[: framer.HEADER_SIZE], packet[framer.HEADER_SIZE :]] - ) + transport = ChunkTransport([packet[: framer.HEADER_SIZE], packet[framer.HEADER_SIZE :]]) reader = TCPReader(transport, framer) assert await reader.read() == packet @@ -88,9 +86,7 @@ async def test_tcp_transport_connect_send_recv_and_close( async def open_connection(*args, **kwargs): return reader, writer - monkeypatch.setattr( - "pymax.transport.tcp.asyncio.open_connection", open_connection - ) + monkeypatch.setattr("pymax.transport.tcp.asyncio.open_connection", open_connection) transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) await transport.connect() @@ -127,9 +123,7 @@ async def open_connection(*args, **kwargs): "pymax.transport.tcp.Proxy.from_url", lambda proxy_url: FakeProxy(), ) - monkeypatch.setattr( - "pymax.transport.tcp.asyncio.open_connection", open_connection - ) + monkeypatch.setattr("pymax.transport.tcp.asyncio.open_connection", open_connection) transport = TCPTransport( "example.test", diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index 9a8130a..ec00c7b 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -9,9 +9,7 @@ @pytest.mark.asyncio -async def test_dispatcher_routes_message_events_through_filters_and_raw_handler() -> ( - None -): +async def test_dispatcher_routes_message_events_through_filters_and_raw_handler() -> None: app = FakeApp() router: Router[str] = Router() dispatcher: Dispatcher[str] = Dispatcher(app, router) @@ -47,9 +45,7 @@ def on_raw(raw_frame, client): @pytest.mark.asyncio -async def test_dispatcher_maps_chat_delete_and_internal_attach_events() -> ( - None -): +async def test_dispatcher_maps_chat_delete_and_internal_attach_events() -> None: app = FakeApp() router: Router[str] = Router() child: Router[str] = Router() diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 8b5f843..a552f6e 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -85,9 +85,7 @@ def get_chat_id(self, first, second): @pytest.mark.asyncio -async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> ( - None -): +async def test_message_bound_methods_delegate_with_chat_and_message_ids() -> None: actions = MessageActions() message = Message.model_validate(message_payload(10, 100)).bind(actions) @@ -112,23 +110,15 @@ async def test_unbound_message_raises_helpful_runtime_errors() -> None: await Message.model_validate(message_payload(10, 100)).answer("x") with pytest.raises(RuntimeError, match="chat_id"): - await ( - Message.model_validate(message_payload(10, None)) - .bind(MessageActions()) - .answer("x") - ) + await Message.model_validate(message_payload(10, None)).bind(MessageActions()).answer("x") @pytest.mark.asyncio async def test_chat_bound_methods_delegate_by_chat_type() -> None: messages = MessageActions() chats = ChatActions() - group = Chat.model_validate(chat_payload(100, "CHAT")).bind( - messages, chats - ) - channel = Chat.model_validate(chat_payload(200, "CHANNEL")).bind( - messages, chats - ) + group = Chat.model_validate(chat_payload(100, "CHAT")).bind(messages, chats) + channel = Chat.model_validate(chat_payload(200, "CHANNEL")).bind(messages, chats) assert await group.answer("hello") == "sent" assert await group.history(backward=1) == ["history"] @@ -167,9 +157,7 @@ def test_chat_bind_also_binds_nested_messages() -> None: @pytest.mark.asyncio async def test_dialog_leave_and_unbound_chat_raise_errors() -> None: - dialog = Chat.model_validate(chat_payload(1, "DIALOG")).bind( - MessageActions(), ChatActions() - ) + dialog = Chat.model_validate(chat_payload(1, "DIALOG")).bind(MessageActions(), ChatActions()) with pytest.raises(RuntimeError, match="Cannot leave dialog"): await dialog.leave() diff --git a/tests/files/test_files_and_formatting.py b/tests/files/test_files_and_formatting.py index b1a48c1..46ae036 100644 --- a/tests/files/test_files_and_formatting.py +++ b/tests/files/test_files_and_formatting.py @@ -57,9 +57,7 @@ def test_markdown_formatter_extracts_functional_entities() -> None: ) assert clean == "Title\nQuote\nHello bold and site" - assert [ - (entity.type, entity.from_, entity.length) for entity in entities - ] == [ + assert [(entity.type, entity.from_, entity.length) for entity in entities] == [ ("HEADING", 0, 5), ("QUOTE", 6, 5), ("STRONG", 18, 4), diff --git a/tests/protocol/test_protocols.py b/tests/protocol/test_protocols.py index b35b984..9a4ff53 100644 --- a/tests/protocol/test_protocols.py +++ b/tests/protocol/test_protocols.py @@ -110,9 +110,7 @@ def test_tcp_framer_handles_short_and_incomplete_packets() -> None: def test_msgpack_codec_serializes_enums_and_decoder_normalizes_keys() -> None: codec = MsgpackPayloadCodec() - encoded = codec.encode( - {1: {b"name": ItemType.DELAYED}, "list": [ItemType.REGULAR]} - ) + encoded = codec.encode({1: {b"name": ItemType.DELAYED}, "list": [ItemType.REGULAR]}) decoded = TcpPayloadDecoder(serializer=codec).decode(encoded) assert decoded == { From 22d7efaf3961834f10ec5e02517be3e6b450e5c8 Mon Sep 17 00:00:00 2001 From: arondy Date: Thu, 11 Jun 2026 21:43:01 +0300 Subject: [PATCH 09/18] fix: handle non-BMP UTF-16 characters in markdown formatting --- src/pymax/formatting/markdown.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/pymax/formatting/markdown.py b/src/pymax/formatting/markdown.py index f228688..f076699 100644 --- a/src/pymax/formatting/markdown.py +++ b/src/pymax/formatting/markdown.py @@ -2,6 +2,10 @@ class Formatter: + # Characters above this value are encoded as surrogate pairs in UTF-16, + # occupying 2 code units instead of 1. + BMP_MAX = 0xFFFF + MARKERS = { "```": "CODE", "**": "STRONG", @@ -14,6 +18,10 @@ class Formatter: MARKER_ORDER = ["```", "**", "__", "~~", "`", "_", "*"] + @staticmethod + def _code_units_len(text: str) -> int: + return len(text.encode("utf-16-le")) // 2 + @staticmethod def _parse_link( text: str, @@ -64,15 +72,16 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: label, url, next_i = parsed_link start = clean_pos + utf16_label_len = Formatter._code_units_len(label) clean_text += label - clean_pos += len(label) + clean_pos += utf16_label_len entities.append( Element( type="LINK", from_=start, - length=len(label), + length=utf16_label_len, attributes=ElementAttributes(url=url), ) ) @@ -93,9 +102,10 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: start = clean_pos while i < len(text) and text[i] != "\n": - clean_text += text[i] + ch = text[i] + clean_text += ch i += 1 - clean_pos += 1 + clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1 length = clean_pos - start @@ -123,9 +133,10 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: start = clean_pos while i < len(text) and text[i] != "\n": - clean_text += text[i] + ch = text[i] + clean_text += ch i += 1 - clean_pos += 1 + clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1 length = clean_pos - start @@ -211,10 +222,11 @@ def format_markdown(text: str) -> tuple[str, list[Element]]: line_start = False continue - clean_text += text[i] - line_start = text[i] == "\n" + ch = text[i] + clean_text += ch + line_start = ch == "\n" i += 1 - clean_pos += 1 + clean_pos += 2 if ord(ch) > Formatter.BMP_MAX else 1 return clean_text, entities From 7d84d0193f19c9fc46161aa08fe51144a78e48d9 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:31:04 +0300 Subject: [PATCH 10/18] fix: support web message deletion events --- src/pymax/dispatch/resolvers.py | 2 ++ src/pymax/types/events/message.py | 43 +++++++++++++++++++++-- tests/dispatch/test_dispatcher.py | 58 ++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index c98b44f..bef7bde 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -45,6 +45,8 @@ def resolve_message(frame: InboundFrame) -> EventType | None: if model.status == MessageStatus.EDITED: return EventType.MESSAGE_EDIT + if model.status == MessageStatus.REMOVED: + return EventType.MESSAGE_DELETE else: return EventType.MESSAGE_NEW except ValidationError: diff --git a/src/pymax/types/events/message.py b/src/pymax/types/events/message.py index 8b574f7..9f294ab 100644 --- a/src/pymax/types/events/message.py +++ b/src/pymax/types/events/message.py @@ -1,15 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from logging import getLogger +from typing import TYPE_CHECKING, Any -from pydantic import PrivateAttr +from pydantic import PrivateAttr, model_validator from pymax.types.domain import Chat from pymax.types.domain.base import CamelModel +from pymax.types.domain.message import Message if TYPE_CHECKING: from pymax.api.messages import MessageService +logger = getLogger(__name__) + class MessageDeleteEvent(CamelModel): """Событие удаления сообщений. @@ -25,12 +29,45 @@ class MessageDeleteEvent(CamelModel): :vartype ttl: bool """ - chat: Chat message_ids: list[int] + chat_id: int + chat: Chat | None = None + message: Message | None = None ttl: bool = False _actions: MessageService | None = PrivateAttr(default=None) + @model_validator(mode="before") + @classmethod + def normalize_payload(cls, data: Any) -> Any: + # i really hate it cause of stupid web version thats send other type + # of payload (128, expect 142) + # TODO: impl it in the better way maybe + + if not isinstance(data, dict): + return data + + if "chat" in data: # case opcode == 142 + chat = data["chat"] + + return { + "chat": chat, + "ttl": data.get("ttl"), + "messageIds": data["messageIds"], + "chatId": chat["id"], + } + if "message" in data: # case opcode == 128 + message = data["message"] + return { + "chatId": data["chatId"], + "message": message, + "ttl": data["ttl"], + "messageIds": [message["id"]], + } + + logger.warning("Illegal state during MessageDeleteEvent validation. Starting fallback") + return data # stupid fallback but who cares. Still better than KeyError + def bind(self, actions: MessageService) -> MessageDeleteEvent: """Привязывает сервис сообщений к событию удаления.""" self._actions = actions diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index ec00c7b..3f3183d 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -86,7 +86,11 @@ async def on_file(signal, _client): ) await dispatcher.dispatch( frame( - {"chat": chat_payload(5), "messageIds": [1, 2]}, + { + "chat": chat_payload(5), + "messageIds": [1, 2], + "ttl": False, + }, opcode=Opcode.NOTIF_MSG_DELETE, cmd=Command.REQUEST, ) @@ -102,6 +106,58 @@ async def on_file(signal, _client): ] +@pytest.mark.asyncio +async def test_dispatcher_maps_web_removed_message_to_delete_event() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[int, list[int], int | None, bool, bool]] = [] + + @router.on_message_delete() + async def on_delete(event, _client): + seen.append( + ( + event.chat_id, + event.message_ids, + event.message.id if event.message is not None else None, + event.ttl, + event.message is not None and event.message._actions is app.api.messages, + ) + ) + + await dispatcher.dispatch( + frame( + { + "chatId": 0, + "message": { + "id": "116738762887754287", + "time": 1781292158321, + "type": "USER", + "status": "REMOVED", + "text": "deleted", + "attaches": [], + }, + "ttl": False, + "unread": 0, + "mark": 1781292158321, + }, + opcode=Opcode.NOTIF_MESSAGE, + cmd=Command.REQUEST, + ) + ) + + assert seen == [ + ( + 0, + [116738762887754287], + 116738762887754287, + False, + True, + ) + ] + + @pytest.mark.asyncio async def test_dispatcher_requires_bound_client_for_callbacks() -> None: dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) From 29e0e43ca5fa7216fe8f896db6a30f9f66f1e7f6 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:05:03 +0300 Subject: [PATCH 11/18] feat: add typing event handler --- src/pymax/__init__.py | 10 +++++++++- src/pymax/base.py | 9 ++++++++- src/pymax/dispatch/dispatcher.py | 7 +++++++ src/pymax/dispatch/enums.py | 1 + src/pymax/dispatch/mapping.py | 10 +++++++++- src/pymax/dispatch/resolvers.py | 4 ++++ src/pymax/dispatch/router.py | 8 ++++++++ src/pymax/types/events/__init__.py | 1 + src/pymax/types/events/typing.py | 6 ++++++ tests/dispatch/test_dispatcher.py | 23 +++++++++++++++++++++++ 10 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/pymax/types/events/typing.py diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index d8ccec0..b3f3999 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -20,7 +20,14 @@ from .files import File, Photo, Video from .logging import configure_logging from .routers import ClientRouter, WebRouter -from .types import Chat, Message, MessageDeleteEvent, Profile, User +from .types import ( + Chat, + Message, + MessageDeleteEvent, + Profile, + TypingEvent, + User, +) from .types.domain.sync import SyncOverrides, SyncState __all__ = ( @@ -49,6 +56,7 @@ "SmsCodeProvider", "SyncOverrides", "SyncState", + "TypingEvent", "UploadError", "User", "Video", diff --git a/src/pymax/base.py b/src/pymax/base.py index 1d5f4ba..e664b16 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -22,7 +22,7 @@ StartDecorator, ) from pymax.protocol import InboundFrame - from pymax.types import Chat, MessageDeleteEvent, User + from pymax.types import Chat, MessageDeleteEvent, TypingEvent, User from pymax.types.domain import Message, Profile logger = get_logger(__name__) @@ -187,6 +187,13 @@ def on_message_delete( """Регистрирует обработчик удаления сообщений.""" return self._router.on_message_delete(*filters) + def on_typing( + self, + *filters: FilterCallback[TypingEvent], + ) -> HandlerDecorator[TypingEvent, ClientT]: + """Регистрирует обработчик набора текста.""" + return self._router.on_typing(*filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 1f6f803..7007b5b 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -9,6 +9,7 @@ from pymax.protocol import InboundFrame from pymax.types import Chat, MessageDeleteEvent from pymax.types.domain import Message +from pymax.types.events import TypingEvent from .enums import EventType from .mapping import EventMapper, EventResolver @@ -92,6 +93,12 @@ def on_message_delete( ) -> HandlerDecorator[MessageDeleteEvent, ClientT]: return self.root_router.on_message_delete(*filters) + def on_typing( + self, + *filters: FilterCallback[TypingEvent], + ) -> HandlerDecorator[TypingEvent, ClientT]: + return self.root_router.on_typing(*filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index 4089870..b6ab572 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -5,6 +5,7 @@ class EventType(str, Enum): MESSAGE_NEW = "message_new" MESSAGE_EDIT = "message_edit" MESSAGE_DELETE = "message_delete" + TYPING = "typing" CHAT_UPDATE = "chat_update" USER_UPDATE = "user_update" VIDEO_READY = "video_ready" diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index bc05261..2489c66 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -8,7 +8,11 @@ from pymax.protocol.enums import Command from pymax.types import Chat, MessageDeleteEvent from pymax.types.domain import Message -from pymax.types.events import FileUploadSignal, VideoUploadSignal +from pymax.types.events import ( + FileUploadSignal, + TypingEvent, + VideoUploadSignal, +) from .enums import EventType from .resolvers import ( @@ -16,6 +20,7 @@ resolve_chat, resolve_message, resolve_message_delete, + resolve_typing, ) if TYPE_CHECKING: @@ -29,6 +34,7 @@ Opcode.NOTIF_CHAT: resolve_chat, Opcode.NOTIF_MSG_DELETE: resolve_message_delete, Opcode.NOTIF_ATTACH: resolve_attach, + Opcode.NOTIF_TYPING: resolve_typing, } @@ -73,6 +79,8 @@ def map(self, event_type: EventType, frame: InboundFrame): self.app, MessageDeleteEvent.model_validate(frame.payload), ) + elif event_type == EventType.TYPING: + return TypingEvent.model_validate(frame.payload) elif event_type == EventType.VIDEO_READY: return VideoUploadSignal.model_validate(frame.payload) elif event_type == EventType.FILE_READY: diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index bef7bde..ad265be 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -20,6 +20,10 @@ def resolve_message_delete(_: InboundFrame) -> EventType | None: return EventType.MESSAGE_DELETE +def resolve_typing(_: InboundFrame) -> EventType | None: + return EventType.TYPING + + def resolve_attach(frame: InboundFrame) -> EventType | None: try: FileUploadSignal.model_validate(frame.payload) diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 3b90364..9d7ef12 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -14,6 +14,7 @@ from pymax.protocol import InboundFrame from pymax.types import Chat from pymax.types.domain import Message + from pymax.types.events import TypingEvent _EventT = TypeVar("_EventT") @@ -186,6 +187,13 @@ def on_message_delete( """ return self.on(EventType.MESSAGE_DELETE, *filters) + def on_typing( + self, + *filters: FilterCallback[TypingEvent], + ) -> HandlerDecorator[TypingEvent, ClientT]: + """Регистрирует обработчик набора текста.""" + return self.on(EventType.TYPING, *filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/types/events/__init__.py b/src/pymax/types/events/__init__.py index efa7805..7c0dabe 100644 --- a/src/pymax/types/events/__init__.py +++ b/src/pymax/types/events/__init__.py @@ -1,3 +1,4 @@ from .file import FileUploadSignal from .message import MessageDeleteEvent +from .typing import TypingEvent from .video import VideoUploadSignal diff --git a/src/pymax/types/events/typing.py b/src/pymax/types/events/typing.py new file mode 100644 index 0000000..bffcfea --- /dev/null +++ b/src/pymax/types/events/typing.py @@ -0,0 +1,6 @@ +from pymax.types.domain.base import CamelModel + + +class TypingEvent(CamelModel): + chat_id: int + user_id: int diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index 3f3183d..173e8af 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -158,6 +158,29 @@ async def on_delete(event, _client): ] +@pytest.mark.asyncio +async def test_dispatcher_maps_typing_event() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[str, object]] = [] + + @router.on_typing() + async def on_typing(event, _client): + seen.append(("typing", (event.chat_id, event.user_id))) + + await dispatcher.dispatch( + frame( + {"chatId": 239067070, "userId": 17620943}, + opcode=Opcode.NOTIF_TYPING, + cmd=Command.REQUEST, + ) + ) + + assert seen == [("typing", (239067070, 17620943))] + + @pytest.mark.asyncio async def test_dispatcher_requires_bound_client_for_callbacks() -> None: dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) From dfb0c5f126f80dda2eeb3ac0be3c9d66288bc0da Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:05:52 +0300 Subject: [PATCH 12/18] feat: add reaction update event handler --- src/pymax/__init__.py | 2 ++ src/pymax/base.py | 9 +++++++- src/pymax/dispatch/dispatcher.py | 8 ++++++- src/pymax/dispatch/enums.py | 1 + src/pymax/dispatch/mapping.py | 5 +++++ src/pymax/dispatch/resolvers.py | 4 ++++ src/pymax/dispatch/router.py | 9 +++++++- src/pymax/types/events/__init__.py | 1 + src/pymax/types/events/reaction.py | 9 ++++++++ tests/dispatch/test_dispatcher.py | 36 ++++++++++++++++++++++++++++++ 10 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/pymax/types/events/reaction.py diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index b3f3999..d59ad6a 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -25,6 +25,7 @@ Message, MessageDeleteEvent, Profile, + ReactionUpdateEvent, TypingEvent, User, ) @@ -50,6 +51,7 @@ "PyMaxError", "QrAuthFlow", "QrHandler", + "ReactionUpdateEvent", "RegistrationConfig", "Router", "SmsAuthFlow", diff --git a/src/pymax/base.py b/src/pymax/base.py index e664b16..8bbc692 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -22,7 +22,7 @@ StartDecorator, ) from pymax.protocol import InboundFrame - from pymax.types import Chat, MessageDeleteEvent, TypingEvent, User + from pymax.types import Chat, MessageDeleteEvent, ReactionUpdateEvent, TypingEvent, User from pymax.types.domain import Message, Profile logger = get_logger(__name__) @@ -194,6 +194,13 @@ def on_typing( """Регистрирует обработчик набора текста.""" return self._router.on_typing(*filters) + def on_reaction_update( + self, + *filters: FilterCallback[ReactionUpdateEvent], + ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]: + """Регистрирует обработчик обновления реакций сообщения.""" + return self._router.on_reaction_update(*filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 7007b5b..62d037d 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -9,7 +9,7 @@ from pymax.protocol import InboundFrame from pymax.types import Chat, MessageDeleteEvent from pymax.types.domain import Message -from pymax.types.events import TypingEvent +from pymax.types.events import ReactionUpdateEvent, TypingEvent from .enums import EventType from .mapping import EventMapper, EventResolver @@ -99,6 +99,12 @@ def on_typing( ) -> HandlerDecorator[TypingEvent, ClientT]: return self.root_router.on_typing(*filters) + def on_reaction_update( + self, + *filters: FilterCallback[ReactionUpdateEvent], + ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]: + return self.root_router.on_reaction_update(*filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index b6ab572..8f918ed 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -6,6 +6,7 @@ class EventType(str, Enum): MESSAGE_EDIT = "message_edit" MESSAGE_DELETE = "message_delete" TYPING = "typing" + REACTION_UPDATE = "reaction_update" CHAT_UPDATE = "chat_update" USER_UPDATE = "user_update" VIDEO_READY = "video_ready" diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index 2489c66..a44d1ad 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -10,6 +10,7 @@ from pymax.types.domain import Message from pymax.types.events import ( FileUploadSignal, + ReactionUpdateEvent, TypingEvent, VideoUploadSignal, ) @@ -20,6 +21,7 @@ resolve_chat, resolve_message, resolve_message_delete, + resolve_reaction_update, resolve_typing, ) @@ -35,6 +37,7 @@ Opcode.NOTIF_MSG_DELETE: resolve_message_delete, Opcode.NOTIF_ATTACH: resolve_attach, Opcode.NOTIF_TYPING: resolve_typing, + Opcode.NOTIF_MSG_REACTIONS_CHANGED: resolve_reaction_update, } @@ -81,6 +84,8 @@ def map(self, event_type: EventType, frame: InboundFrame): ) elif event_type == EventType.TYPING: return TypingEvent.model_validate(frame.payload) + elif event_type == EventType.REACTION_UPDATE: + return ReactionUpdateEvent.model_validate(frame.payload) elif event_type == EventType.VIDEO_READY: return VideoUploadSignal.model_validate(frame.payload) elif event_type == EventType.FILE_READY: diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index ad265be..064770f 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -24,6 +24,10 @@ def resolve_typing(_: InboundFrame) -> EventType | None: return EventType.TYPING +def resolve_reaction_update(_: InboundFrame) -> EventType | None: + return EventType.REACTION_UPDATE + + def resolve_attach(frame: InboundFrame) -> EventType | None: try: FileUploadSignal.model_validate(frame.payload) diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 9d7ef12..304000d 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -14,7 +14,7 @@ from pymax.protocol import InboundFrame from pymax.types import Chat from pymax.types.domain import Message - from pymax.types.events import TypingEvent + from pymax.types.events import ReactionUpdateEvent, TypingEvent _EventT = TypeVar("_EventT") @@ -194,6 +194,13 @@ def on_typing( """Регистрирует обработчик набора текста.""" return self.on(EventType.TYPING, *filters) + def on_reaction_update( + self, + *filters: FilterCallback[ReactionUpdateEvent], + ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]: + """Регистрирует обработчик обновления реакций сообщения.""" + return self.on(EventType.REACTION_UPDATE, *filters) + def on_chat_update( self, *filters: FilterCallback[Chat], diff --git a/src/pymax/types/events/__init__.py b/src/pymax/types/events/__init__.py index 7c0dabe..6803670 100644 --- a/src/pymax/types/events/__init__.py +++ b/src/pymax/types/events/__init__.py @@ -1,4 +1,5 @@ from .file import FileUploadSignal from .message import MessageDeleteEvent +from .reaction import ReactionUpdateEvent from .typing import TypingEvent from .video import VideoUploadSignal diff --git a/src/pymax/types/events/reaction.py b/src/pymax/types/events/reaction.py new file mode 100644 index 0000000..ca68e54 --- /dev/null +++ b/src/pymax/types/events/reaction.py @@ -0,0 +1,9 @@ +from pymax.types.domain.base import CamelModel +from pymax.types.domain.message import ReactionCounter + + +class ReactionUpdateEvent(CamelModel): + message_id: str + chat_id: int + counters: list[ReactionCounter] + total_count: int diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index 173e8af..988cee5 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -181,6 +181,42 @@ async def on_typing(event, _client): assert seen == [("typing", (239067070, 17620943))] +@pytest.mark.asyncio +async def test_dispatcher_maps_reaction_update_event() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[str, int, int, int, str]] = [] + + @router.on_reaction_update() + async def on_reaction_update(event, _client): + seen.append( + ( + event.message_id, + event.chat_id, + event.total_count, + event.counters[0].count, + event.counters[0].reaction, + ) + ) + + await dispatcher.dispatch( + frame( + { + "messageId": "116739131144745294", + "chatId": 239067070, + "counters": [{"count": 1, "reaction": "👍"}], + "totalCount": 1, + }, + opcode=Opcode.NOTIF_MSG_REACTIONS_CHANGED, + cmd=Command.REQUEST, + ) + ) + + assert seen == [("116739131144745294", 239067070, 1, 1, "👍")] + + @pytest.mark.asyncio async def test_dispatcher_requires_bound_client_for_callbacks() -> None: dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) From 6644e2d7362bd9e20cf00ca59236970bfc3695cc Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:43:18 +0300 Subject: [PATCH 13/18] feat: add get message method --- src/pymax/api/messages/payloads.py | 5 ++++ src/pymax/api/messages/service.py | 22 ++++++++++++++++++ src/pymax/infra/message.py | 19 +++++++++++++++ src/pymax/types/domain/chat.py | 22 +++++++++++++++--- tests/api/test_message_service.py | 37 ++++++++++++++++++++++++++++++ tests/domain/test_bound_models.py | 5 ++++ 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index d0c606b..53d5b7e 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -12,6 +12,11 @@ from .enums import ItemType, ReadAction +class GetMessagesPayload(CamelModel): + chat_id: int + message_ids: list[int] + + class ReplyLink(CamelModel): type: str = "REPLY" # TODO: enum? message_id: int diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 396cd8b..6ac8060 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -34,6 +34,7 @@ ChatHistoryPayload, DeleteMessagePayload, GetFilePayload, + GetMessagesPayload, GetReactionsPayload, GetVideoPayload, PinMessagePayload, @@ -135,6 +136,27 @@ async def send_message( logger.info("message sent chat_id=%s", chat_id) return message + async def get_message( + self, + chat_id: int, + message_id: int, + ) -> Message | None: + frame = GetMessagesPayload( + chat_id=chat_id, + message_ids=[message_id], + ) + + response = await self.app.invoke(Opcode.MSG_GET, frame.to_payload()) + messages = parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) + if not messages: + return None + + message = messages[0] + if message.chat_id is None: + message.chat_id = chat_id + + return bind_api_model(self.app, message) + async def fetch_history( self, chat_id: int, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 3ad77dc..7a3e538 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -43,6 +43,25 @@ async def send_message( notify=notify, ) + async def get_message( + self, + chat_id: int, + message_id: int, + ) -> Message | None: + """Возвращает сообщение по ID. + + Args: + chat_id: ID чата. + message_id: ID сообщения. + + Returns: + Сообщение или ``None``, если сервер его не вернул. + """ + return await self._app.api.messages.get_message( + chat_id=chat_id, + message_id=message_id, + ) + async def fetch_history( self, chat_id: int, diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index 62c23e1..902c32a 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -19,9 +19,9 @@ class Chat(CamelModel): Объекты чатов, полученные через клиент, обычно уже привязаны к сервисам сообщений и чатов. После этого можно вызывать удобные методы объекта: - :meth:`answer`, :meth:`history`, :meth:`leave`, :meth:`invite`, - :meth:`remove_users`, :meth:`pin_message`, :meth:`update_settings` и - :meth:`rework_invite_link`. + :meth:`answer`, :meth:`history`, :meth:`get_message`, :meth:`leave`, + :meth:`invite`, :meth:`remove_users`, :meth:`pin_message`, + :meth:`update_settings` и :meth:`rework_invite_link`. Используйте ``Chat`` для работы с конкретным диалогом, группой или каналом. ``client.chats`` содержит чаты из login/sync, а недостающие чаты можно @@ -246,6 +246,22 @@ async def history( interactive=interactive, ) + async def get_message(self, message_id: int) -> Message | None: + """Возвращает сообщение этого чата по ID. + + :param message_id: ID сообщения. + :type message_id: int + :returns: Сообщение или ``None``, если сервер его не вернул. + :rtype: Message | None + :raises RuntimeError: Если чат не привязан к клиенту. + """ + actions, _ = self._bound() + + return await actions.get_message( + chat_id=self.id, + message_id=message_id, + ) + async def leave(self) -> None: """Выходит из группы или канала. diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 8ba1225..16497af 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -103,6 +103,43 @@ async def test_fetch_history_builds_payload_and_parses_messages( assert app.calls[0].payload["getChat"] is True +@pytest.mark.asyncio +async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: + app = FakeApp( + [ + frame( + { + MessagePayloadKey.MESSAGES.value: [ + message_payload( + 116739188629507992, + None, + "message", + ) + ] + } + ), + frame({MessagePayloadKey.MESSAGES.value: []}), + ] + ) + + message = await app.api.messages.get_message( + 239067070, + 116739188629507992, + ) + missing = await app.api.messages.get_message(239067070, 1) + + assert message is not None + assert message.id == 116739188629507992 + assert message.chat_id == 239067070 + assert message._actions is app.api.messages + assert missing is None + assert app.calls[0].opcode == Opcode.MSG_GET + assert app.calls[0].payload == { + "chatId": 239067070, + "messageIds": [116739188629507992], + } + + @pytest.mark.asyncio async def test_delete_pin_and_read_message_send_expected_opcodes( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index a552f6e..09233ee 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 get_message(self, *args, **kwargs): + self.calls.append(("get_message", args, kwargs)) + return "message" + async def pin_message(self, *args, **kwargs): self.calls.append(("pin_message", args, kwargs)) return True @@ -122,6 +126,7 @@ async def test_chat_bound_methods_delegate_by_chat_type() -> None: assert await group.answer("hello") == "sent" assert await group.history(backward=1) == ["history"] + assert await group.get_message(10) == "message" await group.leave() await channel.leave() assert await group.invite([1, 2]) == "group" From 874c804e8e5a58b4969c53c960cec37252e13449 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:44:37 +0300 Subject: [PATCH 14/18] feat: add edit message method --- src/pymax/api/messages/enums.py | 1 + src/pymax/api/messages/payloads.py | 10 ++++ src/pymax/api/messages/service.py | 38 ++++++++++++ src/pymax/infra/message.py | 31 +++++++++- src/pymax/types/domain/message.py | 32 +++++++++- tests/api/test_message_service.py | 93 +++++++++++++++++++++++++++++- tests/domain/test_bound_models.py | 19 +++++- 7 files changed, 219 insertions(+), 5 deletions(-) diff --git a/src/pymax/api/messages/enums.py b/src/pymax/api/messages/enums.py index 94138cb..dd51701 100644 --- a/src/pymax/api/messages/enums.py +++ b/src/pymax/api/messages/enums.py @@ -12,6 +12,7 @@ class ReadAction(str, Enum): class MessagePayloadKey(str, Enum): + MESSAGE = "message" MESSAGES = "messages" REACTION_INFO = "reactionInfo" MESSAGES_REACTIONS = "messagesReactions" diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 53d5b7e..8c8f460 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -17,6 +17,16 @@ class GetMessagesPayload(CamelModel): message_ids: list[int] +class EditMessagePayload(CamelModel): + chat_id: int + message_id: int + text: str + elements: list[Any] + attachments: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field( + default_factory=list + ) + + class ReplyLink(CamelModel): type: str = "REPLY" # TODO: enum? message_id: int diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 6ac8060..e74b4b2 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -8,6 +8,7 @@ parse_payload_list, parse_payload_model, payload_item, + require_payload_item_model, require_payload_model, ) from pymax.api.uploads.payloads import ( @@ -33,6 +34,7 @@ AddReactionPayload, ChatHistoryPayload, DeleteMessagePayload, + EditMessagePayload, GetFilePayload, GetMessagesPayload, GetReactionsPayload, @@ -157,6 +159,42 @@ async def get_message( return bind_api_model(self.app, message) + async def edit_message( + self, + chat_id: int, + message_id: int, + text: str, + attachment: SendAttachment | None = None, + attachments: SendAttachments = None, + ) -> Message: + if attachment is not None and attachments: + logger.warning("both attachment and attachments provided; using attachments") + attachment = None + + edit_attachments = attachments + if attachment is not None: + edit_attachments = [attachment] + + clean_text, elements = Formatter.format_markdown(text) + frame = EditMessagePayload( + chat_id=chat_id, + message_id=message_id, + text=clean_text, + elements=elements, + attachments=await self._upload_attachments(edit_attachments), + ) + + response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload()) + message = require_payload_item_model( + response, + MessagePayloadKey.MESSAGE, + Message, + ) + if message.chat_id is None: + message.chat_id = chat_id + + return bind_api_model(self.app, message) + async def fetch_history( self, chat_id: int, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index 7a3e538..fee2ab2 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -1,5 +1,5 @@ from pymax.api.messages.enums import ItemType -from pymax.api.messages.service import SendAttachments +from pymax.api.messages.service import SendAttachment, SendAttachments from pymax.types import ( FileRequest, Message, @@ -62,6 +62,35 @@ async def get_message( message_id=message_id, ) + async def edit_message( + self, + chat_id: int, + message_id: int, + text: str, + attachment: SendAttachment | None = None, + attachments: SendAttachments = None, + ) -> Message: + """Редактирует текст и вложения сообщения. + + Args: + chat_id: ID чата. + message_id: ID сообщения. + text: Новый текст сообщения с поддержкой markdown. + attachment: Одно новое вложение. + attachments: Список новых вложений. Имеет приоритет над + ``attachment``. + + Returns: + Отредактированное сообщение. + """ + return await self._app.api.messages.edit_message( + chat_id=chat_id, + message_id=message_id, + text=text, + attachment=attachment, + attachments=attachments, + ) + async def fetch_history( self, chat_id: int, diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index 029fdbc..608852b 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -92,7 +92,7 @@ class Message(CamelModel): Сообщения, полученные через клиент, обычно уже привязаны к сервису сообщений. После этого можно вызывать удобные методы объекта: - :meth:`reply`, :meth:`answer`, :meth:`pin`, :meth:`delete`, + :meth:`reply`, :meth:`answer`, :meth:`edit`, :meth:`pin`, :meth:`delete`, :meth:`read`, :meth:`react`, :meth:`unreact` и :meth:`get_reactions`. Используйте ``Message`` в обработчиках ``on_message`` и при работе с @@ -261,6 +261,36 @@ async def pin(self, notify_pin: bool = True) -> bool: notify_pin=notify_pin, ) + async def edit( + self, + text: str, + attachment: SendAttachment | None = None, + attachments: SendAttachments = None, + ) -> Message: + """Редактирует текст и вложения этого сообщения. + + :param text: Новый текст сообщения с поддержкой markdown. + :type text: str + :param attachment: Одно новое вложение. + :type attachment: SendAttachment | None + :param attachments: Список новых вложений. Имеет приоритет над + ``attachment``. + :type attachments: SendAttachments + :returns: Отредактированное сообщение. + :rtype: Message + :raises RuntimeError: Если сообщение не привязано к сервису или не + содержит ``chat_id``. + """ + actions, chat_id = self._bound() + + return await actions.edit_message( + chat_id=chat_id, + message_id=self.id, + text=text, + attachment=attachment, + attachments=attachments, + ) + async def delete(self, for_me: bool = False) -> bool: """Удаляет это сообщение. diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 16497af..5749468 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 +from pymax.files import File, Photo, Video from pymax.protocol import Opcode from tests.conftest import FakeApp, frame, message_payload @@ -140,6 +140,97 @@ async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: } +@pytest.mark.asyncio +async def test_edit_message_formats_text_and_parses_bound_message() -> None: + app = FakeApp( + [ + frame( + { + MessagePayloadKey.MESSAGE.value: { + **message_payload( + 116739188629507992, + None, + "edited", + status="EDITED", + ), + "sender": 255000689, + "updateTime": 1781298658480, + "cid": -1781298654603, + "attaches": [], + } + } + ) + ] + ) + + message = await app.api.messages.edit_message( + 239067070, + 116739188629507992, + "edited **text**", + ) + + assert message.id == 116739188629507992 + assert message.chat_id == 239067070 + assert message.status == "EDITED" + assert message._actions is app.api.messages + assert app.calls[0].opcode == Opcode.MSG_EDIT + assert app.calls[0].payload == { + "chatId": 239067070, + "messageId": 116739188629507992, + "text": "edited text", + "elements": [ + { + "type": "STRONG", + "from": 7, + "length": 4, + } + ], + "attachments": [], + } + + +@pytest.mark.asyncio +async def test_edit_message_uploads_single_and_multiple_attachments() -> None: + response_message = { + MessagePayloadKey.MESSAGE.value: message_payload( + 116739188629507992, + None, + "edited", + status="EDITED", + ) + } + app = FakeApp([frame(response_message), frame(response_message)]) + photo = Photo(raw=b"image", name="image.jpg") + ignored_photo = Photo(raw=b"ignored", name="ignored.jpg") + file = File(raw=b"file", name="file.txt") + video = Video(raw=b"video", name="video.mp4") + + await app.api.messages.edit_message( + 239067070, + 116739188629507992, + "photo", + attachment=photo, + ) + await app.api.messages.edit_message( + 239067070, + 116739188629507992, + "files", + attachment=ignored_photo, + attachments=[file, video], + ) + + assert app.api.uploads.calls == [ + ("photo", photo), + ("file", file), + ("video", video), + ] + 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"}, + ] + + @pytest.mark.asyncio async def test_delete_pin_and_read_message_send_expected_opcodes( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index 09233ee..ceeb9f6 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -18,6 +18,10 @@ async def get_message(self, *args, **kwargs): self.calls.append(("get_message", args, kwargs)) return "message" + async def edit_message(self, *args, **kwargs): + self.calls.append(("edit_message", args, kwargs)) + return "edited" + async def pin_message(self, *args, **kwargs): self.calls.append(("pin_message", args, kwargs)) return True @@ -95,6 +99,14 @@ 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.edit( + "edited", + attachment="photo", + attachments=["file"], + ) + == "edited" + ) assert await message.pin(notify_pin=False) is True assert await message.delete(for_me=True) is True assert await message.read() == "read" @@ -104,8 +116,11 @@ 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[3][2]["message_ids"] == [10] - assert actions.calls[5][2]["message_id"] == "10" + assert actions.calls[2][2]["message_id"] == 10 + assert actions.calls[2][2]["attachment"] == "photo" + assert actions.calls[2][2]["attachments"] == ["file"] + assert actions.calls[4][2]["message_ids"] == [10] + assert actions.calls[6][2]["message_id"] == "10" @pytest.mark.asyncio From c70a342d5e594a96fc7eed7b5354d75a718af814 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:52:38 +0300 Subject: [PATCH 15/18] feat: add get messages method --- src/pymax/api/messages/service.py | 25 +++++++++++++---------- src/pymax/infra/message.py | 19 ++++++++++++++++++ src/pymax/types/domain/chat.py | 23 ++++++++++++++++++--- tests/api/test_message_service.py | 33 ++++++++++++++++++++++++++++--- tests/domain/test_bound_models.py | 5 +++++ 5 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index e74b4b2..668b4ec 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -138,26 +138,31 @@ async def send_message( logger.info("message sent chat_id=%s", chat_id) return message - async def get_message( + async def get_messages( self, chat_id: int, - message_id: int, - ) -> Message | None: + message_ids: list[int], + ) -> list[Message]: frame = GetMessagesPayload( chat_id=chat_id, - message_ids=[message_id], + message_ids=message_ids, ) response = await self.app.invoke(Opcode.MSG_GET, frame.to_payload()) messages = parse_payload_list(response, MessagePayloadKey.MESSAGES, Message) - if not messages: - return None + for message in messages: + if message.chat_id is None: + message.chat_id = chat_id - message = messages[0] - if message.chat_id is None: - message.chat_id = chat_id + return bind_api_models(self.app, messages) - return bind_api_model(self.app, message) + async def get_message( + self, + chat_id: int, + message_id: int, + ) -> Message | None: + messages = await self.get_messages(chat_id, [message_id]) + return messages[0] if messages else None async def edit_message( self, diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index fee2ab2..af8e597 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -62,6 +62,25 @@ async def get_message( message_id=message_id, ) + async def get_messages( + self, + chat_id: int, + message_ids: list[int], + ) -> list[Message]: + """Возвращает сообщения по ID. + + Args: + chat_id: ID чата. + message_ids: ID сообщений. + + Returns: + Список найденных сообщений. + """ + return await self._app.api.messages.get_messages( + chat_id=chat_id, + message_ids=message_ids, + ) + async def edit_message( self, chat_id: int, diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index 902c32a..1b46d0e 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -19,9 +19,10 @@ class Chat(CamelModel): Объекты чатов, полученные через клиент, обычно уже привязаны к сервисам сообщений и чатов. После этого можно вызывать удобные методы объекта: - :meth:`answer`, :meth:`history`, :meth:`get_message`, :meth:`leave`, - :meth:`invite`, :meth:`remove_users`, :meth:`pin_message`, - :meth:`update_settings` и :meth:`rework_invite_link`. + :meth:`answer`, :meth:`history`, :meth:`get_message`, + :meth:`get_messages`, :meth:`leave`, :meth:`invite`, + :meth:`remove_users`, :meth:`pin_message`, :meth:`update_settings` и + :meth:`rework_invite_link`. Используйте ``Chat`` для работы с конкретным диалогом, группой или каналом. ``client.chats`` содержит чаты из login/sync, а недостающие чаты можно @@ -262,6 +263,22 @@ async def get_message(self, message_id: int) -> Message | None: message_id=message_id, ) + async def get_messages(self, message_ids: list[int]) -> list[Message]: + """Возвращает сообщения этого чата по ID. + + :param message_ids: ID сообщений. + :type message_ids: list[int] + :returns: Список найденных сообщений. + :rtype: list[Message] + :raises RuntimeError: Если чат не привязан к клиенту. + """ + actions, _ = self._bound() + + return await actions.get_messages( + chat_id=self.id, + message_ids=message_ids, + ) + async def leave(self) -> None: """Выходит из группы или канала. diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 5749468..aa2006e 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -104,7 +104,7 @@ async def test_fetch_history_builds_payload_and_parses_messages( @pytest.mark.asyncio -async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: +async def test_get_messages_and_get_message_bind_results_and_restore_chat_id() -> None: app = FakeApp( [ frame( @@ -113,7 +113,23 @@ async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: message_payload( 116739188629507992, None, - "message", + "one", + ), + message_payload( + 116739188629507993, + None, + "two", + ), + ] + } + ), + frame( + { + MessagePayloadKey.MESSAGES.value: [ + message_payload( + 116739188629507992, + None, + "one", ) ] } @@ -122,12 +138,22 @@ async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: ] ) + messages = await app.api.messages.get_messages( + 239067070, + [116739188629507992, 116739188629507993], + ) message = await app.api.messages.get_message( 239067070, 116739188629507992, ) missing = await app.api.messages.get_message(239067070, 1) + assert [item.id for item in messages] == [ + 116739188629507992, + 116739188629507993, + ] + assert all(item.chat_id == 239067070 for item in messages) + assert all(item._actions is app.api.messages for item in messages) assert message is not None assert message.id == 116739188629507992 assert message.chat_id == 239067070 @@ -136,8 +162,9 @@ async def test_get_message_returns_bound_message_and_restores_chat_id() -> None: assert app.calls[0].opcode == Opcode.MSG_GET assert app.calls[0].payload == { "chatId": 239067070, - "messageIds": [116739188629507992], + "messageIds": [116739188629507992, 116739188629507993], } + assert app.calls[1].payload["messageIds"] == [116739188629507992] @pytest.mark.asyncio diff --git a/tests/domain/test_bound_models.py b/tests/domain/test_bound_models.py index ceeb9f6..9f05a91 100644 --- a/tests/domain/test_bound_models.py +++ b/tests/domain/test_bound_models.py @@ -18,6 +18,10 @@ async def get_message(self, *args, **kwargs): self.calls.append(("get_message", args, kwargs)) return "message" + async def get_messages(self, *args, **kwargs): + self.calls.append(("get_messages", args, kwargs)) + return ["messages"] + async def edit_message(self, *args, **kwargs): self.calls.append(("edit_message", args, kwargs)) return "edited" @@ -142,6 +146,7 @@ async def test_chat_bound_methods_delegate_by_chat_type() -> None: assert await group.answer("hello") == "sent" assert await group.history(backward=1) == ["history"] assert await group.get_message(10) == "message" + assert await group.get_messages([10, 11]) == ["messages"] await group.leave() await channel.leave() assert await group.invite([1, 2]) == "group" From 8346c482a79906178b3575c970c3d78d7489b03f Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:53:51 +0300 Subject: [PATCH 16/18] feat: add read and presence events --- src/pymax/__init__.py | 4 ++ src/pymax/base.py | 24 +++++++++- src/pymax/dispatch/dispatcher.py | 19 +++++++- src/pymax/dispatch/enums.py | 2 + src/pymax/dispatch/mapping.py | 10 +++++ src/pymax/dispatch/resolvers.py | 8 ++++ src/pymax/dispatch/router.py | 21 ++++++++- src/pymax/types/events/__init__.py | 2 + src/pymax/types/events/mark.py | 8 ++++ src/pymax/types/events/presence.py | 7 +++ tests/dispatch/test_dispatcher.py | 70 ++++++++++++++++++++++++++++++ 11 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 src/pymax/types/events/mark.py create mode 100644 src/pymax/types/events/presence.py diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index d59ad6a..fbc454f 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -24,6 +24,8 @@ Chat, Message, MessageDeleteEvent, + MessageReadEvent, + PresenceEvent, Profile, ReactionUpdateEvent, TypingEvent, @@ -45,8 +47,10 @@ "File", "Message", "MessageDeleteEvent", + "MessageReadEvent", "PasswordProvider", "Photo", + "PresenceEvent", "Profile", "PyMaxError", "QrAuthFlow", diff --git a/src/pymax/base.py b/src/pymax/base.py index 8bbc692..db3383c 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -22,7 +22,15 @@ StartDecorator, ) from pymax.protocol import InboundFrame - from pymax.types import Chat, MessageDeleteEvent, ReactionUpdateEvent, TypingEvent, User + from pymax.types import ( + Chat, + MessageDeleteEvent, + MessageReadEvent, + PresenceEvent, + ReactionUpdateEvent, + TypingEvent, + User, + ) from pymax.types.domain import Message, Profile logger = get_logger(__name__) @@ -187,6 +195,13 @@ def on_message_delete( """Регистрирует обработчик удаления сообщений.""" return self._router.on_message_delete(*filters) + def on_message_read( + self, + *filters: FilterCallback[MessageReadEvent], + ) -> HandlerDecorator[MessageReadEvent, ClientT]: + """Регистрирует обработчик изменения отметки прочтения.""" + return self._router.on_message_read(*filters) + def on_typing( self, *filters: FilterCallback[TypingEvent], @@ -194,6 +209,13 @@ def on_typing( """Регистрирует обработчик набора текста.""" return self._router.on_typing(*filters) + def on_presence( + self, + *filters: FilterCallback[PresenceEvent], + ) -> HandlerDecorator[PresenceEvent, ClientT]: + """Регистрирует обработчик изменения присутствия пользователя.""" + return self._router.on_presence(*filters) + def on_reaction_update( self, *filters: FilterCallback[ReactionUpdateEvent], diff --git a/src/pymax/dispatch/dispatcher.py b/src/pymax/dispatch/dispatcher.py index 62d037d..0579add 100644 --- a/src/pymax/dispatch/dispatcher.py +++ b/src/pymax/dispatch/dispatcher.py @@ -9,7 +9,12 @@ from pymax.protocol import InboundFrame from pymax.types import Chat, MessageDeleteEvent from pymax.types.domain import Message -from pymax.types.events import ReactionUpdateEvent, TypingEvent +from pymax.types.events import ( + MessageReadEvent, + PresenceEvent, + ReactionUpdateEvent, + TypingEvent, +) from .enums import EventType from .mapping import EventMapper, EventResolver @@ -93,12 +98,24 @@ def on_message_delete( ) -> HandlerDecorator[MessageDeleteEvent, ClientT]: return self.root_router.on_message_delete(*filters) + def on_message_read( + self, + *filters: FilterCallback[MessageReadEvent], + ) -> HandlerDecorator[MessageReadEvent, ClientT]: + return self.root_router.on_message_read(*filters) + def on_typing( self, *filters: FilterCallback[TypingEvent], ) -> HandlerDecorator[TypingEvent, ClientT]: return self.root_router.on_typing(*filters) + def on_presence( + self, + *filters: FilterCallback[PresenceEvent], + ) -> HandlerDecorator[PresenceEvent, ClientT]: + return self.root_router.on_presence(*filters) + def on_reaction_update( self, *filters: FilterCallback[ReactionUpdateEvent], diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index 8f918ed..98bb008 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -5,7 +5,9 @@ class EventType(str, Enum): MESSAGE_NEW = "message_new" MESSAGE_EDIT = "message_edit" MESSAGE_DELETE = "message_delete" + MESSAGE_READ = "message_read" TYPING = "typing" + PRESENCE = "presence" REACTION_UPDATE = "reaction_update" CHAT_UPDATE = "chat_update" USER_UPDATE = "user_update" diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index a44d1ad..e814948 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -10,6 +10,8 @@ from pymax.types.domain import Message from pymax.types.events import ( FileUploadSignal, + MessageReadEvent, + PresenceEvent, ReactionUpdateEvent, TypingEvent, VideoUploadSignal, @@ -21,6 +23,8 @@ resolve_chat, resolve_message, resolve_message_delete, + resolve_message_read, + resolve_presence, resolve_reaction_update, resolve_typing, ) @@ -37,6 +41,8 @@ Opcode.NOTIF_MSG_DELETE: resolve_message_delete, Opcode.NOTIF_ATTACH: resolve_attach, Opcode.NOTIF_TYPING: resolve_typing, + Opcode.NOTIF_MARK: resolve_message_read, + Opcode.NOTIF_PRESENCE: resolve_presence, Opcode.NOTIF_MSG_REACTIONS_CHANGED: resolve_reaction_update, } @@ -82,8 +88,12 @@ def map(self, event_type: EventType, frame: InboundFrame): self.app, MessageDeleteEvent.model_validate(frame.payload), ) + elif event_type == EventType.MESSAGE_READ: + return MessageReadEvent.model_validate(frame.payload) elif event_type == EventType.TYPING: return TypingEvent.model_validate(frame.payload) + elif event_type == EventType.PRESENCE: + return PresenceEvent.model_validate(frame.payload) elif event_type == EventType.REACTION_UPDATE: return ReactionUpdateEvent.model_validate(frame.payload) elif event_type == EventType.VIDEO_READY: diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index 064770f..25d72d8 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -20,10 +20,18 @@ def resolve_message_delete(_: InboundFrame) -> EventType | None: return EventType.MESSAGE_DELETE +def resolve_message_read(_: InboundFrame) -> EventType | None: + return EventType.MESSAGE_READ + + def resolve_typing(_: InboundFrame) -> EventType | None: return EventType.TYPING +def resolve_presence(_: InboundFrame) -> EventType | None: + return EventType.PRESENCE + + def resolve_reaction_update(_: InboundFrame) -> EventType | None: return EventType.REACTION_UPDATE diff --git a/src/pymax/dispatch/router.py b/src/pymax/dispatch/router.py index 304000d..24dd572 100644 --- a/src/pymax/dispatch/router.py +++ b/src/pymax/dispatch/router.py @@ -14,7 +14,12 @@ from pymax.protocol import InboundFrame from pymax.types import Chat from pymax.types.domain import Message - from pymax.types.events import ReactionUpdateEvent, TypingEvent + from pymax.types.events import ( + MessageReadEvent, + PresenceEvent, + ReactionUpdateEvent, + TypingEvent, + ) _EventT = TypeVar("_EventT") @@ -187,6 +192,13 @@ def on_message_delete( """ return self.on(EventType.MESSAGE_DELETE, *filters) + def on_message_read( + self, + *filters: FilterCallback[MessageReadEvent], + ) -> HandlerDecorator[MessageReadEvent, ClientT]: + """Регистрирует обработчик изменения отметки прочтения.""" + return self.on(EventType.MESSAGE_READ, *filters) + def on_typing( self, *filters: FilterCallback[TypingEvent], @@ -194,6 +206,13 @@ def on_typing( """Регистрирует обработчик набора текста.""" return self.on(EventType.TYPING, *filters) + def on_presence( + self, + *filters: FilterCallback[PresenceEvent], + ) -> HandlerDecorator[PresenceEvent, ClientT]: + """Регистрирует обработчик изменения присутствия пользователя.""" + return self.on(EventType.PRESENCE, *filters) + def on_reaction_update( self, *filters: FilterCallback[ReactionUpdateEvent], diff --git a/src/pymax/types/events/__init__.py b/src/pymax/types/events/__init__.py index 6803670..1667633 100644 --- a/src/pymax/types/events/__init__.py +++ b/src/pymax/types/events/__init__.py @@ -1,5 +1,7 @@ from .file import FileUploadSignal +from .mark import MessageReadEvent from .message import MessageDeleteEvent +from .presence import PresenceEvent from .reaction import ReactionUpdateEvent from .typing import TypingEvent from .video import VideoUploadSignal diff --git a/src/pymax/types/events/mark.py b/src/pymax/types/events/mark.py new file mode 100644 index 0000000..65183b0 --- /dev/null +++ b/src/pymax/types/events/mark.py @@ -0,0 +1,8 @@ +from pymax.types.domain.base import CamelModel + + +class MessageReadEvent(CamelModel): + set_as_unread: bool + chat_id: int + user_id: int + mark: int diff --git a/src/pymax/types/events/presence.py b/src/pymax/types/events/presence.py new file mode 100644 index 0000000..ef93bed --- /dev/null +++ b/src/pymax/types/events/presence.py @@ -0,0 +1,7 @@ +from pymax.types.domain.base import CamelModel +from pymax.types.domain.presence import Presence + + +class PresenceEvent(CamelModel): + presence: Presence + user_id: int diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index 988cee5..519131c 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -217,6 +217,76 @@ async def on_reaction_update(event, _client): assert seen == [("116739131144745294", 239067070, 1, 1, "👍")] +@pytest.mark.asyncio +async def test_dispatcher_maps_message_read_event() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[bool, int, int, int]] = [] + + @router.on_message_read() + async def on_message_read(event, _client): + seen.append( + ( + event.set_as_unread, + event.chat_id, + event.user_id, + event.mark, + ) + ) + + await dispatcher.dispatch( + frame( + { + "setAsUnread": False, + "chatId": 239067070, + "userId": 17620943, + "mark": 1781354533949, + }, + opcode=Opcode.NOTIF_MARK, + cmd=Command.REQUEST, + ) + ) + + assert seen == [(False, 239067070, 17620943, 1781354533949)] + + +@pytest.mark.asyncio +async def test_dispatcher_maps_presence_event() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[int, int, int | None]] = [] + + @router.on_presence() + async def on_presence(event, _client): + seen.append( + ( + event.user_id, + event.presence.status, + event.presence.seen, + ) + ) + + await dispatcher.dispatch( + frame( + { + "presence": { + "seen": 1781354531, + "status": 1, + }, + "userId": 17620943, + }, + opcode=Opcode.NOTIF_PRESENCE, + cmd=Command.REQUEST, + ) + ) + + assert seen == [(17620943, 1, 1781354531)] + + @pytest.mark.asyncio async def test_dispatcher_requires_bound_client_for_callbacks() -> None: dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) From 25cf64a8c7550c5a08a35d58b2fa7bc6d30d21c9 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:18:29 +0300 Subject: [PATCH 17/18] fix: harden release event and auth handling --- src/pymax/api/messages/service.py | 2 +- src/pymax/api/response.py | 4 +- src/pymax/auth/sms.py | 6 +-- src/pymax/config.py | 12 +++++ src/pymax/logging.py | 2 + src/pymax/types/domain/auth.py | 16 ++++++- src/pymax/types/domain/presence.py | 6 +-- src/pymax/types/events/mark.py | 15 +++++++ src/pymax/types/events/message.py | 35 ++++++++++----- src/pymax/types/events/presence.py | 8 ++++ src/pymax/types/events/reaction.py | 12 +++++ src/pymax/types/events/typing.py | 8 ++++ tests/api/test_auth_service.py | 32 ++++++++++++++ tests/api/test_message_service.py | 15 ++++++- tests/auth/test_auth_flows.py | 71 ++++++++++++++++++++++++++++++ tests/dispatch/test_dispatcher.py | 34 +++++++++++++- 16 files changed, 251 insertions(+), 27 deletions(-) diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 668b4ec..6329b0b 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -406,7 +406,7 @@ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: frame = ReadMessagesPayload( type=ReadAction.READ_MESSAGE, chat_id=chat_id, - message_id=str(message_id), + message_id=message_id, mark=int(time.time() * 1000), ) diff --git a/src/pymax/api/response.py b/src/pymax/api/response.py index 944c0d9..1440746 100644 --- a/src/pymax/api/response.py +++ b/src/pymax/api/response.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, TypeVar, overload +from typing import Any, TypeVar, cast, overload from pydantic import BaseModel @@ -60,7 +60,7 @@ def payload_item( if validation_type is None: return data - return validation_type(data) + return cast(Any, validation_type)(data) def require_payload_item( diff --git a/src/pymax/auth/sms.py b/src/pymax/auth/sms.py index 2920b12..0244ac4 100644 --- a/src/pymax/auth/sms.py +++ b/src/pymax/auth/sms.py @@ -85,11 +85,7 @@ async def authenticate(self, app: App) -> AuthResult: ) elif result.register_token: if not app.config.registration_config: - logger.error( - "Registration token received, but registration config is missing " - "(client.extra_config.registration_config)" - ) - token = None + raise RuntimeError("RegistrationConfig is required to register a new account") else: registration_config = app.config.registration_config response = await app.api.auth.confirm_registration( diff --git a/src/pymax/config.py b/src/pymax/config.py index f66c972..f4bab88 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -85,6 +85,16 @@ class DeviceConfig(BaseModel): class RegistrationConfig(BaseModel): + """Данные профиля для регистрации нового аккаунта по SMS. + + Передайте объект через ``ExtraConfig.registration_config``. Он используется + только если после подтверждения SMS-кода Max вернул токен регистрации. + + Args: + first_name: Имя нового пользователя. + last_name: Фамилия нового пользователя. + """ + first_name: str last_name: str | None = None @@ -126,6 +136,8 @@ class ExtraConfig(BaseModel): Args: token: Готовый token для создания сессии без SMS/QR. + registration_config: Имя и фамилия для автоматического завершения + регистрации нового аккаунта по SMS. host: TCP host Max API. port: TCP port Max API. url: WebSocket URL для ``WebClient``. diff --git a/src/pymax/logging.py b/src/pymax/logging.py index 67a783a..230eb5d 100644 --- a/src/pymax/logging.py +++ b/src/pymax/logging.py @@ -85,6 +85,8 @@ def configure_logging( configure_logging("DEBUG", use_colors=False) """ stream = stream or sys.stderr + if stream is None: + raise RuntimeError("No logging stream is available") if use_colors is None: use_colors = hasattr(stream, "isatty") and stream.isatty() diff --git a/src/pymax/types/domain/auth.py b/src/pymax/types/domain/auth.py index 38f14e6..0db30aa 100644 --- a/src/pymax/types/domain/auth.py +++ b/src/pymax/types/domain/auth.py @@ -73,7 +73,7 @@ class CheckCodeResponse(CamelModel): :vartype password_challenge: PasswordChallenge | None """ - token_attrs: TokenAttrs = Field(default_factory=TokenAttrs) + token_attrs: TokenAttrs = Field(default_factory=lambda: TokenAttrs.model_validate({})) password_challenge: PasswordChallenge | None = None @property @@ -106,7 +106,7 @@ class CheckPasswordResponse(CamelModel): :vartype error: str | None """ - token_attrs: TokenAttrs = Field(default_factory=TokenAttrs) + token_attrs: TokenAttrs = Field(default_factory=lambda: TokenAttrs.model_validate({})) error: str | None = None @property @@ -165,6 +165,18 @@ class CheckQrResponse(CamelModel): class ConfirmRegistrationResponse(CamelModel): + """Ответ Max после регистрации нового аккаунта. + + :ivar user_token: Внутренний ID зарегистрированного пользователя. + :vartype user_token: int + :ivar profile: Профиль зарегистрированного аккаунта. + :vartype profile: Profile + :ivar token_type: Тип выданного токена. + :vartype token_type: AuthType + :ivar token: Токен входа для новой сессии. + :vartype token: str + """ + user_token: int profile: Profile token_type: AuthType diff --git a/src/pymax/types/domain/presence.py b/src/pymax/types/domain/presence.py index a3ef1b4..55e9a00 100644 --- a/src/pymax/types/domain/presence.py +++ b/src/pymax/types/domain/presence.py @@ -7,9 +7,9 @@ class Presence(CamelModel): :ivar seen: Время последней активности в формате Unix time, если оно передано сервером. :vartype seen: int | None - :ivar status: Код статуса присутствия Max. - :vartype status: int + :ivar status: Код статуса присутствия Max, если он передан сервером. + :vartype status: int | None """ seen: int | None = None - status: int + status: int | None = None diff --git a/src/pymax/types/events/mark.py b/src/pymax/types/events/mark.py index 65183b0..713abd9 100644 --- a/src/pymax/types/events/mark.py +++ b/src/pymax/types/events/mark.py @@ -2,6 +2,21 @@ class MessageReadEvent(CamelModel): + """Событие изменения отметки прочтения чата. + + Handler ``on_message_read`` получает объект, когда пользователь отмечает + сообщения прочитанными или возвращает чат в непрочитанное состояние. + + :ivar set_as_unread: Чат был явно отмечен непрочитанным. + :vartype set_as_unread: bool + :ivar chat_id: ID чата. + :vartype chat_id: int + :ivar user_id: ID пользователя, изменившего отметку. + :vartype user_id: int + :ivar mark: Временная отметка прочтения Max. + :vartype mark: int + """ + set_as_unread: bool chat_id: int user_id: int diff --git a/src/pymax/types/events/message.py b/src/pymax/types/events/message.py index 9f294ab..9293a7e 100644 --- a/src/pymax/types/events/message.py +++ b/src/pymax/types/events/message.py @@ -1,10 +1,10 @@ from __future__ import annotations -from logging import getLogger from typing import TYPE_CHECKING, Any from pydantic import PrivateAttr, model_validator +from pymax.logging import get_logger from pymax.types.domain import Chat from pymax.types.domain.base import CamelModel from pymax.types.domain.message import Message @@ -12,7 +12,7 @@ if TYPE_CHECKING: from pymax.api.messages import MessageService -logger = getLogger(__name__) +logger = get_logger(__name__) class MessageDeleteEvent(CamelModel): @@ -21,10 +21,14 @@ class MessageDeleteEvent(CamelModel): Handler ``on_message_delete`` получает этот объект, когда Max сообщает об удалении одного или нескольких сообщений в чате. - :ivar chat: Чат, в котором удалены сообщения. - :vartype chat: Chat :ivar message_ids: ID удаленных сообщений. :vartype message_ids: list[int] + :ivar chat_id: ID чата. + :vartype chat_id: int + :ivar chat: Чат, если Max прислал полный объект. + :vartype chat: Chat | None + :ivar message: Удаленное сообщение для WebSocket-события. + :vartype message: Message | None :ivar ttl: Признак удаления из-за TTL, если Max его прислал. :vartype ttl: bool """ @@ -49,20 +53,31 @@ def normalize_payload(cls, data: Any) -> Any: if "chat" in data: # case opcode == 142 chat = data["chat"] + chat_id = chat.get("id") if isinstance(chat, dict) else getattr(chat, "id", None) + message_ids = data.get("messageIds", data.get("message_ids")) + if chat_id is None or message_ids is None: + return data return { "chat": chat, - "ttl": data.get("ttl"), - "messageIds": data["messageIds"], - "chatId": chat["id"], + "ttl": data.get("ttl", False), + "messageIds": message_ids, + "chatId": chat_id, } if "message" in data: # case opcode == 128 message = data["message"] + message_id = ( + message.get("id") if isinstance(message, dict) else getattr(message, "id", None) + ) + chat_id = data.get("chatId", data.get("chat_id")) + if chat_id is None or message_id is None: + return data + return { - "chatId": data["chatId"], + "chatId": chat_id, "message": message, - "ttl": data["ttl"], - "messageIds": [message["id"]], + "ttl": data.get("ttl", False), + "messageIds": [message_id], } logger.warning("Illegal state during MessageDeleteEvent validation. Starting fallback") diff --git a/src/pymax/types/events/presence.py b/src/pymax/types/events/presence.py index ef93bed..e742b2e 100644 --- a/src/pymax/types/events/presence.py +++ b/src/pymax/types/events/presence.py @@ -3,5 +3,13 @@ class PresenceEvent(CamelModel): + """Событие изменения присутствия пользователя. + + :ivar presence: Новое состояние присутствия. + :vartype presence: Presence + :ivar user_id: ID пользователя. + :vartype user_id: int + """ + presence: Presence user_id: int diff --git a/src/pymax/types/events/reaction.py b/src/pymax/types/events/reaction.py index ca68e54..964729a 100644 --- a/src/pymax/types/events/reaction.py +++ b/src/pymax/types/events/reaction.py @@ -3,6 +3,18 @@ class ReactionUpdateEvent(CamelModel): + """Событие обновления реакций сообщения. + + :ivar message_id: ID сообщения. + :vartype message_id: str + :ivar chat_id: ID чата. + :vartype chat_id: int + :ivar counters: Счетчики реакций по типам. + :vartype counters: list[ReactionCounter] + :ivar total_count: Общее количество реакций. + :vartype total_count: int + """ + message_id: str chat_id: int counters: list[ReactionCounter] diff --git a/src/pymax/types/events/typing.py b/src/pymax/types/events/typing.py index bffcfea..35e45f5 100644 --- a/src/pymax/types/events/typing.py +++ b/src/pymax/types/events/typing.py @@ -2,5 +2,13 @@ class TypingEvent(CamelModel): + """Событие набора текста пользователем. + + :ivar chat_id: ID чата. + :vartype chat_id: int + :ivar user_id: ID пользователя, который набирает текст. + :vartype user_id: int + """ + chat_id: int user_id: int diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index 59e6c64..c2a3daf 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -244,6 +244,38 @@ async def test_qr_auth_service_methods_send_expected_payloads() -> None: assert app.calls[3].payload == {"qrLink": "max://qr"} +@pytest.mark.asyncio +async def test_confirm_registration_sends_profile_and_parses_token() -> None: + app = FakeApp( + [ + frame( + { + "userToken": 42, + "profile": profile_payload(42), + "tokenType": "REGISTER", + "token": "registered-token", + } + ) + ] + ) + + result = await app.api.auth.confirm_registration( + first_name="Max", + last_name="User", + token="register-token", + ) + + assert result.token == "registered-token" + assert result.profile.contact.id == 42 + assert app.calls[0].opcode == Opcode.AUTH_CONFIRM + assert app.calls[0].payload == { + "firstName": "Max", + "lastName": "User", + "token": "register-token", + "tokenType": "REGISTER", + } + + @pytest.mark.asyncio async def test_check_2fa_reads_profile_options() -> None: app = FakeApp() diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index aa2006e..9bb4dbe 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -263,21 +263,32 @@ async def test_delete_pin_and_read_message_send_expected_opcodes( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr("pymax.api.messages.service.time.time", lambda: 3000.0) - app = FakeApp([frame({}), frame({}), frame({"unread": 0, "mark": 3000000})]) + app = FakeApp( + [ + frame({}), + frame({}), + frame({"unread": 0, "mark": 3000000}), + frame({"unread": 0, "mark": 3000000}), + ] + ) assert await app.api.messages.delete_message(100, [1, 2], for_me=True) is True assert await app.api.messages.pin_message(100, 2, notify_pin=False) is True read_state = await app.api.messages.read_message(2, 100) + web_read_state = await app.api.messages.read_message("3", 100) assert read_state.mark == 3000000 + assert web_read_state.mark == 3000000 assert [call.opcode for call in app.calls] == [ Opcode.MSG_DELETE, Opcode.CHAT_UPDATE, Opcode.CHAT_MARK, + Opcode.CHAT_MARK, ] assert app.calls[0].payload["forMe"] is True assert app.calls[1].payload["pinMessageId"] == 2 - assert app.calls[2].payload["messageId"] == "2" + assert app.calls[2].payload["messageId"] == 2 + assert app.calls[3].payload["messageId"] == "3" @pytest.mark.asyncio diff --git a/tests/auth/test_auth_flows.py b/tests/auth/test_auth_flows.py index 2a2cecc..96f29ce 100644 --- a/tests/auth/test_auth_flows.py +++ b/tests/auth/test_auth_flows.py @@ -7,6 +7,7 @@ from pymax.auth.qr import QrAuthFlow from pymax.auth.sms import SmsAuthFlow +from pymax.config import RegistrationConfig from pymax.types.domain.auth import ( CheckCodeResponse, CheckPasswordResponse, @@ -97,6 +98,76 @@ async def test_sms_auth_flow_requires_phone() -> None: await flow.authenticate(app) +class RegistrationAuthApi: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple]] = [] + + async def request_code(self, phone: str): + self.calls.append(("request_code", (phone,))) + return StartAuthResponse.model_validate( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 1, + "altActionDuration": 0, + } + ) + + async def send_code(self, token: str, code: str): + self.calls.append(("send_code", (token, code))) + return CheckCodeResponse.model_validate( + {"tokenAttrs": {"REGISTER": {"token": "register-token"}}} + ) + + async def confirm_registration( + self, + first_name: str, + last_name: str | None, + token: str, + ): + self.calls.append(("confirm_registration", (first_name, last_name, token))) + return SimpleNamespace(token="registered-token") + + +@pytest.mark.asyncio +async def test_sms_auth_flow_confirms_new_account_registration() -> None: + auth_api = RegistrationAuthApi() + app = SimpleNamespace( + config=SimpleNamespace( + phone="+79990000000", + registration_config=RegistrationConfig( + first_name="Max", + last_name="User", + ), + ), + api=SimpleNamespace(auth=auth_api), + ) + + result = await SmsAuthFlow(StaticCodeProvider()).authenticate(app) + + assert result.token == "registered-token" + assert auth_api.calls[-1] == ( + "confirm_registration", + ("Max", "User", "register-token"), + ) + + +@pytest.mark.asyncio +async def test_sms_auth_flow_requires_registration_config_for_new_account() -> None: + auth_api = RegistrationAuthApi() + app = SimpleNamespace( + config=SimpleNamespace( + phone="+79990000000", + registration_config=None, + ), + api=SimpleNamespace(auth=auth_api), + ) + + with pytest.raises(RuntimeError, match="RegistrationConfig is required"): + await SmsAuthFlow(StaticCodeProvider()).authenticate(app) + + class QrProvider: def __init__(self) -> None: self.links: list[str] = [] diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index 519131c..bbf8a37 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -89,7 +89,6 @@ async def on_file(signal, _client): { "chat": chat_payload(5), "messageIds": [1, 2], - "ttl": False, }, opcode=Opcode.NOTIF_MSG_DELETE, cmd=Command.REQUEST, @@ -138,7 +137,6 @@ async def on_delete(event, _client): "text": "deleted", "attaches": [], }, - "ttl": False, "unread": 0, "mark": 1781292158321, }, @@ -287,6 +285,38 @@ async def on_presence(event, _client): assert seen == [(17620943, 1, 1781354531)] +@pytest.mark.asyncio +async def test_dispatcher_maps_partial_presence_event_without_status() -> None: + app = FakeApp() + router: Router[str] = Router() + dispatcher: Dispatcher[str] = Dispatcher(app, router) + dispatcher.bind_client("client") + seen: list[tuple[int, int | None, int | None]] = [] + + @router.on_presence() + async def on_presence(event, _client): + seen.append( + ( + event.user_id, + event.presence.status, + event.presence.seen, + ) + ) + + await dispatcher.dispatch( + frame( + { + "presence": {"seen": 1781360047}, + "userId": 17620943, + }, + opcode=Opcode.NOTIF_PRESENCE, + cmd=Command.REQUEST, + ) + ) + + assert seen == [(17620943, None, 1781360047)] + + @pytest.mark.asyncio async def test_dispatcher_requires_bound_client_for_callbacks() -> None: dispatcher: Dispatcher[str] = Dispatcher(FakeApp()) From b0c5fce6523435632ef5e10e7a000fa448e81c30 Mon Sep 17 00:00:00 2001 From: ink-developer <142109011+ink-developer@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:18:48 +0300 Subject: [PATCH 18/18] chore: prepare release 2.2.0 --- docs/api/client-config.rst | 3 ++ docs/auth.rst | 25 ++++++++++++ docs/index.rst | 1 + docs/messages.rst | 54 +++++++++++++++++++++++++- docs/release-2-2-0.rst | 57 ++++++++++++++++++++++++++++ docs/router.rst | 12 ++++++ docs/types/index.rst | 4 ++ docs/types/message_read_event.rst | 6 +++ docs/types/presence_event.rst | 6 +++ docs/types/reaction_update_event.rst | 6 +++ docs/types/typing_event.rst | 6 +++ pyproject.toml | 3 +- src/pymax/__init__.py | 2 +- uv.lock | 2 +- 14 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 docs/release-2-2-0.rst create mode 100644 docs/types/message_read_event.rst create mode 100644 docs/types/presence_event.rst create mode 100644 docs/types/reaction_update_event.rst create mode 100644 docs/types/typing_event.rst diff --git a/docs/api/client-config.rst b/docs/api/client-config.rst index 816b8f5..819986e 100644 --- a/docs/api/client-config.rst +++ b/docs/api/client-config.rst @@ -8,4 +8,7 @@ Client Config .. autoclass:: ExtraConfig :members: +.. autoclass:: RegistrationConfig + :members: + .. autofunction:: configure_logging diff --git a/docs/auth.rst b/docs/auth.rst index c1cf03c..cd5443a 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -173,6 +173,31 @@ Flow должен иметь async-метод ``authenticate(app)`` и верн Если вам нужно только передать готовый token, проще использовать ``ExtraConfig(token="TOKEN")``. Тогда custom flow не нужен. +Регистрация нового аккаунта +--------------------------- + +Если номер ещё не зарегистрирован в Max, после SMS-кода сервер возвращает +токен регистрации. Чтобы стандартный ``SmsAuthFlow`` завершил создание +аккаунта, передайте имя через ``RegistrationConfig``: + +.. code-block:: python + + from pymax import Client, ExtraConfig, RegistrationConfig + + client = Client( + phone="+79990000000", + extra_config=ExtraConfig( + registration_config=RegistrationConfig( + first_name="Max", + last_name="User", + ) + ), + ) + +Для уже существующего аккаунта эта настройка не используется. Если Max +вернул токен регистрации, а ``registration_config`` не задан, авторизация +завершится с ``RuntimeError``. + Управление 2FA -------------- diff --git a/docs/index.rst b/docs/index.rst index a083959..c646fda 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-2-0 release-2-1-3 release-2-1-2 release-2-1-1 diff --git a/docs/messages.rst b/docs/messages.rst index 7070e56..c530dd1 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -30,7 +30,27 @@ Messages @client.on_message_delete() async def on_delete(event: MessageDeleteEvent, client: Client) -> None: - print("deleted in chat:", event.chat.id) + print("deleted in chat:", event.chat_id) + +Получать и редактировать сообщения +---------------------------------- + +.. code-block:: python + + message = await client.get_message( + chat_id=123456, + message_id=987654, + ) + messages = await client.get_messages( + chat_id=123456, + message_ids=[987654, 987655], + ) + + if message is not None: + await message.edit("Обновленный текст") + +Через клиент то же редактирование доступно как +``client.edit_message(chat_id, message_id, text, ...)``. Отправлять сообщения -------------------- @@ -76,6 +96,38 @@ Messages WebSocket-клиент - как ``str``. Если вызываете метод напрямую, выбирайте тип по клиенту. +Служебные события +----------------- + +В ``2.2.0`` доступны отдельные обработчики набора текста, присутствия, +прочтения и реакций: + +.. code-block:: python + + from pymax import ( + Client, + MessageReadEvent, + PresenceEvent, + ReactionUpdateEvent, + TypingEvent, + ) + + @client.on_typing() + async def typing(event: TypingEvent, client: Client) -> None: + print(event.chat_id, event.user_id) + + @client.on_presence() + async def presence(event: PresenceEvent, client: Client) -> None: + print(event.user_id, event.presence.status) + + @client.on_message_read() + async def read(event: MessageReadEvent, client: Client) -> None: + print(event.chat_id, event.mark) + + @client.on_reaction_update() + async def reactions(event: ReactionUpdateEvent, client: Client) -> None: + print(event.message_id, event.total_count) + История сообщений ----------------- diff --git a/docs/release-2-2-0.rst b/docs/release-2-2-0.rst new file mode 100644 index 0000000..5bdb521 --- /dev/null +++ b/docs/release-2-2-0.rst @@ -0,0 +1,57 @@ +PyMax 2.2.0 +=========== + +Изменения относительно ``2.1.3``. + +Добавлено +--------- + +* Получение сообщений по ID через ``get_message()`` и ``get_messages()``. + Те же операции доступны на bound-объекте ``Chat``. +* Редактирование сообщений через ``edit_message()`` и ``Message.edit()`` с + поддержкой markdown, фото, видео и файлов. +* События ``TypingEvent``, ``PresenceEvent``, ``MessageReadEvent`` и + ``ReactionUpdateEvent`` с обработчиками ``on_typing()``, ``on_presence()``, + ``on_message_read()`` и ``on_reaction_update()``. +* ``join_channel()`` для вступления в канал по полной ссылке или join-токену. +* Автоматическое завершение SMS-регистрации нового аккаунта через + ``RegistrationConfig`` в ``ExtraConfig.registration_config``. +* Поддержка Python 3.14 в метаданных пакета и CI-матрице. + +Исправлено +---------- + +* Позиции markdown-элементов теперь корректно считаются в UTF-16 для emoji и + других символов вне BMP. +* Удаление сообщения в ``WebClient`` распознается из события + ``NOTIF_MESSAGE`` со статусом ``REMOVED``. +* ``MessageDeleteEvent`` принимает обе формы payload-а Max и не требует поле + ``ttl``. +* ``PresenceEvent`` принимает частичные обновления, в которых Max присылает + ``seen`` без ``status``. +* ``read_message()`` сохраняет тип ``message_id``: ``int`` для ``Client`` и + ``str`` для ``WebClient``. +* Вложенные ``Message``, ``Chat`` и ``User`` из API-ответов и событий + привязываются к сервисам клиента, поэтому их bound-методы работают + последовательно. + +Изменилось +---------- + +* ``MessageDeleteEvent`` всегда содержит ``chat_id``. Поля ``chat`` и + ``message`` зависят от формы события и могут быть ``None``. +* Проверка типов ``pyright`` ограничена релизным пакетом ``src`` и снова + проходит без ошибок. +* Документация клиента разделена на отдельные API-страницы для ``Client``, + ``WebClient`` и конфигурации. + +Миграция +-------- + +* В обработчике удаления используйте ``event.chat_id`` вместо + ``event.chat.id``: ``event.chat`` может отсутствовать в ``WebClient``. +* При прямом вызове ``read_message()`` передавайте ``int`` в ``Client`` и + ``str`` в ``WebClient``. +* Для регистрации нового номера задайте + ``ExtraConfig(registration_config=RegistrationConfig(...))``. Для уже + существующих аккаунтов настройка не нужна. diff --git a/docs/router.rst b/docs/router.rst index 26d4102..92046bf 100644 --- a/docs/router.rst +++ b/docs/router.rst @@ -175,6 +175,18 @@ Raw events async def raw(frame: InboundFrame, client: Client) -> None: print(frame.opcode, frame.payload) +Другие события +-------------- + +Кроме новых и измененных сообщений, доступны специализированные декораторы: + +* ``on_message_delete()`` и ``on_message_read()``; +* ``on_typing()`` и ``on_presence()``; +* ``on_reaction_update()`` и ``on_chat_update()``. + +Все они поддерживают те же sync/async-фильтры и сигнатуру +``handler(event, client)``. + Частые ошибки ------------- diff --git a/docs/types/index.rst b/docs/types/index.rst index ec9fbf0..abafcce 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -85,6 +85,10 @@ API reference chat message message_delete_event + message_read_event + typing_event + presence_event + reaction_update_event reaction_counter reaction_info read_state diff --git a/docs/types/message_read_event.rst b/docs/types/message_read_event.rst new file mode 100644 index 0000000..bdd3a4f --- /dev/null +++ b/docs/types/message_read_event.rst @@ -0,0 +1,6 @@ +MessageReadEvent +================ + +.. autoclass:: pymax.types.events.mark.MessageReadEvent + :members: + :show-inheritance: diff --git a/docs/types/presence_event.rst b/docs/types/presence_event.rst new file mode 100644 index 0000000..3f7feb9 --- /dev/null +++ b/docs/types/presence_event.rst @@ -0,0 +1,6 @@ +PresenceEvent +============= + +.. autoclass:: pymax.types.events.presence.PresenceEvent + :members: + :show-inheritance: diff --git a/docs/types/reaction_update_event.rst b/docs/types/reaction_update_event.rst new file mode 100644 index 0000000..bc93ba4 --- /dev/null +++ b/docs/types/reaction_update_event.rst @@ -0,0 +1,6 @@ +ReactionUpdateEvent +=================== + +.. autoclass:: pymax.types.events.reaction.ReactionUpdateEvent + :members: + :show-inheritance: diff --git a/docs/types/typing_event.rst b/docs/types/typing_event.rst new file mode 100644 index 0000000..510714b --- /dev/null +++ b/docs/types/typing_event.rst @@ -0,0 +1,6 @@ +TypingEvent +=========== + +.. autoclass:: pymax.types.events.typing.TypingEvent + :members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index a1e05cf..6217780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.1.3" +version = "2.2.0" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" @@ -87,6 +87,7 @@ dev = [ package = true [tool.pyright] +include = ["src"] venv = ".venv" venvPath = "." diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index fbc454f..07405f4 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.1.3" +__version__ = "2.2.0" from .auth import ( diff --git a/uv.lock b/uv.lock index 6d299fe..57111f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.1.3" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },