maxapi-python 2.4.0__py3-none-any.whl → 2.4.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.
Files changed (47) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
  2. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +4 -2
  5. pymax/_data/apk_fingerprints.json +102 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +14 -2
  8. pymax/api/chats/payloads.py +2 -1
  9. pymax/api/chats/service.py +7 -0
  10. pymax/api/messages/__init__.py +1 -1
  11. pymax/api/messages/payloads.py +9 -3
  12. pymax/api/messages/service.py +106 -12
  13. pymax/api/uploads/payloads.py +33 -1
  14. pymax/api/uploads/service.py +77 -62
  15. pymax/app.py +23 -4
  16. pymax/auth/__init__.py +2 -0
  17. pymax/auth/exceptions.py +9 -0
  18. pymax/auth/providers.py +6 -2
  19. pymax/auth/sms.py +11 -1
  20. pymax/base.py +83 -9
  21. pymax/client.py +39 -10
  22. pymax/client_web.py +15 -12
  23. pymax/config.py +63 -21
  24. pymax/files/base.py +58 -1
  25. pymax/files/video.py +32 -40
  26. pymax/files/voice.py +18 -4
  27. pymax/fingerprint/fingerprint.py +5 -22
  28. pymax/fingerprint/models.py +1 -0
  29. pymax/infra/chat.py +9 -5
  30. pymax/infra/message.py +9 -3
  31. pymax/infra/protocol.py +5 -1
  32. pymax/session/__init__.py +1 -1
  33. pymax/session/models.py +30 -0
  34. pymax/session/protocol.py +33 -7
  35. pymax/session/store.py +73 -5
  36. pymax/transport/tcp.py +11 -3
  37. pymax/types/domain/attachments/call.py +7 -1
  38. pymax/types/domain/attachments/contact.py +3 -3
  39. pymax/types/domain/attachments/control.py +3 -0
  40. pymax/types/domain/attachments/enums.py +16 -0
  41. pymax/types/domain/attachments/video.py +3 -3
  42. pymax/types/domain/enums.py +7 -0
  43. pymax/types/domain/message.py +87 -7
  44. pymax/versions/__init__.py +2 -0
  45. pymax/versions/catalog.py +104 -0
  46. pymax/versions/exceptions.py +10 -0
  47. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
pymax/files/voice.py CHANGED
@@ -1,14 +1,26 @@
1
1
  from collections.abc import AsyncGenerator
2
2
  from pathlib import Path
3
3
 
4
- from .base import BaseFile
4
+ from .base import TimedMediaFile
5
5
 
6
6
 
7
- class Voice(BaseFile):
7
+ class Voice(TimedMediaFile):
8
8
  """Голосовое сообщение в формате OGG для отправки.
9
9
 
10
10
  Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно
11
- передать имя файла.
11
+ передать имя файла. Длительность задается в миллисекундах; если она не
12
+ передана, требуется extra ``video`` для автоматического определения.
13
+
14
+ Args:
15
+ raw: Байты голосового сообщения.
16
+ path: Локальный путь к OGG-файлу.
17
+ url: URL OGG-файла.
18
+ name: Имя файла. Обязательно для ``raw``.
19
+ duration: Длительность в миллисекундах. ``None`` включает
20
+ автоматическое определение через extra ``video``.
21
+
22
+ Raises:
23
+ RuntimeError: При определении длительности без extra ``video``.
12
24
  """
13
25
 
14
26
  def __init__(
@@ -18,8 +30,10 @@ class Voice(BaseFile):
18
30
  path: str | None = None,
19
31
  url: str | None = None,
20
32
  name: str | None = None,
33
+ duration: int | None = None,
21
34
  ) -> None:
22
35
  self.name: str = name or ""
36
+ self.duration = duration
23
37
  if not self.name and path:
24
38
  self.name = Path(path).name
25
39
  elif not self.name and url:
@@ -27,7 +41,7 @@ class Voice(BaseFile):
27
41
 
28
42
  if not self.name:
29
43
  raise ValueError("Either name, url or path must be provided.")
30
- super().__init__(raw=raw, url=url, path=path, name=self.name)
44
+ super().__init__(raw=raw, url=url, path=path, name=self.name, duration=self.duration)
31
45
 
32
46
  async def read(self) -> bytes:
33
47
  return await super().read()
@@ -1,47 +1,30 @@
1
1
  import hashlib
2
- import json
3
2
  import struct
