maxapi-python 2.3.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +4 -1
  2. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +61 -53
  3. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +7 -2
  5. pymax/_data/apk_fingerprints.json +682 -0
  6. pymax/api/auth/enums.py +1 -0
  7. pymax/api/auth/payloads.py +28 -2
  8. pymax/api/auth/service.py +88 -3
  9. pymax/api/chats/__init__.py +1 -0
  10. pymax/api/chats/enums.py +15 -0
  11. pymax/api/chats/payloads.py +16 -1
  12. pymax/api/chats/service.py +36 -1
  13. pymax/api/messages/payloads.py +33 -5
  14. pymax/api/messages/service.py +95 -13
  15. pymax/api/self/__init__.py +2 -1
  16. pymax/api/self/enums.py +9 -0
  17. pymax/api/self/payloads.py +40 -1
  18. pymax/api/self/service.py +37 -0
  19. pymax/api/session/service.py +21 -8
  20. pymax/api/uploads/payloads.py +6 -0
  21. pymax/api/uploads/service.py +198 -21
  22. pymax/app.py +69 -15
  23. pymax/base.py +8 -0
  24. pymax/client_web.py +2 -2
  25. pymax/config.py +44 -7
  26. pymax/connection/connection.py +29 -18
  27. pymax/dispatch/enums.py +1 -0
  28. pymax/dispatch/mapping.py +3 -1
  29. pymax/dispatch/resolvers.py +7 -1
  30. pymax/files/__init__.py +4 -1
  31. pymax/files/video.py +58 -0
  32. pymax/files/voice.py +39 -0
  33. pymax/fingerprint/__init__.py +1 -0
  34. pymax/fingerprint/fingerprint.py +47 -0
  35. pymax/fingerprint/models.py +11 -0
  36. pymax/infra/auth.py +4 -0
  37. pymax/infra/bots.py +3 -3
  38. pymax/infra/chat.py +38 -0
  39. pymax/infra/message.py +65 -9
  40. pymax/infra/self.py +25 -1
  41. pymax/protocol/enums.py +13 -0
  42. pymax/protocol/tcp/compression.py +18 -0
  43. pymax/protocol/tcp/payload.py +41 -5
  44. pymax/protocol/tcp/protocol.py +5 -1
  45. pymax/transport/tcp.py +6 -1
  46. pymax/transport/websocket.py +6 -3
  47. pymax/types/domain/__init__.py +2 -1
  48. pymax/types/domain/attachments/__init__.py +2 -1
  49. pymax/types/domain/attachments/enums.py +17 -1
  50. pymax/types/domain/attachments/poll.py +111 -0
  51. pymax/types/domain/attachments/sticker.py +1 -1
  52. pymax/types/domain/attachments/video.py +29 -8
  53. pymax/types/domain/chat.py +9 -9
  54. pymax/types/domain/handshake.py +16 -0
  55. pymax/types/domain/login.py +30 -1
  56. pymax/types/domain/message.py +47 -19
  57. pymax/types/domain/user.py +5 -5
  58. pymax/types/events/__init__.py +1 -0
  59. pymax/types/events/reaction.py +2 -2
  60. pymax/types/events/voice.py +5 -0
  61. {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/licenses/LICENSE +0 -0
pymax/client_web.py CHANGED
@@ -6,7 +6,7 @@ from pymax.auth.qr import QrAuthFlow
6
6
  from pymax.connection import ConnectionManager
7
7
  from pymax.connection.readers import WSReader
8
8
  from pymax.logging import configure_logging, get_logger
9
- from pymax.protocol.ws import WsProtocol
9
+ from pymax.protocol.tcp import TcpProtocol
10
10
  from pymax.transport.websocket import WebSocketTransport
11
11
 
12
12
  from .base import BaseClient
@@ -80,5 +80,5 @@ class WebClient(BaseClient["WebClient"]):
80
80
  return ConnectionManager(
81
81
  reader=reader,
82
82
  transport=transport,
83
- protocol=WsProtocol(),
83
+ protocol=TcpProtocol(),
84
84
  )
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,
@@ -62,6 +66,33 @@ class MessageMixin(IClientProtocol):
62
66
  message_id=message_id,
63
67
  )
64
68
 
69
+ async def forward_message(
70
+ self,
71
+ chat_id: int,
72
+ message_id: int | str,
73
+ source_chat_id: int | None = None,
74
+ *,
75
+ notify: bool = True,
76
+ ) -> Message:
77
+ """Пересылает существующее сообщение в чат.
78
+
79
+ Args:
80
+ chat_id: ID целевого чата.
81
+ message_id: ID пересылаемого сообщения.
82
+ source_chat_id: ID исходного чата. Если не указан, используется
83
+ целевой чат.
84
+ notify: Отправить ли получателям push-уведомление.
85
+
86
+ Returns:
87
+ Пересланное сообщение.
88
+ """
89
+ return await self._app.api.messages.forward_message(
90
+ chat_id=chat_id,
91
+ message_id=message_id,
92
+ source_chat_id=source_chat_id,
93
+ notify=notify,
94
+ )
95
+
65
96
  async def get_messages(
66
97
  self,
67
98
  chat_id: int,
@@ -85,7 +116,7 @@ class MessageMixin(IClientProtocol):
85
116
  self,
86
117
  chat_id: int,
87
118
  message_id: int,
88
- text: str,
119
+ text: str | None = None,
89
120
  attachments: SendAttachments = None,
90
121
  ) -> Message:
91
122
  """Редактирует текст и вложения сообщения.
@@ -94,7 +125,7 @@ class MessageMixin(IClientProtocol):
94
125
  chat_id: ID чата.
95
126
  message_id: ID сообщения.
96
127
  text: Новый текст сообщения с поддержкой markdown.
97
- attachments: Новые файлы, фотографии или видео для сообщения.
128
+ attachments: Новые файлы, фотографии, видео или опросы для сообщения.
98
129
 
99
130
  Returns:
100
131
  Отредактированное сообщение.
@@ -118,7 +149,7 @@ class MessageMixin(IClientProtocol):
118
149
  get_chat: bool = False,
119
150
  get_messages: bool = True,
120
151
  interactive: bool = False,
121
- ) -> list[Message] | None:
152
+ ) -> list[Message]:
122
153
  """Загружает историю сообщений чата.
123
154
 
124
155
  Args:
@@ -135,7 +166,7 @@ class MessageMixin(IClientProtocol):
135
166
  interactive: Пометить запрос как интерактивный.
136
167
 
137
168
  Returns:
138
- Сообщения или ``None``, если сервер не вернул список.
169
+ Список сообщений. Если сервер не вернул сообщения, список пуст.
139
170
  """
140
171
  return await self._app.api.messages.fetch_history(
141
172
  chat_id=chat_id,
@@ -317,3 +348,28 @@ class MessageMixin(IClientProtocol):
317
348
  message_id=message_id,
318
349
  chat_id=chat_id,
319
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
+ )