maxapi-python 2.3.0__py3-none-any.whl → 2.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/MaxApiTeam/PyMax
6
6
  Project-URL: Repository, https://github.com/MaxApiTeam/PyMax
@@ -31,6 +31,7 @@ Requires-Dist: pydantic>=2.10.0
31
31
  Requires-Dist: python-socks[asyncio]>=2.8.1
32
32
  Requires-Dist: qrcode>=8.2
33
33
  Requires-Dist: websockets>=16.0
34
+ Requires-Dist: zstandard>=0.25.0
34
35
  Description-Content-Type: text/markdown
35
36
 
36
37
  # PyMax
@@ -1,4 +1,4 @@
1
- pymax/__init__.py,sha256=xPT-McHyAWLyFFEbepaDbmT65hBkHiQUPD3u-jOdFgY,1500
1
+ pymax/__init__.py,sha256=y6_pIn47iHEIkP4F4y7EL-qiIpAgJqs_jVKnOmooniY,1500
2
2
  pymax/app.py,sha256=QQldPh1YYpFeKBOMfCQZA0BFW5bUQQO7hFBYtwpioZQ,10847
3
3
  pymax/base.py,sha256=--6VwKUvqlocFAUbPWn_CfXQ-8CYfyCigXQ70ipXOgI,10500
4
4
  pymax/client.py,sha256=ePDxR6wEeHJbk23wI_PkOVFQuyy5tQyckV-OmKEwz5U,3985
@@ -27,8 +27,8 @@ pymax/api/chats/payloads.py,sha256=qA46Q984FgHaXenPmvtotm7ZJOKM07aiP7dBYF_0gqQ,2
27
27
  pymax/api/chats/service.py,sha256=jNQUlSdILR5afeTYFQmQ6mvx-rgQlWjhzmOWUA0vDcg,12064
28
28
  pymax/api/messages/__init__.py,sha256=fw_uF6tgSbkxvqaSVE65wU0KWsu47u1CtR0Y4BbD-K0,36
29
29
  pymax/api/messages/enums.py,sha256=tp5S2Q97a-FILcqpRhQiIRKdj0P_KSEBiCqWmb05dHs,368
30
- pymax/api/messages/payloads.py,sha256=wqseGNfqWYKeoRJhdzrRV855IboChaYU1dJLPGZg71I,2257
31
- pymax/api/messages/service.py,sha256=61reqCHswh7QiP3KvofEeo_96CylozWaoPaqik2iDSI,12117
30
+ pymax/api/messages/payloads.py,sha256=MQSaZD0kYbkojWNK6BJbwoS02ZjDX7eutFnJX3_L6_g,2712
31
+ pymax/api/messages/service.py,sha256=lGnciRoP9PkpgBDpkHQU1ukxNTwMJuGjHhM1EeSHWXU,13334
32
32
  pymax/api/self/__init__.py,sha256=TfbqL4xLb5IMhbW8mlAK-AwVFqqPWMADogKZeWtkw0A,79
33
33
  pymax/api/self/enums.py,sha256=iKEqPy44LyQKvAPfyhpkSXgmWWohxry73F0CVgtekwY,180
34
34
  pymax/api/self/payloads.py,sha256=-SFqkNxC5ZLKnLty_Gyz_b_P3X1esu_7Urb4HjrQxM4,774
@@ -79,7 +79,7 @@ pymax/infra/auth.py,sha256=YuYH_NWNz8UfPyko6bv_cIzXDIvFeivDrxZCIpGoANU,3648
79
79
  pymax/infra/base.py,sha256=sOo40Qkp14bE6CxVKppJhy3e0F5VhFPhnL3iBs8y5dA,368
80
80
  pymax/infra/bots.py,sha256=IJs4ErFxjbMeiHfQgpDiDhtEu9ws-lIp8fb8I96szy8,1202
81
81
  pymax/infra/chat.py,sha256=Jb5kzUnHtCg98glS4Jf-WEHIzZg4oIcS-MA8aGeJuKk,13884