4
- from importlib import resources
5
- from typing import Any
6
3
 
7
4
  from .models import ApkBuildFingerprint
8
5
 
9
6
 
10
7
  class FingerprintGenerator:
11
- def __init__(
12
- self,
13
- ) -> None:
14
- self.path = resources.files("pymax._data") / "apk_fingerprints.json"
15
- self.data = self.load_fingerprints()
16
-
17
- def load_fingerprints(self) -> Any:
18
- with self.path.open("r", encoding="utf-8") as f:
19
- return json.load(f)
8
+ def __init__(self, version_data: ApkBuildFingerprint) -> None:
9
+ self.data = version_data
20
10
 
21
11
  def generate_fingerprint(
22
12
  self,
23
- version: str,
24
13
  device_id: str,
25
14
  calls_seed: int,
26
15
  arch: str = "arm64-v8a",
27
16
  ) -> bytes | None:
28
- data = self.data.get(version)
29
- if not data:
30
- return None
31
-
32
- model = ApkBuildFingerprint.model_validate(data)
33
-
34
17
  seed_bytes = struct.pack(">q", calls_seed)
35
18
  device_bytes = device_id.encode("utf-8")
36
19
 
37
20
  h1 = hashlib.sha256(
38
- bytes.fromhex(model.certificate_meta_sha256) + seed_bytes + device_bytes
21
+ bytes.fromhex(self.data.certificate_meta_sha256) + seed_bytes + device_bytes
39
22
  ).digest()
40
23
  h2 = hashlib.sha256(
41
- bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes
24
+ bytes.fromhex(self.data.dex_meta_sha256) + seed_bytes + device_bytes
42
25
  ).digest()
43
26
  h3 = hashlib.sha256(
44
- bytes.fromhex(model.so_meta_sha256[arch]) + seed_bytes + device_bytes
27
+ bytes.fromhex(self.data.so_meta_sha256[arch]) + seed_bytes + device_bytes
45
28
  ).digest()
46
29
 
47
30
  return h1 + h2 + h3
@@ -7,5 +7,6 @@ class ApkBuildFingerprint(BaseModel):
7
7
  certificate_meta_sha256: str
8
8
  certificate_sha256: list[str]
9
9
  dex_meta_sha256: str
10
+ so_meta_sha256_arm64_v8a: str
10
11
  so_meta_sha256: dict[str, str]
11
12
  build_number: int
pymax/infra/chat.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from pymax.api.chats import ChannelPermissions
2
+ from pymax.files import Photo
2
3
  from pymax.types import Chat, Member, Message
3
4
 
4
5
  from .protocol import IClientProtocol
@@ -131,20 +132,23 @@ class ChatMixin(IClientProtocol):
131
132
  async def change_group_profile(
132
133
  self,
133
134
  chat_id: int,
134
- name: str | None,
135
+ name: str | None = None,
135
136
  description: str | None = None,
137
+ photo: Photo | None = None,
136
138
  ) -> None:
137
- """Обновляет название и описание группы.
139
+ """Обновляет название, описание или фотографию группы.
140
+
141
+ ``None`` исключает соответствующее поле из запроса. Фотография
142
+ автоматически загружается перед обновлением профиля.
138
143
 
139
144
  Args:
140
145
  chat_id: ID группы.
141
146
  name: Новое название.
142
147
  description: Новое описание.
148
+ photo: Новая фотография группы.
143
149
  """
144
150
  await self._app.api.chats.change_group_profile(
145
- chat_id=chat_id,
146
- name=name,
147
- description=description,
151
+ chat_id=chat_id, name=name, description=description, photo=photo
148
152
  )
149
153
 
150
154
  async def join_group(self, link: str) -> Chat:
pymax/infra/message.py CHANGED
@@ -1,3 +1,4 @@
1
+ from pymax.api.messages import DateTimeUnion
1
2
  from pymax.api.messages.enums import ItemType
2
3
  from pymax.api.messages.service import SendAttachments
3
4
  from pymax.types import (
@@ -23,6 +24,7 @@ class MessageMixin(IClientProtocol):
23
24
  attachments: SendAttachments = None,
24
25
  *,
25
26
  notify: bool = True,
27
+ send_at: DateTimeUnion | None = None,
26
28
  ) -> Message:
27
29
  """Отправляет сообщение в чат.
28
30
 
@@ -32,6 +34,9 @@ class MessageMixin(IClientProtocol):
32
34
  reply_to: ID сообщения для ответа.
