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
pymax/dispatch/mapping.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
6
6
  from pymax.api.binding import bind_api_model
7
7
  from pymax.protocol import InboundFrame, Opcode
8
8
  from pymax.protocol.enums import Command
9
- from pymax.types import Chat, MessageDeleteEvent
9
+ from pymax.types import AudioUploadSignal, Chat, MessageDeleteEvent
10
10
  from pymax.types.domain import Message
11
11
  from pymax.types.events import (
12
12
  FileUploadSignal,
@@ -100,4 +100,6 @@ class EventMapper:
100
100
  return VideoUploadSignal.model_validate(frame.payload)
101
101
  elif event_type == EventType.FILE_READY:
102
102
  return FileUploadSignal.model_validate(frame.payload)
103
+ elif event_type == EventType.VOICE_READY:
104
+ return AudioUploadSignal.model_validate(frame.payload)
103
105
  return frame
@@ -3,7 +3,7 @@ from pydantic import ValidationError
3
3
  from pymax.logging import get_logger
4
4
  from pymax.protocol import InboundFrame
5
5
  from pymax.protocol.enums import Opcode
6
- from pymax.types import Message
6
+ from pymax.types import AudioUploadSignal, Message
7
7
  from pymax.types.domain.enums import MessageStatus
8
8
  from pymax.types.events import FileUploadSignal, VideoUploadSignal
9
9
 
@@ -49,6 +49,12 @@ def resolve_attach(frame: InboundFrame) -> EventType | None:
49
49
  except ValidationError:
50
50
  logger.debug("attach event is not a video upload signal")
51
51
 
52
+ try:
53
+ AudioUploadSignal.model_validate(frame.payload)
54
+ return EventType.VOICE_READY
55
+ except ValidationError:
56
+ logger.debug("attach event is not a voice upload signal")
57
+
52
58
  return None
53
59
 
54
60
 
pymax/files/__init__.py CHANGED
@@ -1,9 +1,12 @@
1
1
  from .file import File
2
2
  from .photo import Photo
3
- from .video import Video
3
+ from .video import Video, VideoNote
4
+ from .voice import Voice
4
5
 
5
6
  __all__ = (
6
7
  "File",
7
8
  "Photo",
8
9
  "Video",
10
+ "VideoNote",
11
+ "Voice",
9
12
  )
pymax/files/base.py CHANGED
@@ -1,10 +1,16 @@
1
1
  import os
2
2
  from abc import ABC, abstractmethod
3
3
  from collections.abc import AsyncGenerator
4
+ from io import BytesIO
4
5
 
5
6
  import aiofiles
6
7
  import aiohttp
7
8
 
9
+ try:
10
+ from tinytag import TinyTag
11
+ except ImportError:
12
+ TinyTag = None
13
+
8
14
 
9
15
  class BaseFile(ABC):
10
16
  def __init__(
@@ -56,7 +62,7 @@ class BaseFile(ABC):
56
62
 
57
63
  if self.url:
58
64
  async with aiohttp.ClientSession() as session: # noqa: SIM117
59
- async with session.head(self.url) as resp:
65
+ async with session.head(self.url, allow_redirects=True) as resp:
60
66
  return int(resp.headers["Content-Length"])
61
67
  else:
62
68
  raise ValueError("Path or Url must be provided")
@@ -85,3 +91,54 @@ class BaseFile(ABC):
85
91
  resp.raise_for_status()
86
92
  async for chunk in resp.content.iter_chunked(size):
87
93
  yield chunk
94
+
95
+
96
+ class TimedMediaFile(BaseFile):
97
+ def __init__(
98
+ self,
99
+ raw: bytes | None = None,
100
+ *,
101
+ path: str | None,
102
+ url: str | None,
103
+ name: str | None,
104
+ duration: int | None,
105
+ ) -> None:
106
+ self.duration = duration
107
+ super().__init__(raw, path=path, url=url, name=name)
108
+
109
+ async def get_duration(self) -> int:
110
+ if self.duration is not None:
111
+ return self.duration
112
+
113
+ if not TinyTag:
114
+ raise RuntimeError(
115
+ "Automatic video duration detection requires the 'video' extra. "
116
+ "Install it with `uv add 'maxapi-python[video]'` "
117
+ "or pass duration manually."
118
+ )
119
+
120
+ if self.raw:
121
+ tag = TinyTag.get(
122
+ filename=self.name,
123
+ file_obj=BytesIO(self.raw),
124
+ tags=False,
125
+ duration=True,
126
+ )
127
+ elif self.path:
128
+ tag = TinyTag.get(
129
+ filename=self.path,
130
+ tags=False,
131
+ duration=True,
132
+ )
133
+ else:
134
+ tag = TinyTag.get(
135
+ filename=self.name,
136
+ file_obj=BytesIO(await self.read()),
137
+ tags=False,
138
+ duration=True,
139
+ )
140
+
141
+ if tag.duration is None:
142
+ raise ValueError("Failed to determine video duration.")
143
+
144
+ return round(tag.duration * 1000)
pymax/files/video.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from collections.abc import AsyncGenerator
2
2
  from pathlib import Path
3
3
 
4
- from .base import BaseFile
4
+ from .base import BaseFile, TimedMediaFile
5
5
 
6
6
 
7
7
  class Video(BaseFile):
@@ -72,3 +72,53 @@ class Video(BaseFile):
72
72
  Async generator с байтами.
73
73
  """
74
74
  return super().iter_chunks(size)
75
+
76
+
77
+ class VideoNote(TimedMediaFile):
78
+ """Круглое видеосообщение для отправки.
79
+
80
+ Принимает те же источники, что и ``Video``. Длительность задается в
81
+ миллисекундах. Если она не передана, требуется extra ``video`` для
82
+ автоматического определения длительности.
83
+
84
+ Args:
85
+ raw: Байты видеосообщения.
86
+ url: URL видеосообщения.
87
+ path: Локальный путь к видеосообщению.
88
+ name: Имя файла. Обязательно для ``raw``.
89
+ duration: Длительность в миллисекундах. ``None`` включает
90
+ автоматическое определение через extra ``video``.
91
+
92
+ Raises:
93
+ RuntimeError: При определении длительности без extra ``video``.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ raw: bytes | None = None,
99
+ *,
100
+ url: str | None = None,
101
+ path: str | None = None,
102
+ name: str | None = None,
103
+ duration: int | None = None,
104
+ ) -> None:
105
+ self.duration = duration
106
+ self.name: str = name or ""
107
+ if not self.name and path:
108
+ self.name = Path(path).name
109
+ elif not self.name and url:
110
+ self.name = Path(url).name
111
+
112
+ if not self.name:
113
+ raise ValueError("Either name, url or path must be provided.")
114
+
115
+ super().__init__(raw, url=url, path=path, name=self.name, duration=self.duration)
116
+
117
+ async def read(self) -> bytes:
118
+ return await super().read()
119
+
120
+ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]:
121
+ return super().iter_chunks(size)
122
+
123
+ async def size(self) -> int:
124
+ return await super().size()
pymax/files/voice.py ADDED
@@ -0,0 +1,53 @@
1
+ from collections.abc import AsyncGenerator
2
+ from pathlib import Path
3
+
4
+ from .base import TimedMediaFile
5
+
6
+
7
+ class Voice(TimedMediaFile):
8
+ """Голосовое сообщение в формате OGG для отправки.
9
+
10
+ Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно
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``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ raw: bytes | None = None,
29
+ *,
30
+ path: str | None = None,
31
+ url: str | None = None,
32
+ name: str | None = None,
33
+ duration: int | None = None,
34
+ ) -> None:
35
+ self.name: str = name or ""
36
+ self.duration = duration
37
+ if not self.name and path:
38
+ self.name = Path(path).name
39
+ elif not self.name and url:
40
+ self.name = Path(url).name
41
+
42
+ if not self.name:
43
+ raise ValueError("Either name, url or path must be provided.")
44
+ super().__init__(raw=raw, url=url, path=path, name=self.name, duration=self.duration)
45
+
46
+ async def read(self) -> bytes:
47
+ return await super().read()
48
+
49
+ async def size(self) -> int:
50
+ return await super().size()
51
+
52
+ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]:
53
+ return super().iter_chunks(size)
@@ -0,0 +1 @@
1
+ from .fingerprint import FingerprintGenerator
@@ -0,0 +1,30 @@
1
+ import hashlib
2
+ import struct
3
+
4
+ from .models import ApkBuildFingerprint
5
+
6
+
7
+ class FingerprintGenerator:
8
+ def __init__(self, version_data: ApkBuildFingerprint) -> None:
9
+ self.data = version_data
10
+
11
+ def generate_fingerprint(
12
+ self,
13
+ device_id: str,
14
+ calls_seed: int,
15
+ arch: str = "arm64-v8a",
16
+ ) -> bytes | None:
17
+ seed_bytes = struct.pack(">q", calls_seed)
18
+ device_bytes = device_id.encode("utf-8")
19
+
20
+ h1 = hashlib.sha256(
21
+ bytes.fromhex(self.data.certificate_meta_sha256) + seed_bytes + device_bytes
22
+ ).digest()
23
+ h2 = hashlib.sha256(
24
+ bytes.fromhex(self.data.dex_meta_sha256) + seed_bytes + device_bytes
25
+ ).digest()
26
+ h3 = hashlib.sha256(
27
+ bytes.fromhex(self.data.so_meta_sha256[arch]) + seed_bytes + device_bytes
28
+ ).digest()
29
+
30
+ return h1 + h2 + h3
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ApkBuildFingerprint(BaseModel):
5
+ signature_scheme: str
6
+ certificate_count: int
7
+ certificate_meta_sha256: str
8
+ certificate_sha256: list[str]
9
+ dex_meta_sha256: str
10
+ so_meta_sha256_arm64_v8a: str
11
+ so_meta_sha256: dict[str, str]
12
+ build_number: int
pymax/infra/auth.py CHANGED
@@ -95,3 +95,7 @@ class AuthMixin(IClientProtocol):
95
95
  """
