Skip to content

Conversation

@ink-developer
Copy link
Collaborator

@ink-developer ink-developer commented Dec 24, 2025

Описание

Кратко, что делает этот PR. Например, добавляет новый метод, исправляет баг, улучшает документацию.

Новые методы:

resolve_group_by_link

Измененные методы:

change_profile

Новые типы:

ContactAttach

Измененные типы:

Names
StickerAttach
Message
Photo
AttachType

Новые парамерты

contacts: list[User]

Тип изменений

  • Исправление бага
  • Новая функциональность
  • Улучшение документации
  • Рефакторинг

Связанные задачи / Issue

Ссылка на issue, если есть: #

Summary by CodeRabbit

Release Notes

  • New Features

    • Profile photo uploads now integrated into profile management.
    • Groups can be resolved directly by link.
    • Messages now support contact attachments.
    • Client now maintains a contacts list.
  • Chores

    • Project version updated to 1.2.3.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 24, 2025

📝 Walkthrough

Walkthrough

This PR adds contact management capabilities to PyMax, introduces profile photo upload functionality via an extended change_profile method, and implements contact synchronization from raw payload data. The changes also include type system enhancements for contact attachments, updates to example code demonstrating new event handlers and filters, and a version bump to 1.2.3.

Changes

Cohort / File(s) Summary
Example Updates
examples/example.py
Expanded event handlers (raw_receive, reaction_change, chat_update, message) with Filters-based message filtering; removed legacy history/startup logic; added Photo and Filters imports.
Example Removals
examples/flt_test.py, examples/large_file_upload.py, examples/reg.py, examples/test.py
Complete removal of four example scripts demonstrating older client patterns; no remaining public API exports from these files.
Version Bump
pyproject.toml
Version incremented from 1.2.2 to 1.2.3.
Client Contacts Attribute
src/pymax/core.py, src/pymax/interfaces.py
Added new public attribute contacts: list[User] initialized as empty list in MaxClient and ClientProtocol interface.
Photo Initialization
src/pymax/files.py
Photo.init now auto-derives file_name from path or url before calling base constructor.
Group Link Resolution
src/pymax/mixins/group.py
New public method resolve_group_by_link(link: str) -> Chat \| None with input validation and LINK_INFO request handling.
Profile Photo Upload
src/pymax/mixins/self.py
Extended change_profile to accept optional photo: Photo \| None parameter; added private async helpers _request_photo_upload_url and _upload_profile_photo for photo upload workflow; updated payload to include photo_token and sets self.me from response.
Contacts Sync & Parsing
src/pymax/mixins/socket.py, src/pymax/mixins/websocket.py
Runtime logic to iterate raw_payload["contacts"], construct User objects via User.from_dict, and append to self.contacts with per-entry error handling.
Type System & Attachments
src/pymax/types.py
New public class ContactAttach with fields (contact_id, first_name, last_name, name, photo_url); expanded Message.attaches union to include ContactAttach and AudioAttach; widened type hints for Name/Names.first_name to accept None; made StickerAttach.lottie_url optional; updated Message/Dialog/Chat.from_dict with inline refactoring.
Payload Extensions
src/pymax/payloads.py
Added profile: bool = False to UploadPayload; added photo_token: str \| None = None and avatar_type: str = "USER_AVATAR" to ChangeProfilePayload.
Enum Extension
src/pymax/static/enum.py
Added new member CONTACT = "CONTACT" to AttachType enumeration.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client<br/>(SelfMixin)
    participant UploadSvc as Upload<br/>Service
    participant ProfileSvc as Profile<br/>Service

    Client->>Client: change_profile(photo=Photo(...))
    alt photo provided
        Client->>UploadSvc: PHOTO_UPLOAD request<br/>(profile=True)
        activate UploadSvc
        UploadSvc-->>Client: upload_url
        deactivate UploadSvc
        
        Note over Client,UploadSvc: _upload_profile_photo flow
        Client->>UploadSvc: multipart form POST<br/>(photo to upload_url)
        activate UploadSvc
        UploadSvc-->>Client: photo_token
        deactivate UploadSvc
        
        Client->>ProfileSvc: CHANGE_PROFILE<br/>(photo_token, names...)
        activate ProfileSvc
        ProfileSvc-->>Client: profile contact data
        deactivate ProfileSvc
        
        Client->>Client: self.me = Me.from_dict(...)
    else no photo
        Client->>ProfileSvc: CHANGE_PROFILE<br/>(names only)
        activate ProfileSvc
        ProfileSvc-->>Client: profile contact data
        deactivate ProfileSvc
        
        Client->>Client: self.me = Me.from_dict(...)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • 1.1.21 #27: Modifies handler/filter registration surfaces with Filters class and event handler APIs that align with changes to examples/example.py event handler patterns.
  • Dev/1.2.1 #30: Updates MaxClient attributes and internal protocol interfaces, overlapping with contacts attribute addition and example handler modifications.