33
35
  attachments: Файлы, фотографии, видео или опросы для отправки.
34
36
  notify: Отправить ли получателям push-уведомление.
37
+ send_at: Время отложенной отправки: ``datetime`` для абсолютного
38
+ времени, ``timedelta`` для задержки или Unix time в секундах
39
+ как ``int``. ``None`` и ``0`` отправляют сообщение сразу.
35
40
 
36
41
  Returns:
37
42
  Отправленное сообщение.
@@ -45,6 +50,7 @@ class MessageMixin(IClientProtocol):
45
50
  reply_to,
46
51
  attachments,
47
52
  notify=notify,
53
+ send_at=send_at,
48
54
  )
49
55
 
50
56
  async def get_message(
@@ -272,7 +278,7 @@ class MessageMixin(IClientProtocol):
272
278
  async def add_reaction(
273
279
  self,
274
280
  chat_id: int,
275
- message_id: str,
281
+ message_id: int,
276
282
  reaction: str,
277
283
  ) -> ReactionInfo | None:
278
284
  """Добавляет реакцию к сообщению.
@@ -294,7 +300,7 @@ class MessageMixin(IClientProtocol):
294
300
  async def get_reactions(
295
301
  self,
296
302
  chat_id: int,
297
- message_ids: list[str],
303
+ message_ids: list[int],
298
304
  ) -> dict[str, ReactionInfo] | None:
299
305
  """Возвращает реакции для нескольких сообщений.
300
306
 
@@ -314,7 +320,7 @@ class MessageMixin(IClientProtocol):
314
320
  async def remove_reaction(
315
321
  self,
316
322
  chat_id: int,
317
- message_id: str,
323
+ message_id: int,
318
324
  ) -> ReactionInfo | None:
319
325
  """Удаляет свою реакцию с сообщения.
320
326
 
pymax/infra/protocol.py CHANGED
@@ -6,4 +6,8 @@ from pymax.app import App
6
6
  class IClientProtocol(Protocol):
7
7
  """Описывает минимальный клиент, нужный infra-миксинам."""
8
8
 
9
- _app: App
9
+ @property
10
+ def _app(self) -> App: ...
11
+
12
+ @_app.setter
13
+ def _app(self, value: App) -> None: ...
pymax/session/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from .models import SessionInfo
2
2
  from .protocol import StoreProtocol
3
- from .store import SessionStore
3
+ from .store import InMemoryStore, SessionStore
pymax/session/models.py CHANGED
@@ -1,11 +1,41 @@
1
1
  from pydantic import BaseModel, Field
2
2
 
3
+ from pymax.api.session.payloads import MobileUserAgentPayload
3
4
  from pymax.types.domain.sync import SyncState
4
5
 
5
6
 
6
7
  class SessionInfo(BaseModel):
8
+ """Состояние авторизованной сессии, сохраняемое через ``StoreProtocol``.
9
+
10
+ Args:
11
+ token: Login token Max.
12
+ device_id: ID устройства, связанный с сессией.
13
+ phone: Номер телефона сессии; для web-сессии может быть пустой строкой.
14
+ mt_instance_id: Instance ID устройства. Пустая строка поддерживается для
15
+ сессий, созданных предыдущими версиями PyMax.
16
+ user_agent: Device/user-agent сессии. ``None`` поддерживается для старых
17
+ или пользовательских store, которые его не сохраняют.
18
+ sync: Последние sync-маркеры login.
19
+ """
20
+
7
21
  token: str
8
22
  device_id: str
9
23
  phone: str
10
24
  mt_instance_id: str = ""
25
+ user_agent: MobileUserAgentPayload | None = None
11
26
  sync: SyncState = Field(default_factory=SyncState)
27
+
28
+
29
+ def resolve_session_user_agent(
30
+ current: MobileUserAgentPayload,
31
+ stored: MobileUserAgentPayload | None,
32
+ ) -> MobileUserAgentPayload:
33
+ if stored is None or stored.device_type != current.device_type:
34
+ return current
35
+
36
+ return stored.model_copy(
37
+ update={
38
+ "app_version": current.app_version,
39
+ "build_number": current.build_number,
40
+ }
41
+ )
pymax/session/protocol.py CHANGED
@@ -5,10 +5,36 @@ from .models import SessionInfo
5
5
 
6
6
  @runtime_checkable
7
7
  class StoreProtocol(Protocol):
8
- async def save_session(self, session_info: SessionInfo) -> None: ...
9
- async def update_token(self, old_token: str, new_token: str) -> None: ...
10
- async def load_session(self) -> SessionInfo | None: ...
11
- async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ...
12
- async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ...
13
- async def delete_session(self, token: str) -> None: ...
14
- async def close(self) -> None: ...
8
+ """Асинхронный контракт пользовательского хранилища сессии.
9
+
10
+ PyMax использует переданный ``ExtraConfig.store`` только при
11
+ ``persist_session=True`` и вызывает :meth:`close` при закрытии runtime.
12
+ """
13
+
14
+ async def save_session(self, session_info: SessionInfo) -> None:
15
+ """Сохраняет или полностью заменяет данные сессии."""
16
+ ...
17
+
18
+ async def update_token(self, old_token: str, new_token: str, /) -> None:
19
+ """Заменяет ``old_token`` у соответствующей сессии."""
20
+ ...
21
+
22
+ async def load_session(self) -> SessionInfo | None:
23
+ """Возвращает сессию для запуска клиента или ``None``."""
24
+ ...
25
+
26
+ async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
27
+ """Ищет сессию по device ID."""
28
+ ...
29
+
30
+ async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
31
+ """Ищет сессию по номеру телефона."""
32
+ ...
33
+
34
+ async def delete_session(self, token: str, /) -> None:
35
+ """Удаляет сессию с переданным token, если она существует."""
36
+ ...
37
+
38
+ async def close(self) -> None:
39
+ """Освобождает ресурсы хранилища при закрытии runtime."""
40
+ ...
pymax/session/store.py CHANGED
@@ -2,10 +2,12 @@ from pathlib import Path
2
2
 
3
3
  import aiosqlite
4
4
 
5
+ from pymax.api.session.payloads import MobileUserAgentPayload
5
6
  from pymax.logging import get_logger
6
7
  from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, SyncState
7
8
 
8
9
  from .models import SessionInfo
10
+ from .protocol import StoreProtocol
9
11
 
10
12
  logger = get_logger(__name__)
11
13
 
@@ -18,11 +20,64 @@ SESSION_COLUMNS = """
18
20
  contacts_sync,
19
21
  drafts_sync,
20
22
  presence_sync,
21
- config_hash
23
+ config_hash,
24
+ user_agent
22
25
  """
23
26
 
24
27
 
25
- class SessionStore:
28
+ class InMemoryStore(StoreProtocol):
29
+ """Хранилище одной сессии в памяти текущего runtime.
30
+
31
+ Данные не записываются на диск и становятся недоступны после пересоздания
32
+ ``App``. Обычно этот store выбирается автоматически при
33
+ ``ExtraConfig(persist_session=False)``.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ ) -> None:
39
+ self._session: SessionInfo | None = None
40
+
41
+ async def save_session(self, session_info: SessionInfo) -> None:
42
+ self._session = session_info
43
+
44
+ async def update_token(self, _: str, new_token: str) -> None:
45
+ if not self._session:
46
+ return
47
+ self._session.token = new_token
48
+
49
+ async def load_session(self) -> SessionInfo | None:
50
+ return self._session
51
+
52
+ async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
53
+ if self._session and self._session.device_id != device_id:
54
+ return None
55
+
56
+ return self._session
57
+
58
+ async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
59
+ if self._session and self._session.phone != phone:
60
+ return None
61
+
62
+ return self._session
63
+
64
+ async def delete_session(self, _: str) -> None:
65
+ self._session = None
66
+
67
+ async def close(self) -> None:
68
+ return None
69
+
70
+
71
+ class SessionStore(StoreProtocol):
72
+ """SQLite-хранилище сессий, используемое по умолчанию.
73
+
74
+ Args:
75
+ work_dir: Директория файла базы данных; создается автоматически.
76
+ db_name: Имя файла базы данных. По умолчанию ``session.db``.
77
+
78
+ Схема существующего файла автоматически дополняется недостающими колонками.
79
+ """
80
+
26
81
  def __init__(self, work_dir: str, db_name: str = "session.db") -> None:
27
82
  self.work_dir = Path(work_dir)
28
83
  self.work_dir.mkdir(parents=True, exist_ok=True)
@@ -51,7 +106,8 @@ class SessionStore:
51
106
  contacts_sync INTEGER NOT NULL DEFAULT -1,
52
107
  drafts_sync INTEGER NOT NULL DEFAULT -1,
53
108
  presence_sync INTEGER NOT NULL DEFAULT -1,
54
- config_hash TEXT NOT NULL DEFAULT ''
109
+ config_hash TEXT NOT NULL DEFAULT '',
110
+ user_agent TEXT
55
111
  )
56
112
  """
57
113
  )
@@ -61,6 +117,7 @@ class SessionStore:
61
117
  await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1")
62
118
  await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1")
63
119
  await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''")
120
+ await self._ensure_column(conn, "user_agent", "TEXT")
64
121
  await conn.execute(
65
122
  """
66
123
  UPDATE sessions
@@ -102,9 +159,10 @@ class SessionStore:
102
159
  contacts_sync,
103
160
  drafts_sync,
104
161
  presence_sync,
105
- config_hash
162
+ config_hash,
163
+ user_agent
106
164
  )
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108
166
  """,
109
167
  (
110
168
  session_info.token,
@@ -116,6 +174,11 @@ class SessionStore:
116
174
  session_info.sync.drafts_sync,
117
175
  session_info.sync.presence_sync,
118
176
  session_info.sync.config_hash,
177
+ (
178
+ session_info.user_agent.model_dump_json(by_alias=True, exclude_none=True)
179
+ if session_info.user_agent is not None
180
+ else None
181
+ ),
119
182
  ),
120
183
  )
121
184
  await conn.commit()
@@ -233,6 +296,11 @@ class SessionStore:
233
296
  device_id=row["device_id"],
234
297
  phone=row["phone"],
235
298
  mt_instance_id=row["mt_instance_id"] or "",
299
+ user_agent=(
300
+ MobileUserAgentPayload.model_validate_json(row["user_agent"])
301
+ if row["user_agent"] is not None
302
+ else None
303
+ ),
236
304
  sync=SyncState(
237
305
  chats_sync=row["chats_sync"],
238
306
  contacts_sync=row["contacts_sync"],
pymax/transport/tcp.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import ssl
3
+ from importlib import resources
2
4
 
3
5
  from python_socks.async_.asyncio import Proxy
4
6
 
@@ -16,6 +18,10 @@ class TCPTransport(Transport):
16
18
  self._port = port
17
19
  self._proxy = proxy
18
20
  self._use_ssl = use_ssl
21
+ self._ssl_ctx = ssl.create_default_context()
22
+ self._ssl_ctx.load_verify_locations(
23
+ str(resources.files("pymax._data") / "rootca_ssl_rsa2022.crt")
24
+ )
19
25
  self._reader = None
20
26
  self._writer = None
21
27
 
@@ -26,6 +32,9 @@ class TCPTransport(Transport):
26
32
  self._port,
27
33
  self._use_ssl,
28
34
  )
35
+ ssl_ctx = self._ssl_ctx if self._use_ssl else None
36
+ server_hostname = self._host if self._use_ssl else None
37
+
29
38
  if self._proxy:
30
39
  logger.debug("tcp connecting via proxy %s", self._proxy)
31
40
  proxy = Proxy.from_url(self._proxy)
@@ -33,10 +42,9 @@ class TCPTransport(Transport):
33
42
  dest_host=self._host,
34
43
  dest_port=self._port,
35
44
  )
36
- server_hostname = self._host if self._use_ssl else None
37
45
  self._reader, self._writer = await asyncio.open_connection(
38
46
  sock=sock,
39
- ssl=self._use_ssl,
47
+ ssl=ssl_ctx,
40
48
  server_hostname=server_hostname,
41
49
  )
42
50
  logger.info(
@@ -50,7 +58,7 @@ class TCPTransport(Transport):
50
58
  self._reader, self._writer = await asyncio.open_connection(
51
59
  self._host,
52
60
  self._port,
53
- ssl=self._use_ssl,
61
+ ssl=ssl_ctx,
54
62
  )
55
63
  logger.info(
56
64
  "tcp connected host=%s port=%s ssl=%s",
@@ -4,7 +4,7 @@ from pydantic import Field
4
4
 
5
5
  from pymax.types.domain.base import CamelModel
6
6
 
7
- from .enums import AttachmentType
7
+ from .enums import AttachmentType, CallType, HangupType
8
8
 
9
9
 
10
10
  class CallAttachment(CamelModel):
@@ -18,9 +18,15 @@ class CallAttachment(CamelModel):
18
18
  :vartype conversation_id: str | int | None
19
19
  :ivar contact_ids: ID участников звонка.
20
20
  :vartype contact_ids: list[int]
21
+ :ivar call_type: Тип звонка, если Max его прислал.
22
+ :vartype call_type: CallType | None
23
+ :ivar hangup_type: Причина завершения звонка, если она есть в payload-е.
24
+ :vartype hangup_type: HangupType | None
21
25
  """
22
26
 
23
27
  type: Literal[AttachmentType.CALL] = Field(alias="_type")
24
28
  duration: int | None = None
25
29
  conversation_id: str | int | None = None
26
30
  contact_ids: list[int] = Field(default_factory=list)
31
+ call_type: CallType | None = None
32
+ hangup_type: HangupType | None = None
@@ -10,8 +10,8 @@ from .enums import AttachmentType
10
10
  class ContactAttachment(CamelModel):
11
11
  """Контактное вложение сообщения.
12
12
 
13
- :ivar contact_id: ID контакта.
14
- :vartype contact_id: int
13
+ :ivar contact_id: ID контакта, если Max его прислал.
14
+ :vartype contact_id: int | None
15
15
  :ivar first_name: Имя контакта.
16
16
  :vartype first_name: str | None
17
17
  :ivar last_name: Фамилия контакта.
@@ -24,7 +24,7 @@ class ContactAttachment(CamelModel):
24
24
  :vartype type: Literal[AttachmentType.CONTACT]
25
25
  """
26
26
 
27
- contact_id: int
27
+ contact_id: int | None = None
28
28
  first_name: str | None = None
29
29
  last_name: str | None = None
30
30
  name: str | None = None
@@ -14,7 +14,10 @@ class ControlAttachment(CamelModel):
14
14
  :vartype type: Literal[AttachmentType.CONTROL]
