maxapi-python 2.4.0__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.
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
- pymax/__init__.py +4 -2
- pymax/_data/apk_fingerprints.json +102 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +14 -2
- pymax/api/chats/payloads.py +2 -1
- pymax/api/chats/service.py +7 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/payloads.py +9 -3
- pymax/api/messages/service.py +106 -12
- pymax/api/uploads/payloads.py +33 -1
- pymax/api/uploads/service.py +77 -62
- pymax/app.py +23 -4
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/sms.py +11 -1
- pymax/base.py +83 -9
- pymax/client.py +39 -10
- pymax/client_web.py +15 -12
- pymax/config.py +63 -21
- pymax/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- pymax/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -0
- pymax/infra/chat.py +9 -5
- pymax/infra/message.py +9 -3
- pymax/infra/protocol.py +5 -1
- pymax/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +11 -3
- pymax/types/domain/attachments/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +16 -0
- pymax/types/domain/attachments/video.py +3 -3
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +87 -7
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
pymax/files/voice.py
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
from collections.abc import AsyncGenerator
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
from .base import
|
|
4
|
+
from .base import TimedMediaFile
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class Voice(
|
|
7
|
+
class Voice(TimedMediaFile):
|
|
8
8
|
"""Голосовое сообщение в формате OGG для отправки.
|
|
9
9
|
|
|
10
10
|
Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно
|
|
11
|
-
передать имя файла.
|
|
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``.
|
|
12
24
|
"""
|
|
13
25
|
|
|
14
26
|
def __init__(
|
|
@@ -18,8 +30,10 @@ class Voice(BaseFile):
|
|
|
18
30
|
path: str | None = None,
|
|
19
31
|
url: str | None = None,
|
|
20
32
|
name: str | None = None,
|
|
33
|
+
duration: int | None = None,
|
|
21
34
|
) -> None:
|
|
22
35
|
self.name: str = name or ""
|
|
36
|
+
self.duration = duration
|
|
23
37
|
if not self.name and path:
|
|
24
38
|
self.name = Path(path).name
|
|
25
39
|
elif not self.name and url:
|
|
@@ -27,7 +41,7 @@ class Voice(BaseFile):
|
|
|
27
41
|
|
|
28
42
|
if not self.name:
|
|
29
43
|
raise ValueError("Either name, url or path must be provided.")
|
|
30
|
-
super().__init__(raw=raw, url=url, path=path, name=self.name)
|
|
44
|
+
super().__init__(raw=raw, url=url, path=path, name=self.name, duration=self.duration)
|
|
31
45
|
|
|
32
46
|
async def read(self) -> bytes:
|
|
33
47
|
return await super().read()
|
pymax/fingerprint/fingerprint.py
CHANGED
|
@@ -1,47 +1,30 @@
|
|
|
1
1
|
import hashlib
|
|
2
|
-
import json
|
|
3
2
|
import struct
|
|
4
|
-
from importlib import resources
|
|
5
|
-
from typing import Any
|
|
6
3
|
|
|
7
4
|
from .models import ApkBuildFingerprint
|
|
8
5
|
|
|
9
6
|
|
|
10
7
|
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)
|
|
8
|
+
def __init__(self, version_data: ApkBuildFingerprint) -> None:
|
|
9
|
+
self.data = version_data
|
|
20
10
|
|
|
21
11
|
def generate_fingerprint(
|
|
22
12
|
self,
|
|
23
|
-
version: str,
|
|
24
13
|
device_id: str,
|
|
25
14
|
calls_seed: int,
|
|
26
15
|
arch: str = "arm64-v8a",
|
|
27
16
|
) -> bytes | None:
|
|
28
|
-
data = self.data.get(version)
|
|
29
|
-
if not data:
|
|
30
|
-
return None
|
|
31
|
-
|
|
32
|
-
model = ApkBuildFingerprint.model_validate(data)
|
|
33
|
-
|
|
34
17
|
seed_bytes = struct.pack(">q", calls_seed)
|
|
35
18
|
device_bytes = device_id.encode("utf-8")
|
|
36
19
|
|
|
37
20
|
h1 = hashlib.sha256(
|
|
38
|
-
bytes.fromhex(
|
|
21
|
+
bytes.fromhex(self.data.certificate_meta_sha256) + seed_bytes + device_bytes
|
|
39
22
|
).digest()
|
|
40
23
|
h2 = hashlib.sha256(
|
|
41
|
-
bytes.fromhex(
|
|
24
|
+
bytes.fromhex(self.data.dex_meta_sha256) + seed_bytes + device_bytes
|
|
42
25
|
).digest()
|
|
43
26
|
h3 = hashlib.sha256(
|
|
44
|
-
bytes.fromhex(
|
|
27
|
+
bytes.fromhex(self.data.so_meta_sha256[arch]) + seed_bytes + device_bytes
|
|
45
28
|
).digest()
|
|
46
29
|
|
|
47
30
|
return h1 + h2 + h3
|
pymax/fingerprint/models.py
CHANGED
pymax/infra/chat.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pymax.api.chats import ChannelPermissions
|
|
2
|
+
from pymax.files import Photo
|
|
2
3
|
from pymax.types import Chat, Member, Message
|
|
3
4
|
|
|
4
5
|
from .protocol import IClientProtocol
|
|
@@ -131,20 +132,23 @@ class ChatMixin(IClientProtocol):
|
|
|
131
132
|
async def change_group_profile(
|
|
132
133
|
self,
|
|
133
134
|
chat_id: int,
|
|
134
|
-
name: str | None,
|
|
135
|
+
name: str | None = None,
|
|
135
136
|
description: str | None = None,
|
|
137
|
+
photo: Photo | None = None,
|
|
136
138
|
) -> None:
|
|
137
|
-
"""Обновляет
|
|
139
|
+
"""Обновляет название, описание или фотографию группы.
|
|
140
|
+
|
|
141
|
+
``None`` исключает соответствующее поле из запроса. Фотография
|
|
142
|
+
автоматически загружается перед обновлением профиля.
|
|
138
143
|
|
|
139
144
|
Args:
|
|
140
145
|
chat_id: ID группы.
|
|
141
146
|
name: Новое название.
|
|
142
147
|
description: Новое описание.
|
|
148
|
+
photo: Новая фотография группы.
|
|
143
149
|
"""
|
|
144
150
|
await self._app.api.chats.change_group_profile(
|
|
145
|
-
chat_id=chat_id,
|
|
146
|
-
name=name,
|
|
147
|
-
description=description,
|
|
151
|
+
chat_id=chat_id, name=name, description=description, photo=photo
|
|
148
152
|
)
|
|
149
153
|
|
|
150
154
|
async def join_group(self, link: str) -> Chat:
|
pymax/infra/message.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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 (
|
|
@@ -23,6 +24,7 @@ class MessageMixin(IClientProtocol):
|
|
|
23
24
|
attachments: SendAttachments = None,
|
|
24
25
|
*,
|
|
25
26
|
notify: bool = True,
|
|
27
|
+
send_at: DateTimeUnion | None = None,
|
|
26
28
|
) -> Message:
|
|
27
29
|
"""Отправляет сообщение в чат.
|
|
28
30
|
|
|
@@ -32,6 +34,9 @@ class MessageMixin(IClientProtocol):
|
|
|
32
34
|
reply_to: ID сообщения для ответа.
|
|
33
35
|
attachments: Файлы, фотографии, видео или опросы для отправки.
|
|
34
36
|
notify: Отправить ли получателям push-уведомление.
|
|
37
|
+
send_at: Время отложенной отправки: ``datetime`` для абсолютного
|
|
38
|
+
времени, ``timedelta`` для задержки или Unix time в секундах
|
|
39
|
+
как ``int``. ``None`` и ``0`` отправляют сообщение сразу.
|
|
35
40
|
|
|
36
41
|
Returns:
|
|
37
42
|
Отправленное сообщение.
|
|
@@ -45,6 +50,7 @@ class MessageMixin(IClientProtocol):
|
|
|
45
50
|
reply_to,
|
|
46
51
|
attachments,
|
|
47
52
|
notify=notify,
|
|
53
|
+
send_at=send_at,
|
|
48
54
|
)
|
|
49
55
|
|
|
50
56
|
async def get_message(
|
|
@@ -272,7 +278,7 @@ class MessageMixin(IClientProtocol):
|
|
|
272
278
|
async def add_reaction(
|
|
273
279
|
self,
|
|
274
280
|
chat_id: int,
|
|
275
|
-
message_id:
|
|
281
|
+
message_id: int,
|
|
276
282
|
reaction: str,
|
|
277
283
|
) -> ReactionInfo | None:
|
|
278
284
|
"""Добавляет реакцию к сообщению.
|
|
@@ -294,7 +300,7 @@ class MessageMixin(IClientProtocol):
|
|
|
294
300
|
async def get_reactions(
|
|
295
301
|
self,
|
|
296
302
|
chat_id: int,
|
|
297
|
-
message_ids: list[
|
|
303
|
+
message_ids: list[int],
|
|
298
304
|
) -> dict[str, ReactionInfo] | None:
|
|
299
305
|
"""Возвращает реакции для нескольких сообщений.
|
|
300
306
|
|
|
@@ -314,7 +320,7 @@ class MessageMixin(IClientProtocol):
|
|
|
314
320
|
async def remove_reaction(
|
|
315
321
|
self,
|
|
316
322
|
chat_id: int,
|
|
317
|
-
message_id:
|
|
323
|
+
message_id: int,
|
|
318
324
|
) -> ReactionInfo | None:
|
|
319
325
|
"""Удаляет свою реакцию с сообщения.
|
|
320
326
|
|
pymax/infra/protocol.py
CHANGED
pymax/session/__init__.py
CHANGED
pymax/session/models.py
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
from pydantic import BaseModel, Field
|
|
2
2
|
|
|
3
|
+
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
3
4
|
from pymax.types.domain.sync import SyncState
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class SessionInfo(BaseModel):
|
|
8
|
+
"""Состояние авторизованной сессии, сохраняемое через ``StoreProtocol``.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
token: Login token Max.
|
|
12
|
+
device_id: ID устройства, связанный с сессией.
|
|
13
|
+
phone: Номер телефона сессии; для web-сессии может быть пустой строкой.
|
|
14
|
+
mt_instance_id: Instance ID устройства. Пустая строка поддерживается для
|
|
15
|
+
сессий, созданных предыдущими версиями PyMax.
|
|
16
|
+
user_agent: Device/user-agent сессии. ``None`` поддерживается для старых
|
|
17
|
+
или пользовательских store, которые его не сохраняют.
|
|
18
|
+
sync: Последние sync-маркеры login.
|
|
19
|
+
"""
|
|
20
|
+
|
|
7
21
|
token: str
|
|
8
22
|
device_id: str
|
|
9
23
|
phone: str
|
|
10
24
|
mt_instance_id: str = ""
|
|
25
|
+
user_agent: MobileUserAgentPayload | None = None
|
|
11
26
|
sync: SyncState = Field(default_factory=SyncState)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_session_user_agent(
|
|
30
|
+
current: MobileUserAgentPayload,
|
|
31
|
+
stored: MobileUserAgentPayload | None,
|
|
32
|
+
) -> MobileUserAgentPayload:
|
|
33
|
+
if stored is None or stored.device_type != current.device_type:
|
|
34
|
+
return current
|
|
35
|
+
|
|
36
|
+
return stored.model_copy(
|
|
37
|
+
update={
|
|
38
|
+
"app_version": current.app_version,
|
|
39
|
+
"build_number": current.build_number,
|
|
40
|
+
}
|
|
41
|
+
)
|
pymax/session/protocol.py
CHANGED
|
@@ -5,10 +5,36 @@ from .models import SessionInfo
|
|
|
5
5
|
|
|
6
6
|
@runtime_checkable
|
|
7
7
|
class StoreProtocol(Protocol):
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
async def
|
|
8
|
+
"""Асинхронный контракт пользовательского хранилища сессии.
|
|
9
|
+
|
|
10
|
+
PyMax использует переданный ``ExtraConfig.store`` только при
|
|
11
|
+
``persist_session=True`` и вызывает :meth:`close` при закрытии runtime.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
async def save_session(self, session_info: SessionInfo) -> None:
|
|
15
|
+
"""Сохраняет или полностью заменяет данные сессии."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
async def update_token(self, old_token: str, new_token: str, /) -> None:
|
|
19
|
+
"""Заменяет ``old_token`` у соответствующей сессии."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
async def load_session(self) -> SessionInfo | None:
|
|
23
|
+
"""Возвращает сессию для запуска клиента или ``None``."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
|
|
27
|
+
"""Ищет сессию по device ID."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
|
|
31
|
+
"""Ищет сессию по номеру телефона."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def delete_session(self, token: str, /) -> None:
|
|
35
|
+
"""Удаляет сессию с переданным token, если она существует."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def close(self) -> None:
|
|
39
|
+
"""Освобождает ресурсы хранилища при закрытии runtime."""
|
|
40
|
+
...
|
pymax/session/store.py
CHANGED
|
@@ -2,10 +2,12 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
import aiosqlite
|
|
4
4
|
|
|
5
|
+
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
5
6
|
from pymax.logging import get_logger
|
|
6
7
|
from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, SyncState
|
|
7
8
|
|
|
8
9
|
from .models import SessionInfo
|
|
10
|
+
from .protocol import StoreProtocol
|
|
9
11
|
|
|
10
12
|
logger = get_logger(__name__)
|
|
11
13
|
|
|
@@ -18,11 +20,64 @@ SESSION_COLUMNS = """
|
|
|
18
20
|
contacts_sync,
|
|
19
21
|
drafts_sync,
|
|
20
22
|
presence_sync,
|
|
21
|
-
config_hash
|
|
23
|
+
config_hash,
|
|
24
|
+
user_agent
|
|
22
25
|
"""
|
|
23
26
|
|
|
24
27
|
|
|
25
|
-
class
|
|
28
|
+
class InMemoryStore(StoreProtocol):
|
|
29
|
+
"""Хранилище одной сессии в памяти текущего runtime.
|
|
30
|
+
|
|
31
|
+
Данные не записываются на диск и становятся недоступны после пересоздания
|
|
32
|
+
``App``. Обычно этот store выбирается автоматически при
|
|
33
|
+
``ExtraConfig(persist_session=False)``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._session: SessionInfo | None = None
|
|
40
|
+
|
|
41
|
+
async def save_session(self, session_info: SessionInfo) -> None:
|
|
42
|
+
self._session = session_info
|
|
43
|
+
|
|
44
|
+
async def update_token(self, _: str, new_token: str) -> None:
|
|
45
|
+
if not self._session:
|
|
46
|
+
return
|
|
47
|
+
self._session.token = new_token
|
|
48
|
+
|
|
49
|
+
async def load_session(self) -> SessionInfo | None:
|
|
50
|
+
return self._session
|
|
51
|
+
|
|
52
|
+
async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
|
|
53
|
+
if self._session and self._session.device_id != device_id:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
|
|
59
|
+
if self._session and self._session.phone != phone:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return self._session
|
|
63
|
+
|
|
64
|
+
async def delete_session(self, _: str) -> None:
|
|
65
|
+
self._session = None
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SessionStore(StoreProtocol):
|
|
72
|
+
"""SQLite-хранилище сессий, используемое по умолчанию.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
work_dir: Директория файла базы данных; создается автоматически.
|
|
76
|
+
db_name: Имя файла базы данных. По умолчанию ``session.db``.
|
|
77
|
+
|
|
78
|
+
Схема существующего файла автоматически дополняется недостающими колонками.
|
|
79
|
+
"""
|
|
80
|
+
|
|
26
81
|
def __init__(self, work_dir: str, db_name: str = "session.db") -> None:
|
|
27
82
|
self.work_dir = Path(work_dir)
|
|
28
83
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -51,7 +106,8 @@ class SessionStore:
|
|
|
51
106
|
contacts_sync INTEGER NOT NULL DEFAULT -1,
|
|
52
107
|
drafts_sync INTEGER NOT NULL DEFAULT -1,
|
|
53
108
|
presence_sync INTEGER NOT NULL DEFAULT -1,
|
|
54
|
-
config_hash TEXT NOT NULL DEFAULT ''
|
|
109
|
+
config_hash TEXT NOT NULL DEFAULT '',
|
|
110
|
+
user_agent TEXT
|
|
55
111
|
)
|
|
56
112
|
"""
|
|
57
113
|
)
|
|
@@ -61,6 +117,7 @@ class SessionStore:
|
|
|
61
117
|
await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1")
|
|
62
118
|
await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1")
|
|
63
119
|
await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''")
|
|
120
|
+
await self._ensure_column(conn, "user_agent", "TEXT")
|
|
64
121
|
await conn.execute(
|
|
65
122
|
"""
|
|
66
123
|
UPDATE sessions
|
|
@@ -102,9 +159,10 @@ class SessionStore:
|
|
|
102
159
|
contacts_sync,
|
|
103
160
|
drafts_sync,
|
|
104
161
|
presence_sync,
|
|
105
|
-
config_hash
|
|
162
|
+
config_hash,
|
|
163
|
+
user_agent
|
|
106
164
|
)
|
|
107
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
108
166
|
""",
|
|
109
167
|
(
|
|
110
168
|
session_info.token,
|
|
@@ -116,6 +174,11 @@ class SessionStore:
|
|
|
116
174
|
session_info.sync.drafts_sync,
|
|
117
175
|
session_info.sync.presence_sync,
|
|
118
176
|
session_info.sync.config_hash,
|
|
177
|
+
(
|
|
178
|
+
session_info.user_agent.model_dump_json(by_alias=True, exclude_none=True)
|
|
179
|
+
if session_info.user_agent is not None
|
|
180
|
+
else None
|
|
181
|
+
),
|
|
119
182
|
),
|
|
120
183
|
)
|
|
121
184
|
await conn.commit()
|
|
@@ -233,6 +296,11 @@ class SessionStore:
|
|
|
233
296
|
device_id=row["device_id"],
|
|
234
297
|
phone=row["phone"],
|
|
235
298
|
mt_instance_id=row["mt_instance_id"] or "",
|
|
299
|
+
user_agent=(
|
|
300
|
+
MobileUserAgentPayload.model_validate_json(row["user_agent"])
|
|
301
|
+
if row["user_agent"] is not None
|
|
302
|
+
else None
|
|
303
|
+
),
|
|
236
304
|
sync=SyncState(
|
|
237
305
|
chats_sync=row["chats_sync"],
|
|
238
306
|
contacts_sync=row["contacts_sync"],
|
pymax/transport/tcp.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import ssl
|
|
3
|
+
from importlib import resources
|
|
2
4
|
|
|
3
5
|
from python_socks.async_.asyncio import Proxy
|
|
4
6
|
|
|
@@ -16,6 +18,10 @@ class TCPTransport(Transport):
|
|
|
16
18
|
self._port = port
|
|
17
19
|
self._proxy = proxy
|
|
18
20
|
self._use_ssl = use_ssl
|
|
21
|
+
self._ssl_ctx = ssl.create_default_context()
|
|
22
|
+
self._ssl_ctx.load_verify_locations(
|
|
23
|
+
str(resources.files("pymax._data") / "rootca_ssl_rsa2022.crt")
|
|
24
|
+
)
|
|
19
25
|
self._reader = None
|
|
20
26
|
self._writer = None
|
|
21
27
|
|
|
@@ -26,6 +32,9 @@ class TCPTransport(Transport):
|
|
|
26
32
|
self._port,
|
|
27
33
|
self._use_ssl,
|
|
28
34
|
)
|
|
35
|
+
ssl_ctx = self._ssl_ctx if self._use_ssl else None
|
|
36
|
+
server_hostname = self._host if self._use_ssl else None
|
|
37
|
+
|
|
29
38
|
if self._proxy:
|
|
30
39
|
logger.debug("tcp connecting via proxy %s", self._proxy)
|
|
31
40
|
proxy = Proxy.from_url(self._proxy)
|
|
@@ -33,10 +42,9 @@ class TCPTransport(Transport):
|
|
|
33
42
|
dest_host=self._host,
|
|
34
43
|
dest_port=self._port,
|
|
35
44
|
)
|
|
36
|
-
server_hostname = self._host if self._use_ssl else None
|
|
37
45
|
self._reader, self._writer = await asyncio.open_connection(
|
|
38
46
|
sock=sock,
|
|
39
|
-
ssl=
|
|
47
|
+
ssl=ssl_ctx,
|
|
40
48
|
server_hostname=server_hostname,
|
|
41
49
|
)
|
|
42
50
|
logger.info(
|
|
@@ -50,7 +58,7 @@ class TCPTransport(Transport):
|
|
|
50
58
|
self._reader, self._writer = await asyncio.open_connection(
|
|
51
59
|
self._host,
|
|
52
60
|
self._port,
|
|
53
|
-
ssl=
|
|
61
|
+
ssl=ssl_ctx,
|
|
54
62
|
)
|
|
55
63
|
logger.info(
|
|
56
64
|
"tcp connected host=%s port=%s ssl=%s",
|
|
@@ -4,7 +4,7 @@ from pydantic import Field
|
|
|
4
4
|
|
|
5
5
|
from pymax.types.domain.base import CamelModel
|
|
6
6
|
|
|
7
|
-
from .enums import AttachmentType
|
|
7
|
+
from .enums import AttachmentType, CallType, HangupType
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class CallAttachment(CamelModel):
|
|
@@ -18,9 +18,15 @@ class CallAttachment(CamelModel):
|
|
|
18
18
|
:vartype conversation_id: str | int | None
|
|
19
19
|
:ivar contact_ids: ID участников звонка.
|
|
20
20
|
:vartype contact_ids: list[int]
|
|
21
|
+
:ivar call_type: Тип звонка, если Max его прислал.
|
|
22
|
+
:vartype call_type: CallType | None
|
|
23
|
+
:ivar hangup_type: Причина завершения звонка, если она есть в payload-е.
|
|
24
|
+
:vartype hangup_type: HangupType | None
|
|
21
25
|
"""
|
|
22
26
|
|
|
23
27
|
type: Literal[AttachmentType.CALL] = Field(alias="_type")
|
|
24
28
|
duration: int | None = None
|
|
25
29
|
conversation_id: str | int | None = None
|
|
26
30
|
contact_ids: list[int] = Field(default_factory=list)
|
|
31
|
+
call_type: CallType | None = None
|
|
32
|
+
hangup_type: HangupType | None = None
|
|
@@ -10,8 +10,8 @@ from .enums import AttachmentType
|
|
|
10
10
|
class ContactAttachment(CamelModel):
|
|
11
11
|
"""Контактное вложение сообщения.
|
|
12
12
|
|
|
13
|
-
:ivar contact_id: ID
|
|
14
|
-
:vartype contact_id: int
|
|
13
|
+
:ivar contact_id: ID контакта, если Max его прислал.
|
|
14
|
+
:vartype contact_id: int | None
|
|
15
15
|
:ivar first_name: Имя контакта.
|
|
16
16
|
:vartype first_name: str | None
|
|
17
17
|
:ivar last_name: Фамилия контакта.
|
|
@@ -24,7 +24,7 @@ class ContactAttachment(CamelModel):
|
|
|
24
24
|
:vartype type: Literal[AttachmentType.CONTACT]
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
contact_id: int
|
|
27
|
+
contact_id: int | None = None
|
|
28
28
|
first_name: str | None = None
|
|
29
29
|
last_name: str | None = None
|
|
30
30
|
name: str | None = None
|
|
@@ -14,7 +14,10 @@ class ControlAttachment(CamelModel):
|
|
|
14
14
|
:vartype type: Literal[AttachmentType.CONTROL]
|
|
15
15
|
:ivar event: Событие управления.
|
|
16
16
|
:vartype event: str
|
|
17
|
+
:ivar title: Заголовок служебного события, если он есть в payload-е.
|
|
18
|
+
:vartype title: str | None
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
21
|
type: Literal[AttachmentType.CONTROL] = Field(alias="_type")
|
|
20
22
|
event: str
|
|
23
|
+
title: str | None = None
|
|
@@ -42,3 +42,19 @@ class PollFlags(IntFlag):
|
|
|
42
42
|
FLAG_SETTINGS_CLOSED = 8
|
|
43
43
|
FLAG_SETTINGS_QUIZ = 16
|
|
44
44
|
FLAG_SETTINGS_CAN_FORWARD = 32
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CallType(str, Enum):
|
|
48
|
+
"""Тип звонка во входящем ``CallAttachment``."""
|
|
49
|
+
|
|
50
|
+
AUDIO = "AUDIO"
|
|
51
|
+
VIDEO = "VIDEO"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HangupType(str, Enum):
|
|
55
|
+
"""Причина завершения звонка во входящем ``CallAttachment``."""
|
|
56
|
+
|
|
57
|
+
MISSED = "MISSED"
|
|
58
|
+
REJECTED = "REJECTED"
|
|
59
|
+
CANCELED = "CANCELED"
|
|
60
|
+
HUNGUP = "HUNGUP"
|
|
@@ -32,8 +32,8 @@ class VideoAttachment(CamelModel):
|
|
|
32
32
|
:vartype video_id: int
|
|
33
33
|
:ivar duration: Длительность видео.
|
|
34
34
|
:vartype duration: int | None
|
|
35
|
-
:ivar preview_data: Данные
|
|
36
|
-
:vartype preview_data: bytes
|
|
35
|
+
:ivar preview_data: Данные превью, если Max их прислал.
|
|
36
|
+
:vartype preview_data: bytes | None
|
|
37
37
|
:ivar type: Тип вложения.
|
|
38
38
|
:vartype type: Literal[AttachmentType.VIDEO]
|
|
39
39
|
:ivar thumbnail: URL миниатюры.
|
|
@@ -48,7 +48,7 @@ class VideoAttachment(CamelModel):
|
|
|
48
48
|
width: int
|
|
49
49
|
video_id: int
|
|
50
50
|
duration: int | None = None
|
|
51
|
-
preview_data: bytes
|
|
51
|
+
preview_data: bytes | None = None
|
|
52
52
|
type: Literal[AttachmentType.VIDEO] = Field(alias="_type")
|
|
53
53
|
thumbnail: str
|
|
54
54
|
token: str
|