82
- pymax/infra/message.py,sha256=RW5LwaLDwekKNC_8s-12TM1PbCmXyFD_l5muAvX_rLg,10701
82
+ pymax/infra/message.py,sha256=4AUp8EUQ46GhsJMgh7YmgogkBT0K4wUqEkIfrFT--oI,11737
83
83
  pymax/infra/protocol.py,sha256=I2WrAeAgATuNSdK2gvHU-MhhxdRBfSMGMovF5HPFsQw,208
84
84
  pymax/infra/self.py,sha256=h2GnzcCF2-FeY9-qBjtHjrSCx7elc4RjuCXPaewDkgg,4828
85
85
  pymax/infra/user.py,sha256=wy6lElmJx7Ypjkk48kDrlLiL3V3p7pzO2MevsgCNy1k,4801
@@ -88,10 +88,10 @@ pymax/protocol/base.py,sha256=_bisk1BU_GSMSPIqfnizSCgm_qBrdbB5cJw6foAa2tc,294
88
88
  pymax/protocol/enums.py,sha256=9y4kn9y2pKinaT_twdeW5MXVH3O-sJF6h0v5KbC9VkA,4376
89
89
  pymax/protocol/models.py,sha256=kno-09OoPoLKBMixS9nrDkCbxcIwvU37RlZgaq5MfVo,584
90
90
  pymax/protocol/tcp/__init__.py,sha256=ivIZZ-UoT_MiRIUWTLiyeKSBOzf9XztSprHPHgoWNuI,34
91
- pymax/protocol/tcp/compression.py,sha256=sa9xe3cjAYXsJCPPxGc6iRyctFaT15VcqQ0fiC8vk4A,3014
91
+ pymax/protocol/tcp/compression.py,sha256=JsW84MOjtUCJPxMfsfc4CX2bMH0D5sb_2mut189KHmU,3545
92
92
  pymax/protocol/tcp/framing.py,sha256=qH2zNsJLfg5J2wNTcp1hPqsrWhZHajK-Ja7pdB_Gji0,1630
93
- pymax/protocol/tcp/payload.py,sha256=86-NJ5MVvxpgD2ffQqJGaSbDhVBFhaNOH91-k1IQJk8,3515
94
- pymax/protocol/tcp/protocol.py,sha256=-8JQe5rhXXtBMNeCWM8XJXmlnL-0lNSEUCP6LU2zU7c,2244
93
+ pymax/protocol/tcp/payload.py,sha256=roViz6uNrJvmEBukPwBPk462uFJMya48jEnLthZs-yU,4329
94
+ pymax/protocol/tcp/protocol.py,sha256=b5AAYQUOhq985Wc1pCPzt84pjGnVGfGxbdwRN0R91xk,2380
95
95
  pymax/protocol/ws/__init__.py,sha256=oQK0h28B6gGItf7eHanWy_XsgUA_L9Lclnuz9ZQL9YI,33
96
96
  pymax/protocol/ws/protocol.py,sha256=z6zzBQTlJfzwOmLsJYoYQ2L1AYpY5wYMJyPir_zfNBQ,913
97
97
  pymax/session/__init__.py,sha256=natjnLjPjTvkfvSYtoiPxzmU44XJi0MpjWiOPD-On4g,100
@@ -118,13 +118,13 @@ pymax/types/domain/error.py,sha256=WZoDAXf5vt8O4VYy7iEHLGBfH6hZQZ1fVkTz9rVCTN0,5
118
118
  pymax/types/domain/folder.py,sha256=Z9pXMzOTfxuhUfrJpLjl5LPnVXqqFsBVfKBQm4IbGWM,2225
119
119
  pymax/types/domain/login.py,sha256=FBKpRhNPv6Zdjo6Wfl-lK8JpzpMx7zh84f1qrq6Z0og,1301