Poem

🐰 Contacts hop into the client's den,
Photos upload, profiles refresh again!
No more old scripts, just clean handlers new,
With filters and events—what a bold debut! 📸✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete; while it lists new methods and types, it lacks the required explanatory narrative and testing examples specified in the repository template. Expand the description to include a concise explanation of what the PR does, provide testing code examples, and ensure all template sections are properly filled out.
Docstring Coverage ⚠️ Warning Docstring coverage is 23.81% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'Dev/1.2.3' is vague and does not clearly describe the main change; it only indicates a version bump branch name without conveying meaningful information about the changeset. Use a descriptive title that summarizes the primary change, such as 'Add contact support and profile photo upload functionality' or 'Add ContactAttach type and resolve_group_by_link method'.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev/1.2.3

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
src/pymax/files.py (1)

48-54: Consider aligning initialization pattern with Video and File classes.

The file_name initialization works correctly but differs slightly from Video (lines 86-95) and File (lines 103-113) classes, which initialize file_name to an empty string first and then validate it. While not critical, aligning the pattern would improve consistency.

🔎 Proposed refactor for consistency
 def __init__(self, url: str | None = None, path: str | None = None) -> None:
+    self.file_name: str = ""
     if path:
         self.file_name = Path(path).name
     elif url:
         self.file_name = Path(url).name

     super().__init__(url, path)
src/pymax/mixins/socket.py (1)

608-614: LGTM!

The contacts parsing implementation follows the established pattern for chats, dialogs, and channels. The per-entry error handling ensures that a single malformed contact doesn't break the entire sync process.

Optional: Include contacts count in sync completion log

Consider updating the sync completion log message (lines 618-623) to include the contacts count for better observability:

 self.logger.info(
-    "Sync completed: dialogs=%d chats=%d channels=%d",
+    "Sync completed: dialogs=%d chats=%d channels=%d contacts=%d",
     len(self.dialogs),
     len(self.chats),
     len(self.channels),
+    len(self.contacts),
 )
src/pymax/mixins/websocket.py (1)

469-475: LGTM!

The contacts parsing implementation is consistent with both the chat parsing in this file and the socket.py implementation. The per-entry error handling provides good resilience.

Optional: Include contacts count in sync completion log

Consider updating the sync completion log message (lines 480-485) to include the contacts count for consistency with the suggestion in socket.py:

 self.logger.info(
-    "Sync completed: dialogs=%d chats=%d channels=%d",
+    "Sync completed: dialogs=%d chats=%d channels=%d contacts=%d",
     len(self.dialogs),
     len(self.chats),
     len(self.channels),
+    len(self.contacts),
 )
src/pymax/mixins/self.py (2)

1-4: Unused import: urllib.parse.

Line 1 imports urllib.parse but is not used directly. Line 4 imports parse_qs and urlparse from urllib.parse, which are the actual functions used in the code.

🔎 Suggested fix
-import urllib.parse
 from http import HTTPStatus
 from typing import Any
 from urllib.parse import parse_qs, urlparse

87-108: Consider consolidating duplicated payload construction.

The payload construction logic is nearly identical in both branches, differing only by the inclusion of photo_token. This can be simplified.

🔎 Suggested refactor
+        photo_token: str | None = None
         if photo:
             upload_url = await self._request_photo_upload_url()
             photo_token = await self._upload_profile_photo(upload_url, photo)

-            payload = ChangeProfilePayload(
-                first_name=first_name,
-                last_name=last_name,
-                description=description,
-                photo_token=photo_token,
-            ).model_dump(
-                by_alias=True,
-                exclude_none=True,
-            )
-        else:
-            payload = ChangeProfilePayload(
-                first_name=first_name,
-                last_name=last_name,
-                description=description,
-            ).model_dump(
-                by_alias=True,
-                exclude_none=True,
-            )
+        payload = ChangeProfilePayload(
+            first_name=first_name,
+            last_name=last_name,
+            description=description,
+            photo_token=photo_token,
+        ).model_dump(
+            by_alias=True,
+            exclude_none=True,
+        )
src/pymax/types.py (1)

50-50: Minor: Unconventional type annotation order.

None | str works but str | None is the more conventional ordering in Python. This is a minor style inconsistency.

