maxapi-python 2.3.0__py3-none-any.whl → 2.4.0__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.
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +4 -1
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +61 -53
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +7 -2
- pymax/_data/apk_fingerprints.json +682 -0
- pymax/api/auth/enums.py +1 -0
- pymax/api/auth/payloads.py +28 -2
- pymax/api/auth/service.py +88 -3
- pymax/api/chats/__init__.py +1 -0
- pymax/api/chats/enums.py +15 -0
- pymax/api/chats/payloads.py +16 -1
- pymax/api/chats/service.py +36 -1
- pymax/api/messages/payloads.py +33 -5
- pymax/api/messages/service.py +95 -13
- pymax/api/self/__init__.py +2 -1
- pymax/api/self/enums.py +9 -0
- pymax/api/self/payloads.py +40 -1
- pymax/api/self/service.py +37 -0
- pymax/api/session/service.py +21 -8
- pymax/api/uploads/payloads.py +6 -0
- pymax/api/uploads/service.py +198 -21
- pymax/app.py +69 -15
- pymax/base.py +8 -0
- pymax/client_web.py +2 -2
- pymax/config.py +44 -7
- pymax/connection/connection.py +29 -18
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +3 -1
- pymax/dispatch/resolvers.py +7 -1
- pymax/files/__init__.py +4 -1
- pymax/files/video.py +58 -0
- pymax/files/voice.py +39 -0
- pymax/fingerprint/__init__.py +1 -0
- pymax/fingerprint/fingerprint.py +47 -0
- pymax/fingerprint/models.py +11 -0
- pymax/infra/auth.py +4 -0
- pymax/infra/bots.py +3 -3
- pymax/infra/chat.py +38 -0
- pymax/infra/message.py +65 -9
- pymax/infra/self.py +25 -1
- pymax/protocol/enums.py +13 -0
- pymax/protocol/tcp/compression.py +18 -0
- pymax/protocol/tcp/payload.py +41 -5
- pymax/protocol/tcp/protocol.py +5 -1
- pymax/transport/tcp.py +6 -1
- pymax/transport/websocket.py +6 -3
- pymax/types/domain/__init__.py +2 -1
- pymax/types/domain/attachments/__init__.py +2 -1
- pymax/types/domain/attachments/enums.py +17 -1
- pymax/types/domain/attachments/poll.py +111 -0
- pymax/types/domain/attachments/sticker.py +1 -1
- pymax/types/domain/attachments/video.py +29 -8
- pymax/types/domain/chat.py +9 -9
- pymax/types/domain/handshake.py +16 -0
- pymax/types/domain/login.py +30 -1
- pymax/types/domain/message.py +47 -19
- pymax/types/domain/user.py +5 -5
- pymax/types/events/__init__.py +1 -0
- pymax/types/events/reaction.py +2 -2
- pymax/types/events/voice.py +5 -0
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/licenses/LICENSE +0 -0
pymax/infra/self.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
+
from pymax.api.self import PrivacySettingsUpdate
|
|
4
|
+
from pymax.files import Photo
|
|
3
5
|
from pymax.types import FolderList, FolderUpdate
|
|
4
6
|
|
|
5
7
|
from .protocol import IClientProtocol
|
|
@@ -19,7 +21,7 @@ class SelfMixin(IClientProtocol):
|
|
|
19
21
|
first_name: str,
|
|
20
22
|
last_name: str | None = None,
|
|
21
23
|
description: str | None = None,
|
|
22
|
-
photo:
|
|
24
|
+
photo: Photo | None = None,
|
|
23
25
|
*,
|
|
24
26
|
photo_token: str | None = None,
|
|
25
27
|
) -> bool:
|
|
@@ -133,3 +135,25 @@ class SelfMixin(IClientProtocol):
|
|
|
133
135
|
``True``, если сервер принял запрос на выход.
|
|
134
136
|
"""
|
|
135
137
|
return await self._app.api.account.logout()
|
|
138
|
+
|
|
139
|
+
def set_presence(self, *, online: bool) -> None:
|
|
140
|
+
"""Устанавливает статус присутствия текущего аккаунта.
|
|
141
|
+
|
|
142
|
+
Статус применяется не мгновенно, а при следующем запросе login/ping.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
online: ``True``, если нужно установить статус "в сети", иначе
|
|
146
|
+
``False``.
|
|
147
|
+
"""
|
|
148
|
+
self._app.api.account.set_presence(online)
|
|
149
|
+
|
|
150
|
+
async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool:
|
|
151
|
+
"""Обновляет настройки приватности текущего аккаунта.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
settings: Объект с новыми настройками приватности.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
``True``, если сервер принял запрос.
|
|
158
|
+
"""
|
|
159
|
+
return await self._app.api.account.change_profile_settings(settings=settings)
|
pymax/protocol/enums.py
CHANGED
|
@@ -18,6 +18,7 @@ class Opcode(int, Enum):
|
|
|
18
18
|
RECONNECT = 3
|
|
19
19
|
LOG = 5
|
|
20
20
|
SESSION_INIT = 6
|
|
21
|
+
LOGIN2 = 8
|
|
21
22
|
PROFILE = 16
|
|
22
23
|
AUTH_REQUEST = 17
|
|
23
24
|
AUTH = 18
|
|
@@ -152,6 +153,18 @@ class Opcode(int, Enum):
|
|
|
152
153
|
PROFILE_DELETE = 199
|
|
153
154
|
PROFILE_DELETE_TIME = 200
|
|
154
155
|
TRANSCRIBE_MEDIA = 202
|
|
156
|
+
STORIES_LIST = 208
|
|
157
|
+
STORIES_LIST_BY_OWNER_ID = 209
|
|
158
|
+
STORIES_GET_BY_OWNER_ID = 210
|
|
159
|
+
STORIES_GET_STATS = 211
|
|
160
|
+
STORIES_GET_DETAILED_STATS = 212
|
|
161
|
+
STORIES_REACT = 213
|
|
162
|
+
STORIES_MARK = 214
|
|
163
|
+
STORIES_SEND = 215
|
|
164
|
+
NOTIF_STORIES_UPDATE = 216
|
|
165
|
+
STORIES_EDIT = 217
|
|
166
|
+
STORIES_DELETE = 218
|
|
167
|
+
STORIES_GET_BY_STORY_ID = 220
|
|
155
168
|
ORG_INFO = 256
|
|
156
169
|
CHAT_REACTIONS_SETTINGS_SET = 257
|
|
157
170
|
REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258
|
|
@@ -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
|
pymax/protocol/tcp/payload.py
CHANGED
|
@@ -5,12 +5,14 @@ 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
|
|
|
12
12
|
|
|
13
13
|
class MsgpackPayloadCodec:
|
|
14
|
+
WRAPPED_VALUE_EXT_CODE = 1
|
|
15
|
+
|
|
14
16
|
def _to_msgpack_value(self, value: Any) -> Any:
|
|
15
17
|
if isinstance(value, Enum):
|
|
16
18
|
return value.value
|
|
@@ -30,10 +32,25 @@ class MsgpackPayloadCodec:
|
|
|
30
32
|
def _unpack_stream(
|
|
31
33
|
self, payload_bytes: bytes, *, raw: bool
|
|
32
34
|
) -> list[Any]: # TODO: deprecate? idk
|
|
33
|
-
unpacker = msgpack.Unpacker(
|
|
35
|
+
unpacker = msgpack.Unpacker(
|
|
36
|
+
raw=raw,
|
|
37
|
+
strict_map_key=False,
|
|
38
|
+
ext_hook=self._decode_ext,
|
|
39
|
+
)
|
|
34
40
|
unpacker.feed(payload_bytes)
|
|
35
41
|
return list(unpacker)
|
|
36
42
|
|
|
43
|
+
def _decode_ext(self, code: int, data: bytes) -> Any:
|
|
44
|
+
if code != self.WRAPPED_VALUE_EXT_CODE:
|
|
45
|
+
return msgpack.ExtType(code, data)
|
|
46
|
+
|
|
47
|
+
return msgpack.unpackb(
|
|
48
|
+
data,
|
|
49
|
+
raw=False,
|
|
50
|
+
strict_map_key=False,
|
|
51
|
+
ext_hook=self._decode_ext,
|
|
52
|
+
)
|
|
53
|
+
|
|
37
54
|
def decode(self, payload_bytes: bytes) -> Any:
|
|
38
55
|
if not payload_bytes:
|
|
39
56
|
return {}
|
|
@@ -43,6 +60,7 @@ class MsgpackPayloadCodec:
|
|
|
43
60
|
payload_bytes,
|
|
44
61
|
raw=False,
|
|
45
62
|
strict_map_key=False,
|
|
63
|
+
ext_hook=self._decode_ext,
|
|
46
64
|
)
|
|
47
65
|
|
|
48
66
|
except msgpack.exceptions.ExtraData as e:
|
|
@@ -70,15 +88,19 @@ class TcpPayloadDecoder:
|
|
|
70
88
|
*,
|
|
71
89
|
serializer: MsgpackPayloadCodec,
|
|
72
90
|
compression: Lz4BlockCompression | None = None,
|
|
91
|
+
zstd_compression: ZstdCompression | None = None,
|
|
73
92
|
) -> None:
|
|
74
93
|
self.serializer = serializer
|
|
75
94
|
self.compression = compression
|
|
95
|
+
self.zstd_compression = zstd_compression
|
|
76
96
|
|
|
77
97
|
def _normalize_keys(self, obj: Any) -> Any:
|
|
78
98
|
if isinstance(obj, dict):
|
|
79
99
|
return {self._normalize_key(k): self._normalize_keys(v) for k, v in obj.items()}
|
|
80
100
|
if isinstance(obj, list):
|
|
81
101
|
return [self._normalize_keys(item) for item in obj]
|
|
102
|
+
if isinstance(obj, msgpack.ExtType):
|
|
103
|
+
return obj
|
|
82
104
|
if isinstance(obj, tuple):
|
|
83
105
|
return tuple(self._normalize_keys(item) for item in obj)
|
|
84
106
|
return obj
|
|
@@ -97,12 +119,26 @@ class TcpPayloadDecoder:
|
|
|
97
119
|
if not payload_bytes:
|
|
98
120
|
return {}
|
|
99
121
|
|
|
100
|
-
if flags
|
|
122
|
+
if flags == 0xFF:
|
|
123
|
+
if self.zstd_compression is None:
|
|
124
|
+
raise ValueError("Zstd-compressed TCP payload without a decoder")
|
|
125
|
+
try:
|
|
126
|
+
payload_bytes = self.zstd_compression.decompress(payload_bytes)
|
|
127
|
+
logger.debug("tcp payload decompressed with Zstd")
|
|
128
|
+
except ValueError:
|
|
129
|
+
logger.debug("tcp Zstd payload decompression failed", exc_info=True)
|
|
130
|
+
raise
|
|
131
|
+
elif flags > 0x7F:
|
|
132
|
+
raise ValueError(f"invalid TCP compression factor: {flags}")
|
|
133
|
+
elif flags > 0:
|
|
134
|
+
if self.compression is None:
|
|
135
|
+
raise ValueError("LZ4-compressed TCP payload without a decoder")
|
|
101
136
|
try:
|
|
102
137
|
payload_bytes = self.compression.decompress(payload_bytes)
|
|
103
|
-
logger.debug("tcp payload decompressed
|
|
138
|
+
logger.debug("tcp payload decompressed cof=%s", flags)
|
|
104
139
|
except ValueError:
|
|
105
|
-
logger.debug("tcp payload
|
|
140
|
+
logger.debug("tcp payload decompression failed cof=%s", flags, exc_info=True)
|
|
141
|
+
raise
|
|
106
142
|
|
|
107
143
|
result = self.serializer.decode(payload_bytes)
|
|
108
144
|
return self._normalize_keys(result)
|
pymax/protocol/tcp/protocol.py
CHANGED
|
@@ -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,
|
|
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:
|
pymax/transport/tcp.py
CHANGED
|
@@ -7,6 +7,7 @@ from pymax.logging import get_logger
|
|
|
7
7
|
from .base import Transport
|
|
8
8
|
|
|
9
9
|
logger = get_logger(__name__)
|
|
10
|
+
_CLOSE_TIMEOUT = 5.0
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class TCPTransport(Transport):
|
|
@@ -66,7 +67,11 @@ class TCPTransport(Transport):
|
|
|
66
67
|
if writer:
|
|
67
68
|
logger.debug("tcp close")
|
|
68
69
|
writer.close()
|
|
69
|
-
|
|
70
|
+
try:
|
|
71
|
+
await asyncio.wait_for(writer.wait_closed(), _CLOSE_TIMEOUT)
|
|
72
|
+
except (OSError, TimeoutError) as e:
|
|
73
|
+
logger.warning("tcp close did not finish cleanly: %s", e)
|
|
74
|
+
writer.transport.abort()
|
|
70
75
|
logger.debug("tcp closed")
|
|
71
76
|
|
|
72
77
|
async def send(self, data: bytes | str) -> None:
|
pymax/transport/websocket.py
CHANGED
|
@@ -17,11 +17,14 @@ class WebSocketTransport(Transport):
|
|
|
17
17
|
async def connect(self) -> None:
|
|
18
18
|
if self.proxy:
|
|
19
19
|
self.ws = await client.connect(
|
|
20
|
-
self.url,
|
|
20
|
+
self.url,
|
|
21
|
+
origin=Origin("https://web.max.ru"),
|
|
22
|
+
proxy=self.proxy,
|
|
23
|
+
max_size=1024 * 1024 * 10, # 10 MB
|
|
21
24
|
)
|
|
22
25
|
else:
|
|
23
26
|
self.ws = await client.connect(
|
|
24
|
-
self.url, origin=Origin("https://web.max.ru")
|
|
27
|
+
self.url, origin=Origin("https://web.max.ru"), max_size=1024 * 1024 * 10
|
|
25
28
|
) # TODO: origin should be configurable
|
|
26
29
|
|
|
27
30
|
async def close(self) -> None:
|
|
@@ -41,7 +44,7 @@ class WebSocketTransport(Transport):
|
|
|
41
44
|
if self.ws is None or not self.connected:
|
|
42
45
|
raise ConnectionError("Not connected to the server")
|
|
43
46
|
|
|
44
|
-
return await self.ws.recv(decode=
|
|
47
|
+
return await self.ws.recv(decode=False)
|
|
45
48
|
|
|
46
49
|
@property
|
|
47
50
|
def connected(self) -> bool:
|
pymax/types/domain/__init__.py
CHANGED
|
@@ -3,7 +3,8 @@ from .bots import InitData
|
|
|
3
3
|
from .chat import Chat
|
|
4
4
|
from .error import MaxApiError
|
|
5
5
|
from .folder import Folder, FolderList, FolderUpdate
|
|
6
|
-
from .
|
|
6
|
+
from .handshake import HandshakeResponse
|
|
7
|
+
from .login import Login2Flags, Login2Response, LoginResponse
|
|
7
8
|
from .member import Member
|
|
8
9
|
from .message import Message, ReactionCounter, ReactionInfo, ReadState
|
|
9
10
|
from .name import Name
|
|
@@ -2,10 +2,11 @@ from .audio import AudioAttachment
|
|
|
2
2
|
from .call import CallAttachment
|
|
3
3
|
from .contact import ContactAttachment
|
|
4
4
|
from .control import ControlAttachment
|
|
5
|
-
from .enums import AttachmentType
|
|
5
|
+
from .enums import AttachmentType, PollFlags
|
|
6
6
|
from .file import FileAttachment, FileRequest
|
|
7
7
|
from .keyboards import InlineKeyboardAttachment
|
|
8
8
|
from .photo import PhotoAttachment
|
|
9
|
+
from .poll import Poll, PollAnswer, PollAttachment, PollState
|
|
9
10
|
from .share import ShareAttachment
|
|
10
11
|
from .sticker import StickerAttachment
|
|
11
12
|
from .unknown import UnknownAttachment
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from enum import Enum
|
|
1
|
+
from enum import Enum, IntFlag
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class AttachmentType(str, Enum):
|
|
@@ -14,6 +14,7 @@ class AttachmentType(str, Enum):
|
|
|
14
14
|
CALL = "CALL"
|
|
15
15
|
SHARE = "SHARE"
|
|
16
16
|
INLINE_KEYBOARD = "INLINE_KEYBOARD"
|
|
17
|
+
POLL = "POLL"
|
|
17
18
|
UNKNOWN = "UNKNOWN"
|
|
18
19
|
|
|
19
20
|
|
|
@@ -26,3 +27,18 @@ class TranscriptionStatus(str, Enum):
|
|
|
26
27
|
PROCESSING = "PROCESSING"
|
|
27
28
|
SUCCESS = "SUCCESS"
|
|
28
29
|
UNKNOWN = "UNKNOWN"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PollFlags(IntFlag):
|
|
33
|
+
"""Настройки опроса, представленные битовой маской.
|
|
34
|
+
|
|
35
|
+
Несколько настроек объединяются оператором ``|``. Числовые значения,
|
|
36
|
+
полученные от Max, автоматически преобразуются в ``PollFlags``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
FLAG_SETTINGS_ANONYMOUS = 1
|
|
40
|
+
FLAG_SETTINGS_MULTISELECT = 2
|
|
41
|
+
FLAG_SETTINGS_REVOTE = 4
|
|
42
|
+
FLAG_SETTINGS_CLOSED = 8
|
|
43
|
+
FLAG_SETTINGS_QUIZ = 16
|
|
44
|
+
FLAG_SETTINGS_CAN_FORWARD = 32
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from pymax.types.domain.base import CamelModel
|
|
6
|
+
|
|
7
|
+
from .enums import AttachmentType, PollFlags
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PollAnswer(CamelModel):
|
|
11
|
+
"""Вариант ответа в опросе.
|
|
12
|
+
|
|
13
|
+
:ivar text: Текст варианта ответа.
|
|
14
|
+
:vartype text: str
|
|
15
|
+
:ivar answer_id: ID варианта, назначенный Max.
|
|
16
|
+
:vartype answer_id: int | None
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
text: str
|
|
20
|
+
answer_id: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PollVote(CamelModel):
|
|
24
|
+
"""Голос пользователя за один вариант ответа.
|
|
25
|
+
|
|
26
|
+
:ivar timestamp: Время голосования в формате Unix time.
|
|
27
|
+
:vartype timestamp: int
|
|
28
|
+
:ivar user_id: ID проголосовавшего пользователя.
|
|
29
|
+
:vartype user_id: int
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
timestamp: int
|
|
33
|
+
user_id: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PollResult(CamelModel):
|
|
37
|
+
"""Результат голосования по одному варианту ответа.
|
|
38
|
+
|
|
39
|
+
:ivar answer_id: ID варианта ответа.
|
|
40
|
+
:vartype answer_id: int
|
|
41
|
+
:ivar vote_count: Количество голосов.
|
|
42
|
+
:vartype vote_count: int
|
|
43
|
+
:ivar votes: Голоса пользователей, доступные текущему аккаунту.
|
|
44
|
+
:vartype votes: list[PollVote]
|
|
45
|
+
:ivar rate: Доля голосов в формате, возвращаемом Max.
|
|
46
|
+
:vartype rate: int
|
|
47
|
+
:ivar options: Дополнительные параметры результата от Max.
|
|
48
|
+
:vartype options: int
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
answer_id: int
|
|
52
|
+
vote_count: int
|
|
53
|
+
votes: list[PollVote]
|
|
54
|
+
rate: int
|
|
55
|
+
options: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PollState(CamelModel):
|
|
59
|
+
"""Текущее состояние голосования.
|
|
60
|
+
|
|
61
|
+
:ivar total: Общее количество голосов.
|
|
62
|
+
:vartype total: int
|
|
63
|
+
:ivar result: Результаты по вариантам ответа.
|
|
64
|
+
:vartype result: list[PollResult] | None
|
|
65
|
+
:ivar voter_preview_ids: ID пользователей для предпросмотра списка
|
|
66
|
+
проголосовавших.
|
|
67
|
+
:vartype voter_preview_ids: list[int]
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
total: int = 0
|
|
71
|
+
result: list[PollResult] | None = None
|
|
72
|
+
voter_preview_ids: list[int]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Poll(CamelModel):
|
|
76
|
+
"""Опрос для отправки в сообщении.
|
|
77
|
+
|
|
78
|
+
:ivar title: Вопрос или заголовок опроса.
|
|
79
|
+
:vartype title: str
|
|
80
|
+
:ivar answers: Варианты ответа.
|
|
81
|
+
:vartype answers: list[PollAnswer]
|
|
82
|
+
:ivar settings: Настройки опроса. Несколько ``PollFlags`` объединяются
|
|
83
|
+
оператором ``|``.
|
|
84
|
+
:vartype settings: PollFlags
|
|
85
|
+
:ivar type: Тип вложения.
|
|
86
|
+
:vartype type: Literal[AttachmentType.POLL]
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
title: str
|
|
90
|
+
answers: list[PollAnswer]
|
|
91
|
+
settings: PollFlags
|
|
92
|
+
type: Literal[AttachmentType.POLL] = Field(alias="_type", default=AttachmentType.POLL)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PollAttachment(Poll):
|
|
96
|
+
"""Опрос, полученный как вложение сообщения.
|
|
97
|
+
|
|
98
|
+
Помимо параметров опроса содержит назначенный Max ID, версию и текущее
|
|
99
|
+
состояние голосования.
|
|
100
|
+
|
|
101
|
+
:ivar poll_id: ID опроса.
|
|
102
|
+
:vartype poll_id: int
|
|
103
|
+
:ivar version: Версия данных опроса.
|
|
104
|
+
:vartype version: int
|
|
105
|
+
:ivar state: Текущее состояние голосования.
|
|
106
|
+
:vartype state: PollState
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
poll_id: int
|
|
110
|
+
version: int
|
|
111
|
+
state: PollState
|
|
@@ -62,18 +62,18 @@ class VideoRequest(CamelModel):
|
|
|
62
62
|
:vartype external: str | bool | None
|
|
63
63
|
:ivar cache: Использовать ли кеш.
|
|
64
64
|
:vartype cache: bool
|
|
65
|
-
:ivar url: URL видео.
|
|
66
|
-
:vartype url: str
|
|
65
|
+
:ivar url: Прямой URL видео или ``None`` для внешнего видео.
|
|
66
|
+
:vartype url: str | None
|
|
67
67
|
"""
|
|
68
68
|
|
|
69
69
|
external: str | bool | None = Field(default=None, alias="EXTERNAL")
|
|
70
|
-
cache: bool
|
|
71
|
-
url: str
|
|
70
|
+
cache: bool = False # TODO: idk maybe | None = None better
|
|
71
|
+
url: str | None = None
|
|
72
72
|
|
|
73
73
|
@model_validator(mode="before")
|
|
74
74
|
@classmethod
|
|
75
|
-
def
|
|
76
|
-
"""
|
|
75
|
+
def select_video_url(cls, value: Any) -> Any:
|
|
76
|
+
"""Выбирает прямой URL с максимальным доступным MP4-качеством.
|
|
77
77
|
|
|
78
78
|
:param value: Значение, переданное в валидатор модели.
|
|
79
79
|
:type value: Any
|
|
@@ -83,8 +83,29 @@ class VideoRequest(CamelModel):
|
|
|
83
83
|
if not isinstance(value, dict) or "url" in value:
|
|
84
84
|
return value
|
|
85
85
|
|
|
86
|
+
mp4_urls: list[tuple[int, str]] = []
|
|
86
87
|
for key, url in value.items():
|
|
87
|
-
if key not
|
|
88
|
-
|
|
88
|
+
if not isinstance(key, str) or not isinstance(url, str):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
normalized_key = key.upper()
|
|
92
|
+
if not normalized_key.startswith("MP4_"):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
quality = int(normalized_key.removeprefix("MP4_"))
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if quality > 0:
|
|
101
|
+
mp4_urls.append((quality, url))
|
|
102
|
+
|
|
103
|
+
if mp4_urls:
|
|
104
|
+
_, url = max(mp4_urls, key=lambda item: item[0])
|
|
105
|
+
return {**value, "url": url}
|
|
106
|
+
|
|
107
|
+
legacy_url = value.get("dynamicUrl", value.get("dynamic_url"))
|
|
108
|
+
if isinstance(legacy_url, str):
|
|
109
|
+
return {**value, "url": legacy_url}
|
|
89
110
|
|
|
90
111
|
return value
|
pymax/types/domain/chat.py
CHANGED
|
@@ -156,25 +156,24 @@ class Chat(CamelModel):
|
|
|
156
156
|
|
|
157
157
|
async def answer(
|
|
158
158
|
self,
|
|
159
|
-
text: str,
|
|
159
|
+
text: str | None = None,
|
|
160
160
|
reply_to: int | None = None,
|
|
161
161
|
attachments: SendAttachments = None,
|
|
162
162
|
*,
|
|
163
163
|
notify: bool = True,
|
|
164
|
-
) -> Message
|
|
164
|
+
) -> Message:
|
|
165
165
|
"""Отправляет сообщение в этот чат.
|
|
166
166
|
|
|
167
167
|
:param text: Текст сообщения.
|
|
168
|
-
:type text: str
|
|
168
|
+
:type text: str | None
|
|
169
169
|
:param reply_to: ID сообщения для ответа.
|
|
170
170
|
:type reply_to: int | None
|
|
171
171
|
:param attachments: Файлы, фотографии или видео для отправки.
|
|
172
172
|
:type attachments: SendAttachments
|
|
173
173
|
:param notify: Отправить ли получателям push-уведомление.
|
|
174
174
|
:type notify: bool
|
|
175
|
-
:returns: Отправленное
|
|
176
|
-
|
|
177
|
-
:rtype: Message | None
|
|
175
|
+
:returns: Отправленное сообщение.
|
|
176
|
+
:rtype: Message
|
|
178
177
|
:raises RuntimeError: Если чат не привязан к клиенту.
|
|
179
178
|
"""
|
|
180
179
|
actions, _ = self._bound()
|
|
@@ -198,7 +197,7 @@ class Chat(CamelModel):
|
|
|
198
197
|
get_chat: bool = False,
|
|
199
198
|
get_messages: bool = True,
|
|
200
199
|
interactive: bool = False,
|
|
201
|
-
) -> list[Message]
|
|
200
|
+
) -> list[Message]:
|
|
202
201
|
"""Загружает историю сообщений этого чата.
|
|
203
202
|
|
|
204
203
|
``from_time``, ``backward_time`` и ``forward_time`` передаются в
|
|
@@ -224,8 +223,9 @@ class Chat(CamelModel):
|
|
|
224
223
|
:type get_messages: bool
|
|
225
224
|
:param interactive: Пометить запрос как интерактивный.
|
|
226
225
|
:type interactive: bool
|
|
227
|
-
:returns:
|
|
228
|
-
|
|
226
|
+
:returns: Список сообщений. Если сервер не вернул сообщения, список
|
|
227
|
+
пуст.
|
|
228
|
+
:rtype: list[Message]
|
|
229
229
|
:raises RuntimeError: Если чат не привязан к клиенту.
|
|
230
230
|
"""
|
|
231
231
|
actions, _ = self._bound()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from .base import CamelModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HandshakeResponse(CamelModel):
|
|
7
|
+
"""Результат рукопожатия.
|
|
8
|
+
|
|
9
|
+
:ivar calls_seed: Сид для генерации хэшей вызовов.
|
|
10
|
+
:vartype calls_seed: int
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
calls_seed: int | None = None
|
|
14
|
+
app_update_type: int | None = Field(
|
|
15
|
+
alias="app-update-type", default=None
|
|
16
|
+
) # very bizarre casing
|
pymax/types/domain/login.py
CHANGED
|
@@ -13,14 +13,26 @@ class LoginConfig(CamelModel):
|
|
|
13
13
|
hash: ConfigHash | None = None
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
class Login2Flags(CamelModel):
|
|
17
|
+
config_enabled: bool = False
|
|
18
|
+
contact_enabled: bool = False
|
|
19
|
+
profile_enabled: bool = False
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def enabled(self) -> bool:
|
|
23
|
+
return self.config_enabled or self.contact_enabled or self.profile_enabled
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
class LoginResponse(CamelModel):
|
|
17
27
|
chats: list[Chat] = Field(default_factory=list)
|
|
18
|
-
profile: Profile
|
|
28
|
+
profile: Profile | None = None
|
|
19
29
|
messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message]
|
|
20
30
|
contacts: list[User | None] = Field(default_factory=list)
|
|
21
31
|
token: str | None = None
|
|
22
32
|
time: int | None = None
|
|
23
33
|
config: LoginConfig | None = None
|
|
34
|
+
updates: int | None = None
|
|
35
|
+
login2_flags: Login2Flags | None = None
|
|
24
36
|
|
|
25
37
|
def update_sync_state(self, current: SyncState) -> SyncState:
|
|
26
38
|
sync_time = self.time
|
|
@@ -33,3 +45,20 @@ class LoginResponse(CamelModel):
|
|
|
33
45
|
presence_sync=(sync_time if sync_time is not None else current.presence_sync),
|
|
34
46
|
config_hash=(config_hash if config_hash is not None else current.config_hash),
|
|
35
47
|
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Login2Response(CamelModel):
|
|
51
|
+
profile: Profile | None = None
|
|
52
|
+
contacts: list[User | None] = Field(default_factory=list, alias="contactInfos")
|
|
53
|
+
config: LoginConfig | None = None
|
|
54
|
+
|
|
55
|
+
def update_sync_state(self, current: SyncState) -> SyncState:
|
|
56
|
+
config_hash = self.config.hash if self.config is not None else None
|
|
57
|
+
|
|
58
|
+
return SyncState(
|
|
59
|
+
chats_sync=current.chats_sync,
|
|
60
|
+
contacts_sync=current.contacts_sync,
|
|
61
|
+
drafts_sync=current.drafts_sync,
|
|
62
|
+
presence_sync=current.presence_sync,
|
|
63
|
+
config_hash=(config_hash if config_hash is not None else current.config_hash),
|
|
64
|
+
)
|