120
120
  pymax/types/domain/member.py,sha256=8gUNn4fBS_S2ap45A40lRlrDXYFMoq0Kx-dh57hMWQc,490
121
- pymax/types/domain/message.py,sha256=2klOqUolng2NUjO3zxhmv-Mq55X6EhoBV3NO5oywkEE,15503
121
+ pymax/types/domain/message.py,sha256=a1wdIVfRcLo30rUUQzBAyLqCGcPoc-6Uk9jcDg8oqHQ,16497
122
122
  pymax/types/domain/name.py,sha256=qMIshIqgWkVuROxmiCRhwfJf8XtmhSBoEU6nXs_gEh0,523
123
123
  pymax/types/domain/presence.py,sha256=lKvkK0uYwY0gTSEEkaVdI-tEpWjxLOtH7gp2dy5Uh-0,534
124
124
  pymax/types/domain/profile.py,sha256=taN5PjlKp4EDypVhDx89vmBDpSKh-kT-ISpu1tR2cY8,471
125
125
  pymax/types/domain/session.py,sha256=T0b0qjI65kNQOCgx3nIkaIqynD4eGKyqnscbIlPFVnQ,2002
126
126
  pymax/types/domain/sync.py,sha256=lBDWo4ACLq2ov5XBccfnI8s_dsGEA7O4mIJVBSUpyrc,3248
127
- pymax/types/domain/user.py,sha256=dh9NNPQuvIT74W-2URkFfylSWmo8VkfdHsO4_AqDzsQ,5337
127
+ pymax/types/domain/user.py,sha256=tpHBnatNXdajS8noMy8oox9qp8Yej9b7mkdtg7BpjQM,5514
128
128
  pymax/types/domain/attachments/__init__.py,sha256=ZVSb7sSAwabSB8A8BWF_JbooPOMIP1r6gF_NUASgGGk,471
129
129
  pymax/types/domain/attachments/audio.py,sha256=I_Qq1qQ9bXsEzWwxEbQbkiY8CEjSKqGqinDFYY_5DY8,1117
130
130
  pymax/types/domain/attachments/call.py,sha256=RV-BFutymZFinzUiRdaIryqx6ubku2v6y6brc-qsHOQ,818
@@ -147,7 +147,7 @@ pymax/types/events/presence.py,sha256=77Cy352CdfIkbH6ZfLijHffF8gcMz-AZk5OCeESQeD
147
147
  pymax/types/events/reaction.py,sha256=7kauScAH8O-K79xS6Q3b6Vi0KwRl4lGCKGeAMAH9sEU,672
148
148
  pymax/types/events/typing.py,sha256=vDMgqYE0jz8dN2YfqsNaJLB8k7pMd6kaDEdR_KrLk5g,376
149
149
  pymax/types/events/video.py,sha256=swBHYadmDS0SjLXGqVqRYsuMoVZ6MjD2aYR2fbM-AJc,104
150
- maxapi_python-2.3.0.dist-info/METADATA,sha256=2OJj5PZgaKs9M_5UOLdtC0z9MdVWbASx5jx5XWMB5Kw,7276
151
- maxapi_python-2.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
152
- maxapi_python-2.3.0.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
153
- maxapi_python-2.3.0.dist-info/RECORD,,
150
+ maxapi_python-2.3.1.dist-info/METADATA,sha256=k5ItHuOsbXla9MpvlsqOWi7lJbV2u_T8wMotxOA2vDc,7309
151
+ maxapi_python-2.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
152
+ maxapi_python-2.3.1.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
153
+ maxapi_python-2.3.1.dist-info/RECORD,,
pymax/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "2.3.0"
1
+ __version__ = "2.3.1"
2
2
 
3
3
 
