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.
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +4 -1
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +61 -53
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +7 -2
- pymax/_data/apk_fingerprints.json +682 -0
- pymax/api/auth/enums.py +1 -0
- pymax/api/auth/payloads.py +28 -2
- pymax/api/auth/service.py +88 -3
- pymax/api/chats/__init__.py +1 -0
- pymax/api/chats/enums.py +15 -0
- pymax/api/chats/payloads.py +16 -1
- pymax/api/chats/service.py +36 -1
- pymax/api/messages/payloads.py +33 -5
- pymax/api/messages/service.py +95 -13
- pymax/api/self/__init__.py +2 -1
- pymax/api/self/enums.py +9 -0
- pymax/api/self/payloads.py +40 -1
- pymax/api/self/service.py +37 -0
- pymax/api/session/service.py +21 -8
- pymax/api/uploads/payloads.py +6 -0
- pymax/api/uploads/service.py +198 -21
- pymax/app.py +69 -15
- pymax/base.py +8 -0
- pymax/client_web.py +2 -2
- pymax/config.py +44 -7
- pymax/connection/connection.py +29 -18
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +3 -1
- pymax/dispatch/resolvers.py +7 -1
- pymax/files/__init__.py +4 -1
- pymax/files/video.py +58 -0
- pymax/files/voice.py +39 -0
- pymax/fingerprint/__init__.py +1 -0
- pymax/fingerprint/fingerprint.py +47 -0
- pymax/fingerprint/models.py +11 -0
- pymax/infra/auth.py +4 -0
- pymax/infra/bots.py +3 -3
- pymax/infra/chat.py +38 -0
- pymax/infra/message.py +65 -9
- pymax/infra/self.py +25 -1
- pymax/protocol/enums.py +13 -0
- pymax/protocol/tcp/compression.py +18 -0
- pymax/protocol/tcp/payload.py +41 -5
- pymax/protocol/tcp/protocol.py +5 -1
- pymax/transport/tcp.py +6 -1
- pymax/transport/websocket.py +6 -3
- pymax/types/domain/__init__.py +2 -1
- pymax/types/domain/attachments/__init__.py +2 -1
- pymax/types/domain/attachments/enums.py +17 -1
- pymax/types/domain/attachments/poll.py +111 -0
- pymax/types/domain/attachments/sticker.py +1 -1
- pymax/types/domain/attachments/video.py +29 -8
- pymax/types/domain/chat.py +9 -9
- pymax/types/domain/handshake.py +16 -0
- pymax/types/domain/login.py +30 -1
- pymax/types/domain/message.py +47 -19
- pymax/types/domain/user.py +5 -5
- pymax/types/events/__init__.py +1 -0
- pymax/types/events/reaction.py +2 -2
- pymax/types/events/voice.py +5 -0
- {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.
|
|
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=
|
|
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",
|
|
21
|
-
("26.11.3",
|
|
49
|
+
("26.12.0", 6676),
|
|
50
|
+
("26.11.3", 6670),
|
|
22
51
|
("26.11.2", 6669),
|
|
23
52
|
("26.11.1", 6665),
|
|
24
|
-
("26.
|
|
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.
|
|
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://
|
|
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
|
-
|
|
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
|
|
pymax/connection/connection.py
CHANGED
|
@@ -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
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
|
pymax/dispatch/resolvers.py
CHANGED
|
@@ -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
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
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
|
|
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
|
-
Отправленное
|
|
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]
|
|
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
|
-
|
|
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
|
+
)
|