maxapi-python 2.3.1__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 (59) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +3 -1
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +59 -51
  3. {maxapi_python-2.3.1.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 +12 -4
  14. pymax/api/messages/service.py +57 -14
  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 +40 -11
  40. pymax/infra/self.py +25 -1
  41. pymax/protocol/enums.py +13 -0
  42. pymax/protocol/tcp/payload.py +21 -1
  43. pymax/transport/tcp.py +6 -1
  44. pymax/transport/websocket.py +6 -3
  45. pymax/types/domain/__init__.py +2 -1
  46. pymax/types/domain/attachments/__init__.py +2 -1
  47. pymax/types/domain/attachments/enums.py +17 -1
  48. pymax/types/domain/attachments/poll.py +111 -0
  49. pymax/types/domain/attachments/sticker.py +1 -1
  50. pymax/types/domain/attachments/video.py +29 -8
  51. pymax/types/domain/chat.py +9 -9
  52. pymax/types/domain/handshake.py +16 -0
  53. pymax/types/domain/login.py +30 -1
  54. pymax/types/domain/message.py +21 -20
  55. pymax/types/domain/user.py +1 -3
  56. pymax/types/events/__init__.py +1 -0
  57. pymax/types/events/reaction.py +2 -2
  58. pymax/types/events/voice.py +5 -0
  59. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/licenses/LICENSE +0 -0
pymax/config.py CHANGED
@@ -1,4 +1,4 @@
1
- from random import choice, randint
1
+ from random import choice, randint, random
2
2
  from uuid import uuid4
3
3
 
4
4
  from pydantic import BaseModel, ConfigDict, Field
@@ -11,17 +11,48 @@ from pymax.api.session.payloads import (
11
11
  from pymax.session import StoreProtocol
12
12
  from pymax.types.domain.sync import SyncOverrides
13
13
 
14
+ MIN_PREFERRED_BUILD = 6712
14
15
  APP_VERSIONS: tuple[tuple[str, int], ...] = (
16
+ ("26.25.0", 6790),
17
+ ("26.24.0", 6784),
18
+ ("26.23.2", 6779),
19
+ ("26.23.1", 6778),
20
+ ("26.23.0", 6777),
21
+ ("26.22.2", 6773),
22
+ ("26.22.1", 6772),
23
+ ("26.22.0", 6770),
24
+ ("26.21.1", 6763),
25
+ ("26.20.2", 6758),
26
+ ("26.20.1", 6740),
27
+ ("26.19.3", 6734),
28
+ ("26.19.2", 6732),
29
+ ("26.19.1", 6729),
30
+ ("26.19.0", 6727),
31
+ ("26.18.4", 6724),
32
+ ("26.18.2", 6720),
33
+ ("26.18.1", 6716),
34
+ ("26.18.0", 6715),
35
+ ("26.17.1", 6712),
36
+ ("26.16.4", 6704),
37
+ ("26.16.3", 6702),
38
+ ("26.16.2", 6701),
39
+ ("26.16.1", 6700),
40
+ ("26.16.0", 6698),
41
+ ("26.15.3", 6695),
42
+ ("26.15.1", 6690),
43
+ ("26.15.0", 6689),
15
44
  ("26.14.1", 6686),
16
45
  ("26.14.0", 6685),
17
46
  ("26.13.0", 6683),
18
47
  ("26.12.2", 6681),
19
48
  ("26.12.1", 6679),
20
- ("26.12.0", 6678),
21
- ("26.11.3", 6680),
49
+ ("26.12.0", 6676),
50
+ ("26.11.3", 6670),
22
51
  ("26.11.2", 6669),
23
52
  ("26.11.1", 6665),
24
- ("26.11.0", 6661),
53
+ ("26.10.1", 6653),
54
+ ("26.10.0", 6648),
55
+ ("26.9.1", 6643),
25
56
  )
26
57
  ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
27
58
  ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
@@ -73,9 +104,12 @@ LOCALE_TIMEZONES: tuple[tuple[str, str], ...] = (
73
104
  ("ru", "Asia/Yakutsk"),
74
105
  ("ru", "Asia/Vladivostok"),
75
106
  )
76
- WEB_APP_VERSION = "26.5.5"
107
+ WEB_APP_VERSION = "26.7.15"
77
108
  WEB_SCREEN = "1080x1920 1.0x"
78
109
 
110
+ PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD]
111
+ LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD]
112
+
79
113
 