96
96
 
97
97
  return await self._app.api.auth.check_2fa()
98
+
99
+ def is_update_available(self) -> bool:
100
+ """Возвращает признак доступного обновления приложения по handshake."""
101
+ return self._app.api.auth.is_update_available()
pymax/infra/bots.py CHANGED
@@ -9,14 +9,14 @@ class BotsMixin(IClientProtocol):
9
9
  async def get_bot_init_data(
10
10
  self,
11
11
  bot_id: int,
12
- chat_id: int,
12
+ chat_id: int | None = None,
13
13
  start_param: str | None = None,
14
14
  ) -> InitData:
15
- """Получает начальные данные для бота в контексте конкретного чата.
15
+ """Получает начальные данные для запуска бота.
16
16
 
17
17
  Args:
18
18
  bot_id: Идентификатор бота.
19
- chat_id: Идентификатор чата, в котором бот будет использоваться.
19
+ chat_id: Необязательный ID чата, в котором бот будет использоваться.
20
20
  start_param: Необязательный параметр, передаваемый при запуске
21
21
  бота.
22
22
 
pymax/infra/chat.py CHANGED
@@ -1,3 +1,5 @@
1
+ from pymax.api.chats import ChannelPermissions
2
+ from pymax.files import Photo
1
3
  from pymax.types import Chat, Member, Message