🔎 Suggested fix
-        first_name: None | str,
+        first_name: str | None,

Also applies to: 93-93

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4925c3b and b519264.

📒 Files selected for processing (16)
  • examples/example.py
  • examples/flt_test.py
  • examples/large_file_upload.py
  • examples/reg.py
  • examples/test.py
  • pyproject.toml
  • src/pymax/core.py
  • src/pymax/files.py
  • src/pymax/interfaces.py
  • src/pymax/mixins/group.py
  • src/pymax/mixins/self.py
  • src/pymax/mixins/socket.py
  • src/pymax/mixins/websocket.py
  • src/pymax/payloads.py
  • src/pymax/static/enum.py
  • src/pymax/types.py
💤 Files with no reviewable changes (4)
  • examples/test.py
  • examples/flt_test.py
  • examples/reg.py
  • examples/large_file_upload.py
🧰 Additional context used
🧬 Code graph analysis (8)
src/pymax/interfaces.py (1)
src/pymax/types.py (1)
  • User (952-1007)
src/pymax/core.py (1)
src/pymax/types.py (1)
  • User (952-1007)
src/pymax/mixins/socket.py (1)
src/pymax/types.py (17)
  • User (952-1007)
  • from_dict (34-35)
  • from_dict (72-78)
  • from_dict (146-156)
  • from_dict (190-205)
  • from_dict (246-260)
  • from_dict (284-292)
  • from_dict (323-332)
  • from_dict (367-376)
  • from_dict (415-426)
  • from_dict (452-459)
  • from_dict (483-487)
  • from_dict (502-511)
  • from_dict (532-540)
  • from_dict (558-559)
  • from_dict (577-582)
  • from_dict (601-602)
src/pymax/mixins/group.py (3)
src/pymax/interfaces.py (2)
  • _send_and_wait (97-104)
  • _get_chat (107-108)
src/pymax/mixins/websocket.py (2)
  • _send_and_wait (328-360)
  • _get_chat (496-500)
src/pymax/payloads.py (1)
  • GetChatInfoPayload (335-336)
examples/example.py (4)
src/pymax/files.py (3)
  • File (102-117)
  • Photo (38-82)
  • Video (85-99)
src/pymax/filters.py (2)
  • chat (135-136)
  • text (139-140)
src/pymax/payloads.py (1)
  • UserAgentPayload (42-53)
src/pymax/mixins/handler.py (1)
  • on_raw_receive (129-142)
src/pymax/mixins/self.py (5)
src/pymax/files.py (5)
  • Photo (38-82)
  • read (23-35)
  • read (81-82)
  • read (98-99)
  • read (116-117)
src/pymax/payloads.py (2)
  • UploadPayload (98-100)
  • ChangeProfilePayload (168-173)
src/pymax/types.py (17)
  • Me (514-548)
  • from_dict (34-35)
  • from_dict (72-78)
  • from_dict (146-156)
  • from_dict (190-205)
  • from_dict (246-260)
  • from_dict (284-292)
  • from_dict (323-332)
  • from_dict (367-376)
  • from_dict (415-426)
  • from_dict (452-459)
  • from_dict (483-487)
  • from_dict (502-511)
  • from_dict (532-540)
  • from_dict (558-559)
  • from_dict (577-582)
  • from_dict (601-602)
src/pymax/mixins/websocket.py (1)
  • _send_and_wait (328-360)
src/pymax/mixins/utils.py (2)
  • MixinsUtils (6-27)
  • handle_error (8-27)
src/pymax/mixins/websocket.py (1)
src/pymax/types.py (17)
  • User (952-1007)
  • from_dict (34-35)
  • from_dict (72-78)
  • from_dict (146-156)
  • from_dict (190-205)
  • from_dict (246-260)
  • from_dict (284-292)
  • from_dict (323-332)
  • from_dict (367-376)
  • from_dict (415-426)
  • from_dict (452-459)
  • from_dict (483-487)
  • from_dict (502-511)
  • from_dict (532-540)
  • from_dict (558-559)
  • from_dict (577-582)
  • from_dict (601-602)
src/pymax/types.py (1)
src/pymax/static/enum.py (2)
  • AttachType (192-199)
  • FormattingType (202-206)
🪛 Ruff (0.14.10)
src/pymax/mixins/group.py

304-304: Avoid specifying long messages outside the exception class

(TRY003)

src/pymax/mixins/self.py

57-59: Avoid specifying long messages outside the exception class

(TRY003)

🔇 Additional comments (19)
src/pymax/static/enum.py (1)

199-199: LGTM!