80
114
  class DeviceConfig(BaseModel):
81
115
  mt_instance_id: str
@@ -119,6 +153,8 @@ class ClientConfig(BaseModel):
119
153
  log_level: str = "INFO"
120
154
  telemetry: bool = False
121
155
 
156
+ interactive: bool = True
157
+
122
158
  store: StoreProtocol | None = None
123
159
 
124
160
  sync: SyncOverrides = Field(default_factory=SyncOverrides)
@@ -176,7 +212,7 @@ class ExtraConfig(BaseModel):
176
212
 
177
213
  host: str = "api.oneme.ru"
178
214
  port: int = 443
179
- url: str = "wss://ws-api.oneme.ru/websocket"
215
+ url: str = "wss://api.oneme.ru/websocket"
180
216
  use_ssl: bool = True
181
217
  proxy: str | None = None
182
218
  reconnect: bool = True
@@ -201,7 +237,8 @@ class ExtraConfig(BaseModel):
201
237
  Returns:
202
238
  Случайная, но правдоподобная конфигурация Android-клиента Max.
203
239
  """
204
- app_version, build_number = choice(APP_VERSIONS)
240
+ versions = PREFERRED_VERSION if random() < 0.9 else LEGACY_VERSIONS
241
+ app_version, build_number = choice(versions)
205
242
  device_name, os_version, screen, arch = choice(ANDROID_DEVICES)
206
243
  locale, timezone = choice(LOCALE_TIMEZONES)
207
244
 
@@ -37,6 +37,8 @@ class ConnectionManager:
37
37
 
38
38
  self._recv_task: asyncio.Task[None] | None = None
39
39
  self._event_tasks: set[asyncio.Task[None]] = set()
40
+ self._closed_event = asyncio.Event()
41
+ self._closed_event.set()
40
42
 
41
43
  async def open(self) -> None:
42
44
  if self._is_open:
@@ -48,6 +50,7 @@ class ConnectionManager:
48
50
  self._is_open = True
49
51
  self._connection_lost = False
50
52
  self._close_reported = False
53
+ self._closed_event.clear()
51
54
 
52
55
  self._recv_task = asyncio.create_task(self._recv_loop())
53
56
  logger.debug("receive loop started")
@@ -58,25 +61,28 @@ class ConnectionManager:
58
61
  return
59
62
 
60
63
  logger.info("closing connection")
61
- if self._recv_task:
62
- if not self._recv_task.done():
63
- self._recv_task.cancel()
64
- with suppress(asyncio.CancelledError, Exception):
65
- await self._recv_task
66
- logger.debug("receive loop stopped")
67
- self._recv_task = None
68
-
69
- for task in tuple(self._event_tasks):
70
- if not task.done():
71
- task.cancel()
72
- for task in tuple(self._event_tasks):
73
- with suppress(asyncio.CancelledError, Exception):
74
- await task
75
- self._event_tasks.clear()
76
-
77
- self.requests.cancel_all()
78
- await self.transport.close()
79
64
  self._is_open = False
65
+ try:
66
+ if self._recv_task:
67
+ if not self._recv_task.done():
68
+ self._recv_task.cancel()
69
+ with suppress(asyncio.CancelledError, Exception):
70
+ await self._recv_task
71
+ logger.debug("receive loop stopped")
72
+ self._recv_task = None
73
+
74
+ for task in tuple(self._event_tasks):
75
+ if not task.done():
76
+ task.cancel()
77
+ for task in tuple(self._event_tasks):
78
+ with suppress(asyncio.CancelledError, Exception):
79
+ await task
80
+ self._event_tasks.clear()
81
+
82
+ self.requests.cancel_all()
83
+ await self.transport.close()
84
+ finally:
85
+ self._closed_event.set()
80
86
  logger.info("connection closed")
81
87
 
82
88
  async def fail(self, exc: Exception | None = None) -> None:
@@ -147,6 +153,11 @@ class ConnectionManager:
147
153
 
148
154
  try:
149
155
  await self._recv_task
156
+ except asyncio.CancelledError:
157
+ if self._is_open:
158
+ raise
159
+ await self._closed_event.wait()
160
+ return
150
161
  except Exception as e:
151
162
  if self._connection_lost:
152
163
  raise ConnectionError("Connection lost") from e
pymax/dispatch/enums.py CHANGED
@@ -13,5 +13,6 @@ class EventType(str, Enum):
13
13
  USER_UPDATE = "user_update"
14
14
  VIDEO_READY = "video_ready"
15
15
  FILE_READY = "file_ready"
16
+ VOICE_READY = "voice_ready"
16
17
  RAW = "raw"
17
18
  ON_START = "on_start"
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/video.py CHANGED
@@ -1,8 +1,14 @@
1
1
  from collections.abc import AsyncGenerator
2
+ from io import BytesIO
2
3
  from pathlib import Path
3
4
 
4
5
  from .base import BaseFile
5
6
 
7
+ try:
8
+ from tinytag import TinyTag
9
+ except ImportError:
10
+ TinyTag = None
11
+
6
12
 
7
13
  class Video(BaseFile):
8
14
  """Видео для отправки в сообщение.