2
4
 
3
5
  from .protocol import IClientProtocol
@@ -130,20 +132,23 @@ class ChatMixin(IClientProtocol):
130
132
  async def change_group_profile(
131
133
  self,
132
134
  chat_id: int,
133
- name: str | None,
135
+ name: str | None = None,
134
136
  description: str | None = None,
137
+ photo: Photo | None = None,
135
138
  ) -> None:
136
- """Обновляет название и описание группы.
139
+ """Обновляет название, описание или фотографию группы.
140
+
141
+ ``None`` исключает соответствующее поле из запроса. Фотография
142
+ автоматически загружается перед обновлением профиля.
137
143
 
138
144
  Args:
139
145
  chat_id: ID группы.
140
146
  name: Новое название.
141
147
  description: Новое описание.
148
+ photo: Новая фотография группы.
142
149
  """
143
150
  await self._app.api.chats.change_group_profile(
144
- chat_id=chat_id,
145
- name=name,
146
- description=description,
151
+ chat_id=chat_id, name=name, description=description, photo=photo
147
152
  )
148
153
 
149
154
  async def join_group(self, link: str) -> Chat:
@@ -211,6 +216,28 @@ class ChatMixin(IClientProtocol):
211
216
  """
212
217
  return await self._app.api.chats.get_chat(chat_id)
