Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions docs/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,9 @@ Debug-логи показывают handshake, login, входящие собы
Клиент собирает несколько API-направлений:

Сообщения
``send_message()``, ``fetch_history()``, ``delete_message()``,
``pin_message()``, ``read_message()``, реакции и получение URL для входящих
файлов/видео.
``send_message()``, ``forward_message()``, ``fetch_history()``,
``delete_message()``, ``pin_message()``, ``read_message()``, реакции и
получение URL для входящих файлов/видео.

Чаты
``get_chat()``, ``fetch_chats()``, создание групп, invite-ссылки,
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он
:maxdepth: 1
:caption: Новости

release-2-3-1
release-2-3-0
release-2-2-0
release-2-1-3
Expand Down
12 changes: 12 additions & 0 deletions docs/messages.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ Messages
async def on_message(message: Message, client: Client) -> None:
await message.answer("Ответ в тот же чат")
await message.reply("Ответ реплаем")
await message.forward(chat_id=654321)

Переслать сообщение напрямую через клиент можно с указанием исходного и
целевого чатов:

.. code-block:: python

await client.forward_message(
chat_id=654321,
message_id=987654,
source_chat_id=123456,
)

Ответ, реакции, удаление и прочтение
----------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions docs/release-2-3-1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
PyMax 2.3.1
===========

Изменения относительно ``2.3.0``.

Добавлено
---------

* ``forward_message()`` на клиенте и ``Message.forward()`` на bound-объекте
сообщения. Для пересылки между разными чатами укажите ``source_chat_id``.

Исправлено
----------

* Декодирование сжатых TCP payload-ов: коэффициенты LZ4 теперь обрабатываются
корректно, а payload-ы с флагом ``0xFF`` декодируются через Zstandard.
* Разбор профилей bot-аккаунтов, в которых ``gender`` приходит числом, а
``web_app`` — URL-строкой.

Зависимости
-----------