@@ -72,3 +78,55 @@ class Video(BaseFile):
72
78
  Async generator с байтами.
73
79
  """
74
80
  return super().iter_chunks(size)
81
+
82
+
83
+ class VideoNote(Video):
84
+ """Круглое видеосообщение для отправки.
85
+
86
+ Принимает те же источники, что и ``Video``. Длительность задается в
87
+ миллисекундах. Если она не передана, требуется extra ``video`` для
88
+ автоматического определения длительности.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ raw: bytes | None = None,
94
+ *,
95
+ url: str | None = None,
96
+ path: str | None = None,
97
+ name: str | None = None,
98
+ duration: int | None = None,
99
+ ) -> None:
100
+ self.duration = duration
101
+ super().__init__(raw, url=url, path=path, name=name)
102
+
103
+ async def get_duration(self) -> int:
104
+ if self.duration is not None:
105
+ return self.duration
106
+
107
+ if not TinyTag:
108
+ raise RuntimeError(
109
+ "Automatic video duration detection requires the 'video' extra. "
110
+ "Install it with `uv add 'maxapi-python[video]'` "
111
+ "or pass duration manually."
112
+ )
113
+
114
+ if self.raw:
115
+ tag = TinyTag.get(
116
+ filename=self.name,
117
+ file_obj=BytesIO(self.raw),
118
+ tags=False,
119
+ duration=True,
120
+ )
121
+ else:
122
+ tag = TinyTag.get(
123
+ filename=self.name,
124
+ file_obj=BytesIO(await self.read()),
125
+ tags=False,
126
+ duration=True,
127
+ )
128
+
129
+ if tag.duration is None:
130
+ raise ValueError("Failed to determine video duration.")
131
+
132
+ return round(tag.duration * 1000)
pymax/files/voice.py ADDED
@@ -0,0 +1,39 @@
1
+ from collections.abc import AsyncGenerator
2
+ from pathlib import Path
3
+
4
+ from .base import BaseFile
5
+
6
+
7
+ class Voice(BaseFile):
8
+ """Голосовое сообщение в формате OGG для отправки.
9
+
10
+ Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно
11
+ передать имя файла.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ raw: bytes | None = None,
17
+ *,
18
+ path: str | None = None,
19
+ url: str | None = None,
20
+ name: str | None = None,
21
+ ) -> None:
22
+ self.name: str = name or ""
23
+ if not self.name and path:
24
+ self.name = Path(path).name
25
+ elif not self.name and url:
26
+ self.name = Path(url).name
27
+
28
+ if not self.name:
29
+ raise ValueError("Either name, url or path must be provided.")
30
+ super().__init__(raw=raw, url=url, path=path, name=self.name)
31
+
32
+ async def read(self) -> bytes:
33
+ return await super().read()
34
+
35
+ async def size(self) -> int:
36
+ return await super().size()
37
+
38
+ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]:
39
+ return super().iter_chunks(size)
@@ -0,0 +1 @@
1
+ from .fingerprint import FingerprintGenerator
@@ -0,0 +1,47 @@
1
+ import hashlib
2
+ import json
3
+ import struct
4
+ from importlib import resources
5
+ from typing import Any
6
+
7
+ from .models import ApkBuildFingerprint
8
+
9
+
10
+ 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)
20
+
21
+ def generate_fingerprint(
22
+ self,
23
+ version: str,
24
+ device_id: str,
25
+ calls_seed: int,
26
+ arch: str = "arm64-v8a",
27
+ ) -> bytes | None:
28
+ data = self.data.get(version)
29
+ if not data:
30
+ return None
31
+
32
+ model = ApkBuildFingerprint.model_validate(data)
33
+
34
+ seed_bytes = struct.pack(">q", calls_seed)
35
+ device_bytes = device_id.encode("utf-8")
36
+
37
+ h1 = hashlib.sha256(
38
+ bytes.fromhex(model.certificate_meta_sha256) + seed_bytes + device_bytes
39
+ ).digest()
40
+ h2 = hashlib.sha256(
41
+ bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes
42
+ ).digest()
43
+ h3 = hashlib.sha256(
44
+ bytes.fromhex(model.so_meta_sha256[arch]) + seed_bytes + device_bytes
45
+ ).digest()
46
+
47
+ return h1 + h2 + h3
@@ -0,0 +1,11 @@
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: dict[str, str]
11
+ 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,4 @@
1
+ from pymax.api.chats import ChannelPermissions
1
2
  from pymax.types import Chat, Member, Message
2
3
 
3
4
  from .protocol import IClientProtocol
@@ -211,6 +212,28 @@ class ChatMixin(IClientProtocol):
211
212
  """