213
218
 
219
+ async def get_chat_members(
220
+ self,
221
+ chat_id: int,
222
+ marker: int | None = None,
223
+ count: int = 50,
224
+ ) -> tuple[list[Member], int]:
225
+ """Возвращает страницу участников чата по ID.
226
+
227
+ Args:
228
+ chat_id: ID чата.
229
+ marker: Маркер страницы. Если ``None``, запрашивается первая
230
+ страница.
231
+ count: Максимальное количество участников в ответе.
232
+
233
+ Returns:
234
+ ``(members, next_marker)``. Если участников больше, чем
235
+ уместилось в ответ, ``next_marker`` ненулевой — передайте его в
236
+ следующий вызов, чтобы получить следующую страницу. ``0``
237
+ означает, что дальше страниц нет.
238
+ """
239
+ return await self._app.api.chats.get_chat_members(chat_id, marker, count)
240
+
214
241
  async def leave_group(self, chat_id: int) -> None:
215
242
  """Выходит из группы.
216
243
 
@@ -372,3 +399,18 @@ class ChatMixin(IClientProtocol):
372
399
  Канал, в который вступил клиент.
373
400
  """
374
401
  return await self._app.api.chats.join_channel(link=link)
402
+
403
+ async def add_admin(
404
+ self,
405
+ chat_id: int,
406
+ user_id: int,
407
+ permissions: list[ChannelPermissions],
408
+ ) -> None:
409
+ """Назначает пользователя администратором канала.
410
+
411
+ Args:
412
+ chat_id: ID канала.
413
+ user_id: ID пользователя.
414
+ permissions: Непустой список прав администратора.
415
+ """
416
+ return await self._app.api.chats.add_admin(chat_id, user_id, permissions)
pymax/infra/message.py CHANGED
@@ -1,8 +1,10 @@
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 (
4
5
  FileRequest,
5
6
  Message,
7
+ PollState,
6
8
  ReactionInfo,
7
9
  ReadState,
8
10
  VideoRequest,
@@ -17,23 +19,30 @@ class MessageMixin(IClientProtocol):
17
19
  async def send_message(
18
20
  self,
19
21
  chat_id: int,
20
- text: str,
22
+ text: str | None = None,
21
23
  reply_to: int | None = None,
22
24
  attachments: SendAttachments = None,
23
25
  *,
24
26
  notify: bool = True,
25
- ) -> Message | None:
27
+ send_at: DateTimeUnion | None = None,
28
+ ) -> Message:
26
29
  """Отправляет сообщение в чат.
27
30
 
28
31
  Args:
29
32
  chat_id: ID чата.
30
- text: Текст сообщения.
33
+ text: Текст сообщения. Можно не передавать при наличии вложений.
31
34
  reply_to: ID сообщения для ответа.
32
- attachments: Файлы, фотографии или видео для отправки.
35
+ attachments: Файлы, фотографии, видео или опросы для отправки.
33
36
  notify: Отправить ли получателям push-уведомление.
37
+ send_at: Время отложенной отправки: ``datetime`` для абсолютного
38
+ времени, ``timedelta`` для задержки или Unix time в секундах
39
+ как ``int``. ``None`` и ``0`` отправляют сообщение сразу.
34
40
 
35
41
  Returns:
36
- Отправленное сообщение или ``None``, если сервер не вернул его.
42
+ Отправленное сообщение.
43
+
44
+ Raises:
45
+ ValueError: Если не переданы ни текст, ни вложения.
37
46
  """
38
47
  return await self._app.api.messages.send_message(
39
48
  chat_id,
@@ -41,6 +50,7 @@ class MessageMixin(IClientProtocol):
41
50
  reply_to,
42
51
  attachments,
43
52
  notify=notify,
53
+ send_at=send_at,
44
54
  )
45
55
 