The addition of the CONTACT member to the AttachType enum is consistent with the existing enum structure and supports the new contact attachment functionality introduced in this PR.

pyproject.toml (1)

3-3: LGTM!

Version bump to 1.2.3 is appropriate for the new features and bug fixes introduced in this PR.

src/pymax/core.py (1)

119-119: LGTM!

The addition of the contacts collection follows the established pattern for chats, dialogs, and channels. The initialization and type annotation are correct.

src/pymax/interfaces.py (1)

48-48: LGTM!

The contacts attribute addition to ClientProtocol correctly extends the interface to support the new contact management feature.

src/pymax/payloads.py (2)

100-100: LGTM!

The profile field addition enables profile-specific photo upload workflows, complementing the existing count field.


172-173: LGTM with TODO noted.

The photo_token and avatar_type fields appropriately support profile photo upload functionality. The TODO comment correctly identifies that avatar_type should be extracted to an enum for better type safety.

src/pymax/mixins/socket.py (1)

31-31: LGTM!

The User import is correctly added to support contact parsing in the sync method.

src/pymax/mixins/websocket.py (1)

30-30: LGTM!

The User import is correctly added to support contact parsing in the sync method.

src/pymax/mixins/group.py (2)

98-98: LGTM!

These are minor formatting changes that consolidate the _send_and_wait calls into single-line expressions. The functionality remains unchanged.

Also applies to: 156-156


355-358: LGTM!

Minor formatting adjustments with no functional changes.

examples/example.py (3)

8-10: LGTM!

The new imports align with the expanded functionality demonstrated in this example file.


33-36: LGTM!

Good example of the new on_raw_receive event handler for debugging/logging raw server data.


50-57: LGTM!

Good examples of the on_chat_update handler and the new Filters usage for on_message. The filter Filters.chat(0) & Filters.text("hello") clearly demonstrates combining multiple filters.

src/pymax/mixins/self.py (2)

26-37: LGTM!

The _request_photo_upload_url method correctly requests an upload URL using the PHOTO_UPLOAD opcode with profile=True, handles errors via MixinsUtils, and returns the URL from the response.


115-116: LGTM!

Good addition to update self.me with the returned profile data after a successful profile change.

src/pymax/types.py (4)

220-220: LGTM!

Good change to make lottie_url optional and use .get("lottieUrl") for safe access. This handles stickers that may not have a Lottie animation URL.

Also applies to: 249-249


633-661: LGTM!

The new ContactAttach class is well-structured and follows the established patterns for other attach types. It correctly:

  • Defines all required fields in __init__
  • Sets self.type = AttachType.CONTACT
  • Implements from_dict, __repr__, and __str__ consistently with other attach classes

677-729: LGTM!

The Message class is correctly updated to include ContactAttach in the attaches union type, and from_dict properly handles the new AttachType.CONTACT case by instantiating ContactAttach.from_dict(a).


814-814: LGTM!

Minor formatting improvements that consolidate expressions into single lines without changing functionality.

Also applies to: 899-899, 902-902

