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.
Files changed (61) hide show
  1. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +4 -1
  2. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +61 -53
  3. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +7 -2
  5. pymax/_data/apk_fingerprints.json +682 -0
  6. pymax/api/auth/enums.py +1 -0
  7. pymax/api/auth/payloads.py +28 -2
  8. pymax/api/auth/service.py +88 -3
  9. pymax/api/chats/__init__.py +1 -0
  10. pymax/api/chats/enums.py +15 -0
  11. pymax/api/chats/payloads.py +16 -1
  12. pymax/api/chats/service.py +36 -1
  13. pymax/api/messages/payloads.py +33 -5
  14. pymax/api/messages/service.py +95 -13
  15. pymax/api/self/__init__.py +2 -1
  16. pymax/api/self/enums.py +9 -0
  17. pymax/api/self/payloads.py +40 -1
  18. pymax/api/self/service.py +37 -0
  19. pymax/api/session/service.py +21 -8
  20. pymax/api/uploads/payloads.py +6 -0
  21. pymax/api/uploads/service.py +198 -21
  22. pymax/app.py +69 -15
  23. pymax/base.py +8 -0
  24. pymax/client_web.py +2 -2
  25. pymax/config.py +44 -7
  26. pymax/connection/connection.py +29 -18
  27. pymax/dispatch/enums.py +1 -0
  28. pymax/dispatch/mapping.py +3 -1
  29. pymax/dispatch/resolvers.py +7 -1
  30. pymax/files/__init__.py +4 -1
  31. pymax/files/video.py +58 -0
  32. pymax/files/voice.py +39 -0
  33. pymax/fingerprint/__init__.py +1 -0
  34. pymax/fingerprint/fingerprint.py +47 -0
  35. pymax/fingerprint/models.py +11 -0
  36. pymax/infra/auth.py +4 -0
  37. pymax/infra/bots.py +3 -3
  38. pymax/infra/chat.py +38 -0
  39. pymax/infra/message.py +65 -9
  40. pymax/infra/self.py +25 -1
  41. pymax/protocol/enums.py +13 -0
  42. pymax/protocol/tcp/compression.py +18 -0
  43. pymax/protocol/tcp/payload.py +41 -5
  44. pymax/protocol/tcp/protocol.py +5 -1
  45. pymax/transport/tcp.py +6 -1
  46. pymax/transport/websocket.py +6 -3
  47. pymax/types/domain/__init__.py +2 -1
  48. pymax/types/domain/attachments/__init__.py +2 -1
  49. pymax/types/domain/attachments/enums.py +17 -1
  50. pymax/types/domain/attachments/poll.py +111 -0
  51. pymax/types/domain/attachments/sticker.py +1 -1
  52. pymax/types/domain/attachments/video.py +29 -8
  53. pymax/types/domain/chat.py +9 -9
  54. pymax/types/domain/handshake.py +16 -0
  55. pymax/types/domain/login.py +30 -1
  56. pymax/types/domain/message.py +47 -19
  57. pymax/types/domain/user.py +5 -5
  58. pymax/types/events/__init__.py +1 -0
  59. pymax/types/events/reaction.py +2 -2
  60. pymax/types/events/voice.py +5 -0
  61. {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: Any | None = None,
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
@@ -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(raw=raw, strict_map_key=False)
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 & 0x03 and self.compression:
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 flags=%s", flags)
138
+ logger.debug("tcp payload decompressed cof=%s", flags)
104
139
  except ValueError:
105
- logger.debug("tcp payload decompress skipped flags=%s", flags)
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)
@@ -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:
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
- await writer.wait_closed()
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:
@@ -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, origin=Origin("https://web.max.ru"), proxy=self.proxy
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=True)
47
+ return await self.ws.recv(decode=False)
45
48
 
46
49
  @property
47
50
  def connected(self) -> bool:
@@ -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 .login import LoginResponse
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
@@ -42,7 +42,7 @@ class StickerAttachment(CamelModel):
42
42
  sticker_id: int
43
43
  tags: list[str] | None = None
44
44
  width: int
45
- set_id: int
45
+ set_id: int | None = None
46
46
  time: int
47
47
  sticker_type: str
48
48
  audio: bool
@@ -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 unwrap_dynamic_url(cls, value: Any) -> Any:
76
- """Нормализует динамический ключ URL в поле ``url``.
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 in ("EXTERNAL", "cache"):
88
- return {**value, "url": url}
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
@@ -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 | None:
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: Отправленное сообщение или ``None``, если сервер не вернул
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] | None:
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: Сообщения или ``None``, если сервер не вернул список.
228
- :rtype: list[Message] | None
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
@@ -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
+ )