46
56
  async def get_message(
@@ -69,7 +79,7 @@ class MessageMixin(IClientProtocol):
69
79
  source_chat_id: int | None = None,
70
80
  *,
71
81
  notify: bool = True,
72
- ) -> Message | None:
82
+ ) -> Message:
73
83
  """Пересылает существующее сообщение в чат.
74
84
 
75
85
  Args:
@@ -80,7 +90,7 @@ class MessageMixin(IClientProtocol):
80
90
  notify: Отправить ли получателям push-уведомление.
81
91
 
82
92
  Returns:
83
- Пересланное сообщение или ``None``, если сервер не вернул его.
93
+ Пересланное сообщение.
84
94
  """
85
95
  return await self._app.api.messages.forward_message(
86
96
  chat_id=chat_id,
@@ -112,7 +122,7 @@ class MessageMixin(IClientProtocol):
112
122
  self,
113
123
  chat_id: int,
114
124
  message_id: int,
115
- text: str,
125
+ text: str | None = None,
116
126
  attachments: SendAttachments = None,
117
127
  ) -> Message:
118
128
  """Редактирует текст и вложения сообщения.
@@ -121,7 +131,7 @@ class MessageMixin(IClientProtocol):
121
131
  chat_id: ID чата.
122
132
  message_id: ID сообщения.
123
133
  text: Новый текст сообщения с поддержкой markdown.
124
- attachments: Новые файлы, фотографии или видео для сообщения.
134
+ attachments: Новые файлы, фотографии, видео или опросы для сообщения.
125
135
 
126
136
  Returns:
127
137
  Отредактированное сообщение.
@@ -145,7 +155,7 @@ class MessageMixin(IClientProtocol):
145
155
  get_chat: bool = False,
146
156
  get_messages: bool = True,
147
157
  interactive: bool = False,
148
- ) -> list[Message] | None:
158
+ ) -> list[Message]:
149
159
  """Загружает историю сообщений чата.
150
160
 
151
161
  Args:
@@ -162,7 +172,7 @@ class MessageMixin(IClientProtocol):
162
172
  interactive: Пометить запрос как интерактивный.
163
173
 
164
174
  Returns:
165
- Сообщения или ``None``, если сервер не вернул список.
175
+ Список сообщений. Если сервер не вернул сообщения, список пуст.
166
176
  """
167
177
  return await self._app.api.messages.fetch_history(
168
178
  chat_id=chat_id,
@@ -268,7 +278,7 @@ class MessageMixin(IClientProtocol):
268
278
  async def add_reaction(
269
279
  self,
270
280
  chat_id: int,
271
- message_id: str,
281
+ message_id: int,
272
282
  reaction: str,
273
283
  ) -> ReactionInfo | None:
274
284
  """Добавляет реакцию к сообщению.
@@ -290,7 +300,7 @@ class MessageMixin(IClientProtocol):
290
300
  async def get_reactions(
291
301
  self,
292
302
  chat_id: int,
293
- message_ids: list[str],
303
+ message_ids: list[int],
294
304
  ) -> dict[str, ReactionInfo] | None:
295
305
  """Возвращает реакции для нескольких сообщений.
296
306
 
@@ -310,7 +320,7 @@ class MessageMixin(IClientProtocol):
310
320
  async def remove_reaction(
311
321
  self,
312
322
  chat_id: int,
313
- message_id: str,
323
+ message_id: int,
314
324
  ) -> ReactionInfo | None:
315
325
  """Удаляет свою реакцию с сообщения.
316
326
 
@@ -344,3 +354,28 @@ class MessageMixin(IClientProtocol):
344
354
  message_id=message_id,
345
355
  chat_id=chat_id,
346
356
  )
357
+
358
+ async def vote_poll(
359
+ self,
360
+ chat_id: int,
361
+ message_id: int,
362
+ poll_id: int,
363
+ answer_ids: list[int],
364
+ ) -> PollState:
365
+ """Отправляет выбранные ответы в опросе.
366
+
367
+ Args:
368
+ chat_id: ID чата с опросом.
369
+ message_id: ID сообщения с опросом.
370
+ poll_id: ID опроса из ``PollAttachment.poll_id``.
371
+ answer_ids: ID выбранных вариантов ответа.
372
+
373
+ Returns:
374
+ Обновленное состояние опроса.
375
+ """
376
+ return await self._app.api.messages.vote_poll(
377
+ chat_id=chat_id,
378
+ message_id=message_id,
379
+ poll_id=poll_id,
380
+ answer_ids=answer_ids,
381
+ )
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/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