Comment on lines +38 to +47
@client.on_reaction_change
async def handle_reaction_change(
message_id: str, chat_id: int, reaction_info: ReactionInfo
) -> None:
print(
f"Reaction changed on message {message_id} in chat {chat_id}: "
f"Total count: {reaction_info.total_count}, "
f"Your reaction: {reaction_info.your_reaction}, "
f"Counters: {reaction_info.counters[0].reaction}={reaction_info.counters[0].count}"
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential IndexError when accessing counters[0].

Line 46 accesses reaction_info.counters[0] without verifying that the counters list is non-empty. If a reaction change event arrives with an empty counters list, this will raise an IndexError.

Since this is example code, consider adding a guard or a comment noting this assumption.

🔎 Suggested fix
 @client.on_reaction_change
 async def handle_reaction_change(
     message_id: str, chat_id: int, reaction_info: ReactionInfo
 ) -> None:
+    counters_info = (
+        f"{reaction_info.counters[0].reaction}={reaction_info.counters[0].count}"
+        if reaction_info.counters
+        else "none"
+    )
     print(
         f"Reaction changed on message {message_id} in chat {chat_id}: "
         f"Total count: {reaction_info.total_count}, "
         f"Your reaction: {reaction_info.your_reaction}, "
-        f"Counters: {reaction_info.counters[0].reaction}={reaction_info.counters[0].count}"
+        f"Counters: {counters_info}"
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@client.on_reaction_change
async def handle_reaction_change(
message_id: str, chat_id: int, reaction_info: ReactionInfo
) -> None:
print(
f"Reaction changed on message {message_id} in chat {chat_id}: "
f"Total count: {reaction_info.total_count}, "
f"Your reaction: {reaction_info.your_reaction}, "
f"Counters: {reaction_info.counters[0].reaction}={reaction_info.counters[0].count}"
)
@client.on_reaction_change
async def handle_reaction_change(
message_id: str, chat_id: int, reaction_info: ReactionInfo
) -> None:
counters_info = (
f"{reaction_info.counters[0].reaction}={reaction_info.counters[0].count}"
if reaction_info.counters
else "none"
)
print(
f"Reaction changed on message {message_id} in chat {chat_id}: "
f"Total count: {reaction_info.total_count}, "
f"Your reaction: {reaction_info.your_reaction}, "
f"Counters: {counters_info}"
)
🤖 Prompt for AI Agents
In examples/example.py around lines 38 to 47, the example prints
reaction_info.counters[0] without checking that reaction_info.counters is
non-empty which can raise IndexError; update the handler to guard against an
empty counters list (e.g., check len(reaction_info.counters) > 0 before
accessing index 0 or select a safe default when empty) or add a brief comment
explaining the assumption so the example is safe and clear.

Comment on lines +292 to +317
async def resolve_group_by_link(self, link: str) -> Chat | None:
"""
Разрешает группу по ссылке
Args:
link (str): Ссылка на группу.
Returns:
Chat | None: Объект чата группы или None, если не найдено.
"""
proceed_link = self._process_chat_join_link(link)
if proceed_link is None:
raise ValueError("Invalid group link")

data = await self._send_and_wait(
opcode=Opcode.LINK_INFO,
payload={
"link": proceed_link,
},
)

if data.get("payload", {}).get("error"):
MixinsUtils.handle_error(data)

chat = Chat.from_dict(data["payload"].get("chat", {}))
return chat
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Return type inconsistency: method always returns Chat, never None.

The return type annotation is Chat | None, but the method always returns a Chat object. If the server response doesn't include a "chat" key, Chat.from_dict({}) is called with an empty dict, which will create a Chat with default/zero values rather than returning None.

Consider either:

  1. Changing the return type to just Chat if an empty/default Chat is acceptable, or
  2. Adding a check to return None when the chat data is missing/empty.
🔎 Option 2: Return None when chat is not found
-        chat = Chat.from_dict(data["payload"].get("chat", {}))
-        return chat
+        chat_data = data["payload"].get("chat")
+        if not chat_data:
+            return None
+        return Chat.from_dict(chat_data)
🧰 Tools
🪛 Ruff (0.14.10)

304-304: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In src/pymax/mixins/group.py around lines 292 to 317 the method is annotated to
return Chat | None but always calls Chat.from_dict({}) when the server payload
lacks "chat", producing a default Chat instead of None; change the logic to
inspect data.get("payload", {}).get("chat") and if it's missing or empty return
None, otherwise call Chat.from_dict(...) and return the Chat; update any type
hints or tests if needed to reflect the new None-return behavior.

Comment on lines +39 to +65
async def _upload_profile_photo(self, upload_url: str, photo: Photo) -> str:
self.logger.info("Uploading profile photo")

parsed_url = urlparse(upload_url)
photo_id = parse_qs(parsed_url.query)["photoIds"][0]

form = aiohttp.FormData()
form.add_field(
"file",
await photo.read(),
filename=photo.file_name,
)

async with (
aiohttp.ClientSession() as session,
session.post(upload_url, data=form) as response,
):
if response.status != HTTPStatus.OK:
raise Error(
"Failed to upload profile photo.", message="UploadError", title="Upload Error"
)

self.logger.info("Upload successful")
data = await response.json()
return data["photos"][photo_id][
"token"
] # TODO: сделать нормальную типизацию и чекнинг ответа
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential KeyError when parsing photoIds from URL.

Line 43 assumes the photoIds query parameter exists in the upload URL. If the server returns a URL without this parameter, a KeyError will be raised.

Consider adding defensive handling:

🔎 Suggested fix
         parsed_url = urlparse(upload_url)
-        photo_id = parse_qs(parsed_url.query)["photoIds"][0]
+        query_params = parse_qs(parsed_url.query)
+        photo_ids = query_params.get("photoIds")
+        if not photo_ids:
+            raise Error(
+                "Missing photoIds in upload URL", message="UploadError", title="Upload Error"
+            )
+        photo_id = photo_ids[0]
🧰 Tools
🪛 Ruff (0.14.10)

57-59: Avoid specifying long messages outside the exception class

(TRY003)

@ink-developer ink-developer merged commit 9c05e89 into main Dec 24, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants