Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "maxapi-python"
version = "1.2.3"
version = "1.2.4"
description = "Python wrapper для API мессенджера Max"
readme = "README.md"
requires-python = ">=3.10"
Expand All @@ -19,6 +19,7 @@ dependencies = [
"aiohttp>=3.12.15",
"aiofiles>=24.1.0",
"qrcode>=8.2",
"ua-generator>=2.0.19",
]

[project.urls]
Expand Down
14 changes: 14 additions & 0 deletions redocs/source/clients.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ MaxClient
limit=50
)

# Изменить профиль с загрузкой фото
result = await client.change_profile(
first_name="Иван",
last_name="Петров",
description="Привет!",
photo=Photo(...) # Новая фотография профиля
)

# Разрешить группу по ссылке
group = await client.resolve_group_by_link(
link="https://max.app/g/ABC123"
)

Свойства:

.. code-block:: python
Expand All @@ -95,6 +108,7 @@ MaxClient
client.channels # Список каналов (list[Channel])
client.phone # Номер телефона (str)
client.token # Токен сессии (str | None)
client.contacts # Список контактов (list[User])

Обработчики событий:

Expand Down
2 changes: 1 addition & 1 deletion redocs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
project = "PyMax"
author = "ink-developer"
copyright = "2025, ink-developer"
release = "1.1.21"
release = "1.2.3"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# -- Path setup ---------------------------------------------------------------
sys.path.insert(0, os.path.abspath("../../src"))
Expand Down
11 changes: 3 additions & 8 deletions redocs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Greeter Bot

client = MaxClient(phone="+79001234567")

@client.on_message(Filters.private())
@client.on_message(Filters.chat(123))
async def greet(message):
user = await client.get_user(message.sender)
if user and user.names:
Expand Down Expand Up @@ -150,13 +150,8 @@ File Manager
for attach in message.attaches:
if attach.type == AttachType.PHOTO:
print("Получено фото!")
file_info = await client.get_file_by_id(
chat_id=message.chat_id,
message_id=message.id,
file_id=attach.file_id
)
if file_info:
print(f"URL: {file_info.url}")

print(f"URL: {attach.base_url}")

@client.on_message(Filters.text("файл"))
async def send_file(message):
Expand Down
5 changes: 0 additions & 5 deletions redocs/source/guides.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,6 @@ Guides
async def greeting(message: Message) -> None:
pass

# Только личные
@client.on_message(Filters.dialog())
async def private(message: Message) -> None:
pass

# Только группы
@client.on_message(Filters.chat())
async def in_group(message: Message) -> None:
Expand Down
6 changes: 6 additions & 0 deletions redocs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
.. image:: _static/logo.svg
:align: center
:width: 320px

PyMax
=====

Expand Down Expand Up @@ -34,13 +35,18 @@ PyMax
decorators
examples
guides
release_notes

.. rubric:: Особенности

- Вход по номеру телефона
- Отправка / редактирование / удаление сообщений
- Управление чатами, каналами и диалогами
- Получение истории сообщений
- Загрузка фотографий профиля
- Разрешение групп по ссылке
- Поддержка контактов в сообщениях
- Управление списком контактов

---

Expand Down
4 changes: 0 additions & 4 deletions redocs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ Quick Start
text="И тебе привет!"
)

# Только личные сообщения
@client.on_message(Filters.dialog())
async def private_handler(message: Message) -> None:
print(f"Личное сообщение: {message.text}")

Обработчики событий
--------------------
Expand Down
61 changes: 61 additions & 0 deletions redocs/source/release_notes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
Release Notes v1.2.3
====================

Новые функции
-------------

**Загрузка фотографий профиля**
Профиль теперь может быть обновлен с загрузкой новой фотографии через метод ``change_profile()``.

**Разрешение групп по ссылке**
Группы теперь могут быть разрешены (получены) прямо по их ссылке через метод ``resolve_group_by_link()``.

**Поддержка контактов в сообщениях**
Сообщения теперь поддерживают вложения типа контакта с информацией о контакте (ContactAttach).

**Список контактов клиента**
Клиент теперь ведет список всех контактов пользователя через свойство ``client.contacts``.

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

MaxClient.resolve_group_by_link(link: str) -> Chat | None
Разрешает группу по ссылке. Возвращает объект чата группы или None, если не найдено.

MaxClient.change_profile(first_name, last_name, description, photo)
Изменяет информацию профиля текущего пользователя, включая загрузку новой фотографии.

Новые типы
----------

ContactAttach
Представляет контакт в сообщении. Содержит информацию о контакте (ID, имя, фамилия, фото).

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

Message
Теперь поддерживает вложения типа ContactAttach в список attaches.

Names
Улучшен для работы с различными форматами имен пользователя.

StickerAttach
Улучшено представление стикеров в сообщениях.

Photo
Улучшено для работы с фотографиями профиля.

AttachType
Добавлено значение CONTACT для контактов.

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

MaxClient.contacts: list[User]
Список контактов текущего пользователя.

Версия
------

**1.2.3** - выпущена 24 декабря 2025 г.
91 changes: 54 additions & 37 deletions src/pymax/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,12 @@
from .exceptions import (
InvalidPhoneError,
SocketNotConnectedError,
WebSocketNotConnectedError,
)
from .interfaces import BaseClient
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
from .payloads import UserAgentPayload
from .static.constant import (
HOST,
PORT,
WEBSOCKET_URI,
)
from .static.constant import HOST, PORT, SESSION_STORAGE_DB, WEBSOCKET_URI

if TYPE_CHECKING:
from collections.abc import Callable
Expand All @@ -34,21 +31,23 @@

from pymax.filters import BaseFilter

from .filters import Filters
from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User


logger = logging.getLogger(__name__)


class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
allowed_device_types: set[str] = {"WEB"}
"""
Основной клиент для работы с WebSocket API сервиса Max.

:param phone: Номер телефона для авторизации.
:type phone: str
:param uri: URI WebSocket сервера.
:type uri: str, optional
:param session_name: Название сессии для хранения базы данных.
:type session_name: str, optional
:param work_dir: Рабочая директория для хранения базы данных.
:type work_dir: str, optional
:param logger: Пользовательский логгер. Если не передан, используется логгер модуля с именем f"{__name__}.MaxClient".
Expand Down Expand Up @@ -81,7 +80,8 @@ def __init__(
self,
phone: str,
uri: str = WEBSOCKET_URI,
headers: UserAgentPayload = UserAgentPayload(),
session_name: str = SESSION_STORAGE_DB,
headers: UserAgentPayload | None = None,
token: str | None = None,
send_fake_telemetry: bool = True,
host: str = HOST,
Expand Down Expand Up @@ -120,7 +120,7 @@ def __init__(
self._users: dict[int, User] = {}

self._work_dir: str = work_dir
self._database_path: Path = Path(work_dir) / "session.db"
self._database_path: Path = Path(work_dir) / session_name
self._database_path.parent.mkdir(parents=True, exist_ok=True)
self._database_path.touch(exist_ok=True)
self._database = Database(self._work_dir)
Expand All @@ -132,6 +132,7 @@ def __init__(
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
self._background_tasks: set[asyncio.Task[Any]] = set()
self._stop_event = asyncio.Event()

self._seq: int = 0
self._error_count: int = 0
Expand All @@ -142,7 +143,10 @@ def __init__(
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}

self._token = self._database.get_auth_token() or token
if headers is None:
headers = self._default_headers()
self.user_agent = headers
self._validate_device_type()
self._send_fake_telemetry: bool = send_fake_telemetry
self._session_id: int = int(time.time() * 1000)
self._action_id: int = 1
Expand Down Expand Up @@ -180,11 +184,24 @@ def __init__(
self._work_dir,
)

@staticmethod
def _default_headers() -> UserAgentPayload:
return UserAgentPayload(device_type="WEB")

def _validate_device_type(self) -> None:
if self.user_agent.device_type not in self.allowed_device_types:
raise ValueError(
f"{self.__class__.__name__} does not support "
f"device_type={self.user_agent.device_type}"
)

async def _wait_forever(self) -> None:
try:
await self.ws.wait_closed()
except asyncio.CancelledError:
self.logger.debug("wait_closed cancelled")
except WebSocketNotConnectedError:
self.logger.info("WebSocket not connected, exiting wait_forever")

async def close(self) -> None:
"""
Expand All @@ -194,22 +211,7 @@ async def close(self) -> None:
"""
try:
self.logger.info("Closing client")
if self._recv_task:
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
self.logger.debug("recv_task cancelled")
if self._outgoing_task:
self._outgoing_task.cancel()
try:
await self._outgoing_task
except asyncio.CancelledError:
self.logger.debug("outgoing_task cancelled")
if self._ws:
await self._ws.close()
self.is_connected = False
self.logger.info("Client closed")
self._stop_event.set()
except Exception:
self.logger.exception("Error closing client")

Expand Down Expand Up @@ -279,10 +281,9 @@ async def start(self) -> None:
:return: None
:rtype: None
"""

while True:
self.logger.info("Client starting")
while not self._stop_event.is_set():
try:
self.logger.info("Client starting")
await self.connect(self.user_agent)

if self.registration:
Expand All @@ -297,29 +298,45 @@ async def start(self) -> None:
await self._login()

await self._sync(self.user_agent)

await self._post_login_tasks(sync=False)

await self._wait_forever()
self.logger.info("WebSocket closed (wait_forever exited)")
wait_task = asyncio.create_task(self._wait_forever())
stop_task = asyncio.create_task(self._stop_event.wait())

done, pending = await asyncio.wait(
[wait_task, stop_task], return_when=asyncio.FIRST_COMPLETED
)

for task in pending:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

except asyncio.CancelledError:
self.logger.info("Client task cancelled, stopping")
break
except Exception as e:
self.logger.exception("Client start iteration failed")
raise e

finally:
self.logger.debug("Cleaning up background tasks and pending futures")

await self._cleanup_client()

if not self.reconnect:
self.logger.info("Reconnect disabled — exiting start()")
return
if not self.reconnect or self._stop_event.is_set():
self.logger.info("Reconnect disabled or stop requested — exiting start()")
break

self.logger.info("Reconnect enabled — restarting client")
await asyncio.sleep(self.reconnect_delay)

self.logger.info("Client exited cleanly")


class SocketMaxClient(SocketMixin, MaxClient):
allowed_device_types = {"ANDROID", "IOS", "DESKTOP"}

@staticmethod
def _default_headers() -> UserAgentPayload:
return UserAgentPayload(device_type="DESKTOP")

@override
async def _wait_forever(self):
if self._recv_task:
Expand Down
Loading