15
15
  :ivar event: Событие управления.
16
16
  :vartype event: str
17
+ :ivar title: Заголовок служебного события, если он есть в payload-е.
18
+ :vartype title: str | None
17
19
  """
18
20
 
19
21
  type: Literal[AttachmentType.CONTROL] = Field(alias="_type")
20
22
  event: str
23
+ title: str | None = None
@@ -42,3 +42,19 @@ class PollFlags(IntFlag):
42
42
  FLAG_SETTINGS_CLOSED = 8
43
43
  FLAG_SETTINGS_QUIZ = 16
44
44
  FLAG_SETTINGS_CAN_FORWARD = 32
45
+
46
+
47
+ class CallType(str, Enum):
48
+ """Тип звонка во входящем ``CallAttachment``."""
49
+
50
+ AUDIO = "AUDIO"
51
+ VIDEO = "VIDEO"
52
+
53
+
54
+ class HangupType(str, Enum):
55
+ """Причина завершения звонка во входящем ``CallAttachment``."""
56
+
57
+ MISSED = "MISSED"
58
+ REJECTED = "REJECTED"
59
+ CANCELED = "CANCELED"
60
+ HUNGUP = "HUNGUP"
@@ -32,8 +32,8 @@ class VideoAttachment(CamelModel):
32
32
  :vartype video_id: int
33
33
  :ivar duration: Длительность видео.
34
34
  :vartype duration: int | None
35
- :ivar preview_data: Данные превью.
36
- :vartype preview_data: bytes
35
+ :ivar preview_data: Данные превью, если Max их прислал.
36
+ :vartype preview_data: bytes | None
37
37
  :ivar type: Тип вложения.
38
38
  :vartype type: Literal[AttachmentType.VIDEO]
39
39
  :ivar thumbnail: URL миниатюры.
@@ -48,7 +48,7 @@ class VideoAttachment(CamelModel):
48
48
  width: int
49
49
  video_id: int
50
50
  duration: int | None = None
51
- preview_data: bytes
51
+ preview_data: bytes | None = None
52
52
  type: Literal[AttachmentType.VIDEO] = Field(alias="_type")
53
53
  thumbnail: str
54
54
  token: str
@@ -22,3 +22,10 @@ class MessageStatus(str, Enum):
22
22
 
23
23
  EDITED = "EDITED"
24
24
  REMOVED = "REMOVED"
25
+
26
+
27
+ class LinkType(str, Enum):
28
+ """Тип связи входящего сообщения с исходным сообщением."""
29
+
30
+ REPLY = "REPLY"
31
+ FORWARD = "FORWARD"