maxapi-python 2.3.1__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 (79) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
  3. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +9 -2
  5. pymax/_data/apk_fingerprints.json +767 -0
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/enums.py +1 -0
  8. pymax/api/auth/payloads.py +28 -2
  9. pymax/api/auth/service.py +100 -3
  10. pymax/api/chats/__init__.py +1 -0
  11. pymax/api/chats/enums.py +15 -0
  12. pymax/api/chats/payloads.py +18 -2
  13. pymax/api/chats/service.py +43 -1
  14. pymax/api/messages/__init__.py +1 -1
  15. pymax/api/messages/payloads.py +21 -7
  16. pymax/api/messages/service.py +161 -24
  17. pymax/api/self/__init__.py +2 -1
  18. pymax/api/self/enums.py +9 -0
  19. pymax/api/self/payloads.py +40 -1
  20. pymax/api/self/service.py +37 -0
  21. pymax/api/session/service.py +21 -8
  22. pymax/api/uploads/payloads.py +39 -1
  23. pymax/api/uploads/service.py +247 -55
  24. pymax/app.py +91 -18
  25. pymax/auth/__init__.py +2 -0
  26. pymax/auth/exceptions.py +9 -0
  27. pymax/auth/providers.py +6 -2
  28. pymax/auth/sms.py +11 -1
  29. pymax/base.py +90 -8
  30. pymax/client.py +39 -10
  31. pymax/client_web.py +17 -14
  32. pymax/config.py +98 -19
  33. pymax/connection/connection.py +29 -18
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +3 -1
  36. pymax/dispatch/resolvers.py +7 -1
  37. pymax/files/__init__.py +4 -1
  38. pymax/files/base.py +58 -1
  39. pymax/files/video.py +51 -1
  40. pymax/files/voice.py +53 -0
  41. pymax/fingerprint/__init__.py +1 -0
  42. pymax/fingerprint/fingerprint.py +30 -0
  43. pymax/fingerprint/models.py +12 -0
  44. pymax/infra/auth.py +4 -0
  45. pymax/infra/bots.py +3 -3
  46. pymax/infra/chat.py +47 -5
  47. pymax/infra/message.py +49 -14
  48. pymax/infra/protocol.py +5 -1
  49. pymax/infra/self.py +25 -1
  50. pymax/protocol/enums.py +13 -0
  51. pymax/protocol/tcp/payload.py +21 -1
  52. pymax/session/__init__.py +1 -1
  53. pymax/session/models.py +30 -0
  54. pymax/session/protocol.py +33 -7
  55. pymax/session/store.py +73 -5
  56. pymax/transport/tcp.py +17 -4
  57. pymax/transport/websocket.py +6 -3
  58. pymax/types/domain/__init__.py +2 -1
  59. pymax/types/domain/attachments/__init__.py +2 -1
  60. pymax/types/domain/attachments/call.py +7 -1
  61. pymax/types/domain/attachments/contact.py +3 -3
  62. pymax/types/domain/attachments/control.py +3 -0
  63. pymax/types/domain/attachments/enums.py +33 -1
  64. pymax/types/domain/attachments/poll.py +111 -0
  65. pymax/types/domain/attachments/sticker.py +1 -1
  66. pymax/types/domain/attachments/video.py +32 -11
  67. pymax/types/domain/chat.py +9 -9
  68. pymax/types/domain/enums.py +7 -0
  69. pymax/types/domain/handshake.py +16 -0
  70. pymax/types/domain/login.py +30 -1
  71. pymax/types/domain/message.py +108 -27
  72. pymax/types/domain/user.py +1 -3
  73. pymax/types/events/__init__.py +1 -0
  74. pymax/types/events/reaction.py +2 -2
  75. pymax/types/events/voice.py +5 -0
  76. pymax/versions/__init__.py +2 -0
  77. pymax/versions/catalog.py +104 -0
  78. pymax/versions/exceptions.py +10 -0
  79. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,8 @@ 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:
@@ -81,6 +99,8 @@ class TcpPayloadDecoder:
81
99
  return {self._normalize_key(k): self._normalize_keys(v) for k, v in obj.items()}
82
100
  if isinstance(obj, list):
83
101
  return [self._normalize_keys(item) for item in obj]
102
+ if isinstance(obj, msgpack.ExtType):
103
+ return obj
84
104
  if isinstance(obj, tuple):
85
105
  return tuple(self._normalize_keys(item) for item in obj)
86
106
  return obj
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
 
@@ -7,6 +9,7 @@ from pymax.logging import get_logger
7
9
  from .base import Transport
8
10
 
9
11
  logger = get_logger(__name__)
12
+ _CLOSE_TIMEOUT = 5.0
10
13
 
11
14
 
12
15
  class TCPTransport(Transport):
@@ -15,6 +18,10 @@ class TCPTransport(Transport):
15
18
  self._port = port
16
19
  self._proxy = proxy
17
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
+ )
18
25
  self._reader = None
19
26
  self._writer = None
20
27
 
@@ -25,6 +32,9 @@ class TCPTransport(Transport):
25
32
  self._port,
26
33
  self._use_ssl,
27
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
+
28
38
  if self._proxy:
29
39
  logger.debug("tcp connecting via proxy %s", self._proxy)
30
40
  proxy = Proxy.from_url(self._proxy)
@@ -32,10 +42,9 @@ class TCPTransport(Transport):
32
42
  dest_host=self._host,
33
43
  dest_port=self._port,
34
44
  )
35
- server_hostname = self._host if self._use_ssl else None
36
45
  self._reader, self._writer = await asyncio.open_connection(
37
46
  sock=sock,
38
- ssl=self._use_ssl,
47
+ ssl=ssl_ctx,
39
48
  server_hostname=server_hostname,
40
49
  )
41
50
  logger.info(
@@ -49,7 +58,7 @@ class TCPTransport(Transport):
49
58
  self._reader, self._writer = await asyncio.open_connection(
50
59
  self._host,
51
60
  self._port,
52
- ssl=self._use_ssl,
61
+ ssl=ssl_ctx,
53
62
  )
54
63
  logger.info(
55
64
  "tcp connected host=%s port=%s ssl=%s",
@@ -66,7 +75,11 @@ class TCPTransport(Transport):
66
75
  if writer:
67
76
  logger.debug("tcp close")
68
77
  writer.close()
69
- await writer.wait_closed()
78
+ try:
79
+ await asyncio.wait_for(writer.wait_closed(), _CLOSE_TIMEOUT)
80
+ except (OSError, TimeoutError) as e:
81
+ logger.warning("tcp close did not finish cleanly: %s", e)
82
+ writer.transport.abort()
70
83
  logger.debug("tcp closed")
71
84
 
72
85
  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
@@ -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
@@ -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,34 @@ 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
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"
@@ -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