* Добавлена runtime-зависимость ``zstandard`` для декодирования TCP payload-ов.
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 = "2.3.0"
version = "2.3.1"
description = "Python wrapper для API мессенджера Max"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -31,6 +31,7 @@ dependencies = [
"python-socks[asyncio]>=2.8.1",
"qrcode>=8.2",
"websockets>=16.0",
"zstandard>=0.25.0",
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion src/pymax/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.3.0"
__version__ = "2.3.1"


from .auth import (
Expand Down
22 changes: 21 additions & 1 deletion src/pymax/api/messages/payloads.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal

from pydantic import Field

Expand Down Expand Up @@ -46,6 +46,26 @@ class SendMessagePayload(CamelModel):
notify: bool = False


class ForwardLink(CamelModel):
type: Literal["FORWARD"] = "FORWARD"
message_id: str
chat_id: int


class ForwardMessagePayloadMessage(CamelModel):
cid: int
link: ForwardLink
attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field(
default_factory=list
)


class ForwardMessagePayload(CamelModel):
chat_id: int
message: ForwardMessagePayloadMessage
notify: bool = True


class ChatHistoryPayload(CamelModel):
chat_id: int
forward: int
Expand Down
39 changes: 39 additions & 0 deletions src/pymax/api/messages/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
ChatHistoryPayload,
DeleteMessagePayload,
EditMessagePayload,
ForwardLink,
ForwardMessagePayload,
ForwardMessagePayloadMessage,
GetFilePayload,
GetMessagesPayload,
GetReactionsPayload,
Expand Down Expand Up @@ -139,6 +142,42 @@ async def send_message(
logger.info("message sent chat_id=%s", chat_id)
return message

async def forward_message(
self,
chat_id: int,
message_id: int | str,
source_chat_id: int | None = None,
*,
notify: bool = True,
) -> Message | None:
source_chat_id = chat_id if source_chat_id is None else source_chat_id
logger.info(
"forwarding message source_chat_id=%s chat_id=%s message_id=%s",
source_chat_id,
chat_id,
message_id,
)

frame = ForwardMessagePayload(
chat_id=chat_id,
message=ForwardMessagePayloadMessage(
cid=-self._next_cid(),
link=ForwardLink(
message_id=str(message_id),
chat_id=source_chat_id,
),
),
notify=notify,
)

response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
message = bind_api_model(
self.app,
require_payload_model(response, Message),
)
logger.info("message forwarded source_chat_id=%s chat_id=%s", source_chat_id, chat_id)
return message

async def get_messages(
self,
chat_id: int,
Expand Down
27 changes: 27 additions & 0 deletions src/pymax/infra/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ async def get_message(
message_id=message_id,
)

async def forward_message(
self,
chat_id: int,
message_id: int | str,
source_chat_id: int | None = None,
*,
notify: bool = True,
) -> Message | None:
"""Пересылает существующее сообщение в чат.

Args:
chat_id: ID целевого чата.
message_id: ID пересылаемого сообщения.
source_chat_id: ID исходного чата. Если не указан, используется
целевой чат.
notify: Отправить ли получателям push-уведомление.

Returns:
Пересланное сообщение или ``None``, если сервер не вернул его.
"""
return await self._app.api.messages.forward_message(
chat_id=chat_id,
message_id=message_id,
source_chat_id=source_chat_id,
notify=notify,
)

async def get_messages(
self,
chat_id: int,
Expand Down
18 changes: 18 additions & 0 deletions src/pymax/protocol/tcp/compression.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from io import BytesIO

import zstandard


class Lz4BlockCompression:
def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
dst = bytearray()
Expand Down Expand Up @@ -95,3 +100,16 @@ def compress(self, src: bytes) -> bytes:
dst.extend(src[lit_start : lit_start + lit_len])

return bytes(dst)


class ZstdCompression:
def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
try:
with zstandard.ZstdDecompressor().stream_reader(BytesIO(src)) as reader:
result = reader.read(max_output + 1)
except zstandard.ZstdError as e:
raise ValueError("Zstd: failed to decompress payload") from e

if len(result) > max_output:
raise ValueError("Zstd: output too large")
return result
24 changes: 20 additions & 4 deletions src/pymax/protocol/tcp/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pymax.logging import get_logger

from .compression import Lz4BlockCompression
from .compression import Lz4BlockCompression, ZstdCompression

logger = get_logger(__name__)

Expand Down Expand Up @@ -70,9 +70,11 @@ def __init__(
*,
serializer: MsgpackPayloadCodec,
compression: Lz4BlockCompression | None = None,
zstd_compression: ZstdCompression | None = None,
) -> None:
self.serializer = serializer
self.compression = compression
self.zstd_compression = zstd_compression

def _normalize_keys(self, obj: Any) -> Any:
if isinstance(obj, dict):
Expand All @@ -97,12 +99,26 @@ def decode(self, payload_bytes: bytes, flags: int = 0) -> dict[str, Any]:
if not payload_bytes:
return {}

if flags & 0x03 and self.compression:
if flags == 0xFF:
if self.zstd_compression is None:
raise ValueError("Zstd-compressed TCP payload without a decoder")
try:
payload_bytes = self.zstd_compression.decompress(payload_bytes)
logger.debug("tcp payload decompressed with Zstd")
except ValueError:
logger.debug("tcp Zstd payload decompression failed", exc_info=True)
raise
elif flags > 0x7F:
raise ValueError(f"invalid TCP compression factor: {flags}")
elif flags > 0:
if self.compression is None:
raise ValueError("LZ4-compressed TCP payload without a decoder")
try:
payload_bytes = self.compression.decompress(payload_bytes)
logger.debug("tcp payload decompressed flags=%s", flags)
logger.debug("tcp payload decompressed cof=%s", flags)
except ValueError:
logger.debug("tcp payload decompress skipped flags=%s", flags)
logger.debug("tcp payload decompression failed cof=%s", flags, exc_info=True)
raise

result = self.serializer.decode(payload_bytes)
return self._normalize_keys(result)
6 changes: 5 additions & 1 deletion src/pymax/protocol/tcp/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Lz4BlockCompression,
MsgpackPayloadCodec,
TcpPayloadDecoder,
ZstdCompression,
)

logger = get_logger(__name__)
Expand All @@ -20,8 +21,11 @@ def __init__(self) -> None:
self.framer = TcpPacketFramer()
self.serializer = MsgpackPayloadCodec()
self.compression = Lz4BlockCompression()
self.zstd_compression = ZstdCompression()
self.payload_decoder = TcpPayloadDecoder(
serializer=self.serializer, compression=self.compression
serializer=self.serializer,
compression=self.compression,
zstd_compression=self.zstd_compression,
)

def encode(self, frame: OutboundFrame) -> bytes:
Expand Down
31 changes: 29 additions & 2 deletions src/pymax/types/domain/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ class Message(CamelModel):

Сообщения, полученные через клиент, обычно уже привязаны к сервису
сообщений. После этого можно вызывать удобные методы объекта:
:meth:`reply`, :meth:`answer`, :meth:`edit`, :meth:`pin`, :meth:`delete`,
:meth:`read`, :meth:`react`, :meth:`unreact` и :meth:`get_reactions`.
:meth:`reply`, :meth:`answer`, :meth:`forward`, :meth:`edit`, :meth:`pin`,
:meth:`delete`, :meth:`read`, :meth:`react`, :meth:`unreact` и
:meth:`get_reactions`.

Используйте ``Message`` в обработчиках ``on_message`` и при работе с
историей. Некоторые поля могут быть ``None``, потому что Max присылает
Expand Down Expand Up @@ -244,6 +245,32 @@ async def answer(
notify=notify,
)

async def forward(
self,
chat_id: int,
*,
notify: bool = True,
) -> Message | None:
"""Пересылает это сообщение в другой чат.

:param chat_id: ID целевого чата.
:type chat_id: int
:param notify: Отправить ли получателям push-уведомление.
:type notify: bool
:returns: Пересланное сообщение или ``None``, если сервер его не вернул.
:rtype: Message | None
:raises RuntimeError: Если сообщение не привязано к сервису или не
содержит ``chat_id``.
"""
actions, source_chat_id = self._bound()

return await actions.forward_message(
chat_id=chat_id,
message_id=self.id,
source_chat_id=source_chat_id,
notify=notify,
)

async def pin(self, notify_pin: bool = True) -> bool:
"""Закрепляет это сообщение в чате.

Expand Down
10 changes: 6 additions & 4 deletions src/pymax/types/domain/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ class User(CamelModel):
:ivar description: Описание профиля.
:vartype description: str | None
:ivar gender: Пол пользователя.
:vartype gender: str | None
:vartype gender: str | int | None
:ivar link: Ссылка на профиль.
:vartype link: str | None
:ivar web_app: Данные связанного web-приложения, если есть.
:vartype web_app: dict[str, Any] | None
:vartype web_app: dict[str, Any] | str | None
:ivar menu_button: Данные кнопки меню профиля, если есть.
:vartype menu_button: dict[str, Any] | None
"""
Expand All @@ -71,9 +71,11 @@ class User(CamelModel):
phone: int | None = None
status: str | None = None
description: str | None = None
gender: str | None = None
# Bots may send ``gender`` as a numeric code and ``web_app`` as a URL
# string instead of an object; accept these so profile parsing won't fail.
gender: str | int | None = None
link: str | None = None
web_app: dict[str, Any] | None = None
web_app: dict[str, Any] | str | None = None
menu_button: dict[str, Any] | None = None

_actions: UserService | None = PrivateAttr(default=None)
Expand Down
Loading