212
213
  return await self._app.api.chats.get_chat(chat_id)
213
214
 
215
+ async def get_chat_members(
216
+ self,
217
+ chat_id: int,
218
+ marker: int | None = None,
219
+ count: int = 50,
220
+ ) -> tuple[list[Member], int]:
221
+ """Возвращает страницу участников чата по ID.
222
+
223
+ Args:
224
+ chat_id: ID чата.
225
+ marker: Маркер страницы. Если ``None``, запрашивается первая
226
+ страница.
227
+ count: Максимальное количество участников в ответе.
228
+
229
+ Returns:
230
+ ``(members, next_marker)``. Если участников больше, чем
231
+ уместилось в ответ, ``next_marker`` ненулевой — передайте его в
232
+ следующий вызов, чтобы получить следующую страницу. ``0``
233
+ означает, что дальше страниц нет.
234
+ """
235
+ return await self._app.api.chats.get_chat_members(chat_id, marker, count)
236
+
214
237
  async def leave_group(self, chat_id: int) -> None:
215
238
  """Выходит из группы.
216
239
 
@@ -372,3 +395,18 @@ class ChatMixin(IClientProtocol):
372
395
  Канал, в который вступил клиент.
373
396
  """
374
397
  return await self._app.api.chats.join_channel(link=link)
398
+
399
+ async def add_admin(
400
+ self,
401
+ chat_id: int,
402
+ user_id: int,
403
+ permissions: list[ChannelPermissions],
404
+ ) -> None:
405
+ """Назначает пользователя администратором канала.
406
+
407
+ Args:
408
+ chat_id: ID канала.
409
+ user_id: ID пользователя.
410
+ permissions: Непустой список прав администратора.
411
+ """
412
+ return await self._app.api.chats.add_admin(chat_id, user_id, permissions)
pymax/infra/message.py CHANGED
@@ -3,6 +3,7 @@ from pymax.api.messages.service import SendAttachments
3
3
  from pymax.types import (
4
4
  FileRequest,
5
5
  Message,
6
+ PollState,
6
7
  ReactionInfo,
7
8
  ReadState,
8
9
  VideoRequest,
@@ -17,23 +18,26 @@ class MessageMixin(IClientProtocol):
17
18
  async def send_message(
18
19
  self,
19
20
  chat_id: int,
20
- text: str,
21
+ text: str | None = None,
21
22
  reply_to: int | None = None,
22
23
  attachments: SendAttachments = None,
23
24
  *,
24
25
  notify: bool = True,
25
- ) -> Message | None:
26
+ ) -> Message:
26
27
  """Отправляет сообщение в чат.
27
28
 
28
29
  Args:
29
30
  chat_id: ID чата.
30
- text: Текст сообщения.
31
+ text: Текст сообщения. Можно не передавать при наличии вложений.
31
32
  reply_to: ID сообщения для ответа.
32
- attachments: Файлы, фотографии или видео для отправки.
33
+ attachments: Файлы, фотографии, видео или опросы для отправки.
33
34
  notify: Отправить ли получателям push-уведомление.
34
35
 
35
36
  Returns:
36
- Отправленное сообщение или ``None``, если сервер не вернул его.
37
+ Отправленное сообщение.
38
+
39
+ Raises:
40
+ ValueError: Если не переданы ни текст, ни вложения.
37
41
  """