4
4
  from .auth import (
@@ -1,4 +1,4 @@
1
- from typing import Any
1
+ from typing import Any, Literal
2
2
 
3
3
  from pydantic import Field
4
4
 
@@ -46,6 +46,26 @@ class SendMessagePayload(CamelModel):
46
46
  notify: bool = False
47
47
 
48
48
 
49
+ class ForwardLink(CamelModel):
50
+ type: Literal["FORWARD"] = "FORWARD"
51
+ message_id: str
52
+ chat_id: int
53
+
54
+
55
+ class ForwardMessagePayloadMessage(CamelModel):
56
+ cid: int
57
+ link: ForwardLink
58
+ attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field(
59
+ default_factory=list
60
+ )
61
+
62
+
63
+ class ForwardMessagePayload(CamelModel):
64
+ chat_id: int
65
+ message: ForwardMessagePayloadMessage
66
+ notify: bool = True
67
+
68
+
49
69
  class ChatHistoryPayload(CamelModel):
50
70
  chat_id: int
51
71
  forward: int
@@ -36,6 +36,9 @@ from .payloads import (
36
36
  ChatHistoryPayload,
37
37
  DeleteMessagePayload,
38
38
  EditMessagePayload,
39
+ ForwardLink,
40
+ ForwardMessagePayload,
41
+ ForwardMessagePayloadMessage,
39
42
  GetFilePayload,
40
43
  GetMessagesPayload,
41
44
  GetReactionsPayload,
@@ -139,6 +142,42 @@ class MessageService:
139
142
  logger.info("message sent chat_id=%s", chat_id)
140
143
  return message
141
144
 
145
+ async def forward_message(
146
+ self,
147
+ chat_id: int,
148
+ message_id: int | str,
149
+ source_chat_id: int | None = None,
150
+ *,
151
+ notify: bool = True,
152
+ ) -> Message | None:
153
+ source_chat_id = chat_id if source_chat_id is None else source_chat_id
154
+ logger.info(
155
+ "forwarding message source_chat_id=%s chat_id=%s message_id=%s",
156
+ source_chat_id,
157
+ chat_id,
158
+ message_id,
159
+ )
160
+
161
+ frame = ForwardMessagePayload(
162
+ chat_id=chat_id,
163
+ message=ForwardMessagePayloadMessage(
164
+ cid=-self._next_cid(),
165
+ link=ForwardLink(
166
+ message_id=str(message_id),
167
+ chat_id=source_chat_id,
168
+ ),
169
+ ),
170
+ notify=notify,
171
+ )
172
+
173
+ response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
174
+ message = bind_api_model(
175
+ self.app,
176
+ require_payload_model(response, Message),
177
+ )
178
+ logger.info("message forwarded source_chat_id=%s chat_id=%s", source_chat_id, chat_id)
179
+ return message
180
+
142
181
  async def get_messages(
143
182
  self,
144
183
  chat_id: int,
pymax/infra/message.py CHANGED
@@ -62,6 +62,33 @@ class MessageMixin(IClientProtocol):
62
62
  message_id=message_id,
63
63
  )
64
64
 
65
+ async def forward_message(
66
+ self,
67
+ chat_id: int,
68
+ message_id: int | str,
69
+ source_chat_id: int | None = None,
70
+ *,
71
+ notify: bool = True,
72
+ ) -> Message | None:
73
+ """Пересылает существующее сообщение в чат.
74
+
75
+ Args:
76
+ chat_id: ID целевого чата.
77
+ message_id: ID пересылаемого сообщения.
78
+ source_chat_id: ID исходного чата. Если не указан, используется
79
+ целевой чат.
80
+ notify: Отправить ли получателям push-уведомление.
81
+
82
+ Returns:
83
+ Пересланное сообщение или ``None``, если сервер не вернул его.
84
+ """
85
+ return await self._app.api.messages.forward_message(
86
+ chat_id=chat_id,
87
+ message_id=message_id,
88
+ source_chat_id=source_chat_id,
89
+ notify=notify,
90
+ )
91
+
65
92
  async def get_messages(
66
93
  self,
67
94
  chat_id: int,
@@ -1,3 +1,8 @@
1
+ from io import BytesIO
2
+
3
+ import zstandard
4
+
5
+
1
6
  class Lz4BlockCompression:
2
7
  def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
3
8
  dst = bytearray()
@@ -95,3 +100,16 @@ class Lz4BlockCompression:
95
100
  dst.extend(src[lit_start : lit_start + lit_len])
96
101
 
97
102
  return bytes(dst)
103
+
104
+
105
+ class ZstdCompression:
106
+ def decompress(self, src: bytes, max_output: int = 5 * 1024 * 1024) -> bytes:
107
+ try:
108
+ with zstandard.ZstdDecompressor().stream_reader(BytesIO(src)) as reader:
109
+ result = reader.read(max_output + 1)
110
+ except zstandard.ZstdError as e:
111
+ raise ValueError("Zstd: failed to decompress payload") from e
112
+
113
+ if len(result) > max_output:
114
+ raise ValueError("Zstd: output too large")
115
+ return result
@@ -5,7 +5,7 @@ import msgpack
5
5
 
6
6
  from pymax.logging import get_logger
7
7
 
8
- from .compression import Lz4BlockCompression
8
+ from .compression import Lz4BlockCompression, ZstdCompression
9
9
 
10
10
  logger = get_logger(__name__)
11
11
 
@@ -70,9 +70,11 @@ class TcpPayloadDecoder:
70
70
  *,
71
71
  serializer: MsgpackPayloadCodec,
72
72
  compression: Lz4BlockCompression | None = None,
73
+ zstd_compression: ZstdCompression | None = None,
73
74
  ) -> None:
74
75
  self.serializer = serializer
75
76
  self.compression = compression
77
+ self.zstd_compression = zstd_compression
76
78
 
77
79
  def _normalize_keys(self, obj: Any) -> Any:
78
80
  if isinstance(obj, dict):
@@ -97,12 +99,26 @@ class TcpPayloadDecoder:
97
99
  if not payload_bytes:
98
100
  return {}
99
101
 
100
- if flags & 0x03 and self.compression:
102
+ if flags == 0xFF:
103
+ if self.zstd_compression is None:
104
+ raise ValueError("Zstd-compressed TCP payload without a decoder")
105
+ try:
106
+ payload_bytes = self.zstd_compression.decompress(payload_bytes)
107
+ logger.debug("tcp payload decompressed with Zstd")
108
+ except ValueError:
109
+ logger.debug("tcp Zstd payload decompression failed", exc_info=True)
110
+ raise
111
+ elif flags > 0x7F:
112
+ raise ValueError(f"invalid TCP compression factor: {flags}")
113
+ elif flags > 0:
114
+ if self.compression is None:
115
+ raise ValueError("LZ4-compressed TCP payload without a decoder")
101
116
  try:
102
117
  payload_bytes = self.compression.decompress(payload_bytes)
103
- logger.debug("tcp payload decompressed flags=%s", flags)
118
+ logger.debug("tcp payload decompressed cof=%s", flags)
104
119
  except ValueError:
105
- logger.debug("tcp payload decompress skipped flags=%s", flags)
120
+ logger.debug("tcp payload decompression failed cof=%s", flags, exc_info=True)
121
+ raise
106
122
 
107
123
  result = self.serializer.decode(payload_bytes)
108
124
  return self._normalize_keys(result)
@@ -7,6 +7,7 @@ from .payload import (
7
7
  Lz4BlockCompression,
8
8
  MsgpackPayloadCodec,
9
9
  TcpPayloadDecoder,
10
+ ZstdCompression,
10
11
  )
11
12
 
12
13
  logger = get_logger(__name__)
@@ -20,8 +21,11 @@ class TcpProtocol(BaseProtocol):
20
21
  self.framer = TcpPacketFramer()
21
22
  self.serializer = MsgpackPayloadCodec()
22
23
  self.compression = Lz4BlockCompression()
24
+ self.zstd_compression = ZstdCompression()
23
25
  self.payload_decoder = TcpPayloadDecoder(
24
- serializer=self.serializer, compression=self.compression
26
+ serializer=self.serializer,
27
+ compression=self.compression,
28
+ zstd_compression=self.zstd_compression,
25
29
  )
26
30
 
27
31
  def encode(self, frame: OutboundFrame) -> bytes:
@@ -93,8 +93,9 @@ class Message(CamelModel):
93
93
 
94
94
  Сообщения, полученные через клиент, обычно уже привязаны к сервису
95
95
  сообщений. После этого можно вызывать удобные методы объекта:
96
- :meth:`reply`, :meth:`answer`, :meth:`edit`, :meth:`pin`, :meth:`delete`,
97
- :meth:`read`, :meth:`react`, :meth:`unreact` и :meth:`get_reactions`.
96
+ :meth:`reply`, :meth:`answer`, :meth:`forward`, :meth:`edit`, :meth:`pin`,
97
+ :meth:`delete`, :meth:`read`, :meth:`react`, :meth:`unreact` и
98
+ :meth:`get_reactions`.
98
99
 
99
100
  Используйте ``Message`` в обработчиках ``on_message`` и при работе с
100
101
  историей. Некоторые поля могут быть ``None``, потому что Max присылает
@@ -244,6 +245,32 @@ class Message(CamelModel):
244
245
  notify=notify,
245
246
  )
246
247
 
248
+ async def forward(
249
+ self,
250
+ chat_id: int,
251
+ *,
252
+ notify: bool = True,
253
+ ) -> Message | None:
254
+ """Пересылает это сообщение в другой чат.
255
+
256
+ :param chat_id: ID целевого чата.
257
+ :type chat_id: int
258
+ :param notify: Отправить ли получателям push-уведомление.
259
+ :type notify: bool
260
+ :returns: Пересланное сообщение или ``None``, если сервер его не вернул.
261
+ :rtype: Message | None
262
+ :raises RuntimeError: Если сообщение не привязано к сервису или не
263
+ содержит ``chat_id``.
264
+ """
265
+ actions, source_chat_id = self._bound()
266
+
267
+ return await actions.forward_message(
268
+ chat_id=chat_id,
269
+ message_id=self.id,
270
+ source_chat_id=source_chat_id,
271
+ notify=notify,
272
+ )
273
+
247
274
  async def pin(self, notify_pin: bool = True) -> bool:
248
275
  """Закрепляет это сообщение в чате.
249
276
 
@@ -49,11 +49,11 @@ class User(CamelModel):
49
49
  :ivar description: Описание профиля.
50
50
  :vartype description: str | None
51
51
  :ivar gender: Пол пользователя.
52
- :vartype gender: str | None
52
+ :vartype gender: str | int | None
53
53
  :ivar link: Ссылка на профиль.
54
54
  :vartype link: str | None
55
55
  :ivar web_app: Данные связанного web-приложения, если есть.
56
- :vartype web_app: dict[str, Any] | None
56
+ :vartype web_app: dict[str, Any] | str | None
57
57
  :ivar menu_button: Данные кнопки меню профиля, если есть.
58
58
  :vartype menu_button: dict[str, Any] | None
59
59
  """
@@ -71,9 +71,11 @@ class User(CamelModel):
71
71
  phone: int | None = None
72
72
  status: str | None = None
73
73
  description: str | None = None
74
- gender: str | None = None
74
+ # Bots may send ``gender`` as a numeric code and ``web_app`` as a URL
75
+ # string instead of an object; accept these so profile parsing won't fail.
76
+ gender: str | int | None = None
75
77
  link: str | None = None
76
- web_app: dict[str, Any] | None = None
78
+ web_app: dict[str, Any] | str | None = None
77
79
  menu_button: dict[str, Any] | None = None
78
80
 
79
81
  _actions: UserService | None = PrivateAttr(default=None)