38
42
  return await self._app.api.messages.send_message(
39
43
  chat_id,
@@ -69,7 +73,7 @@ class MessageMixin(IClientProtocol):
69
73
  source_chat_id: int | None = None,
70
74
  *,
71
75
  notify: bool = True,
72
- ) -> Message | None:
76
+ ) -> Message:
73
77
  """Пересылает существующее сообщение в чат.
74
78
 
75
79
  Args:
@@ -80,7 +84,7 @@ class MessageMixin(IClientProtocol):
80
84
  notify: Отправить ли получателям push-уведомление.
81
85
 
82
86
  Returns:
83
- Пересланное сообщение или ``None``, если сервер не вернул его.
87
+ Пересланное сообщение.
84
88
  """
85
89
  return await self._app.api.messages.forward_message(
86
90
  chat_id=chat_id,
@@ -112,7 +116,7 @@ class MessageMixin(IClientProtocol):
112
116
  self,
113
117
  chat_id: int,
114
118
  message_id: int,
115
- text: str,
119
+ text: str | None = None,
116
120
  attachments: SendAttachments = None,
117
121
  ) -> Message:
118
122
  """Редактирует текст и вложения сообщения.
@@ -121,7 +125,7 @@ class MessageMixin(IClientProtocol):
121
125
  chat_id: ID чата.
122
126
  message_id: ID сообщения.
123
127
  text: Новый текст сообщения с поддержкой markdown.
124
- attachments: Новые файлы, фотографии или видео для сообщения.
128
+ attachments: Новые файлы, фотографии, видео или опросы для сообщения.
125
129
 
126
130
  Returns:
127
131
  Отредактированное сообщение.
@@ -145,7 +149,7 @@ class MessageMixin(IClientProtocol):
145
149
  get_chat: bool = False,
146
150
  get_messages: bool = True,
147
151
  interactive: bool = False,
148
- ) -> list[Message] | None:
152
+ ) -> list[Message]:
149
153
  """Загружает историю сообщений чата.
150
154
 
151
155
  Args:
@@ -162,7 +166,7 @@ class MessageMixin(IClientProtocol):
162
166
  interactive: Пометить запрос как интерактивный.
163
167
 
164
168
  Returns:
165
- Сообщения или ``None``, если сервер не вернул список.
169
+ Список сообщений. Если сервер не вернул сообщения, список пуст.
166
170
  """
167
171
  return await self._app.api.messages.fetch_history(
168
172
  chat_id=chat_id,
@@ -344,3 +348,28 @@ class MessageMixin(IClientProtocol):
344
348
  message_id=message_id,
345
349
  chat_id=chat_id,
346
350
  )
351
+
352
+ async def vote_poll(
353
+ self,
354
+ chat_id: int,
355
+ message_id: int,
356
+ poll_id: int,
357
+ answer_ids: list[int],
358
+ ) -> PollState:
359
+ """Отправляет выбранные ответы в опросе.
360
+
361
+ Args:
362
+ chat_id: ID чата с опросом.
363
+ message_id: ID сообщения с опросом.
364
+ poll_id: ID опроса из ``PollAttachment.poll_id``.
365
+ answer_ids: ID выбранных вариантов ответа.
366
+
367
+ Returns:
368
+ Обновленное состояние опроса.
369
+ """
370
+ return await self._app.api.messages.vote_poll(
371
+ chat_id=chat_id,
372
+ message_id=message_id,
373
+ poll_id=poll_id,
374
+ answer_ids=answer_ids,
375
+ )
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