maxapi-python 1.2.1__py3-none-any.whl → 1.2.3__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-1.2.1.dist-info → maxapi_python-1.2.3.dist-info}/METADATA +43 -6
- {maxapi_python-1.2.1.dist-info → maxapi_python-1.2.3.dist-info}/RECORD +15 -15
- pymax/core.py +2 -1
- pymax/files.py +5 -0
- pymax/interfaces.py +1 -0
- pymax/mixins/group.py +31 -12
- pymax/mixins/self.py +74 -9
- pymax/mixins/socket.py +10 -0
- pymax/mixins/websocket.py +16 -16
- pymax/payloads.py +32 -14
- pymax/static/constant.py +1 -1
- pymax/static/enum.py +1 -0
- pymax/types.py +57 -28
- {maxapi_python-1.2.1.dist-info → maxapi_python-1.2.3.dist-info}/WHEEL +0 -0
- {maxapi_python-1.2.1.dist-info → maxapi_python-1.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
|
5
5
|
Project-URL: Homepage, https://github.com/ink-developer/PyMax
|
|
6
6
|
Project-URL: Repository, https://github.com/ink-developer/PyMax
|
|
@@ -19,6 +19,13 @@ Requires-Dist: msgpack>=1.1.1
|
|
|
19
19
|
Requires-Dist: qrcode>=8.2
|
|
20
20
|
Requires-Dist: sqlmodel>=0.0.24
|
|
21
21
|
Requires-Dist: websockets>=15.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: flake8; extra == 'test'
|
|
24
|
+
Requires-Dist: mypy; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest-timeout>=2.1.0; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
22
29
|
Description-Content-Type: text/markdown
|
|
23
30
|
|
|
24
31
|
<p align="center">
|
|
@@ -36,11 +43,6 @@ Description-Content-Type: text/markdown
|
|
|
36
43
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
|
37
44
|
</p>
|
|
38
45
|
|
|
39
|
-
> [!IMPORTANT]
|
|
40
|
-
> (20.12.25) Из за резкого изменения апи большая часть библиотеки не работает.
|
|
41
|
-
Смотрите [новость](https://t.me/pymax_news/111)
|
|
42
|
-
>
|
|
43
|
-
> P.s добавил логин по qr в dev/1.2.1
|
|
44
46
|
|
|
45
47
|
---
|
|
46
48
|
> ⚠️ **Дисклеймер**
|
|
@@ -82,6 +84,41 @@ uv add -U maxapi-python
|
|
|
82
84
|
|
|
83
85
|
## Быстрый старт
|
|
84
86
|
|
|
87
|
+
### Аутентификация (`device_type`)
|
|
88
|
+
|
|
89
|
+
> [!IMPORTANT]
|
|
90
|
+
> Параметр `device_type` в `UserAgentPayload` **критически важен** для выбора способа авторизации:
|
|
91
|
+
|
|
92
|
+
**Вход по номеру телефона (DESKTOP):**
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pymax import MaxClient
|
|
96
|
+
from pymax.payloads import UserAgentPayload
|
|
97
|
+
|
|
98
|
+
ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13")
|
|
99
|
+
|
|
100
|
+
client = MaxClient(
|
|
101
|
+
phone="+79111111111",
|
|
102
|
+
work_dir="cache",
|
|
103
|
+
headers=ua,
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Вход через QR-код (WEB)** — токен совместим с веб-версией Max:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from pymax import MaxClient
|
|
111
|
+
from pymax.payloads import UserAgentPayload
|
|
112
|
+
|
|
113
|
+
ua = UserAgentPayload(device_type="WEB", app_version="25.12.13")
|
|
114
|
+
|
|
115
|
+
client = MaxClient(
|
|
116
|
+
phone="+7911111111",
|
|
117
|
+
work_dir="cache",
|
|
118
|
+
headers=ua,
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
85
122
|
### Базовый пример использования
|
|
86
123
|
|
|
87
124
|
```python
|
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
-
pymax/core.py,sha256=
|
|
2
|
+
pymax/core.py,sha256=S5el07cMcqIyXOI25G0a2XtVJSz2_AltmKN7ZW6LEeg,14946
|
|
3
3
|
pymax/crud.py,sha256=YC92TyhA2mr1tJCcfd-tvh8umtXKgqJfgiLo7nXUl3Q,3076
|
|
4
4
|
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
|
-
pymax/files.py,sha256=
|
|
5
|
+
pymax/files.py,sha256=bSjP1m-dLPJCN8HPZtjrLLp0Oc7OxdGxI6U1K2rQzUA,3587
|
|
6
6
|
pymax/filters.py,sha256=gSHPJ1Vi37HKPxf0jRRv9Q3iGwhiQjw1MGrCaouqHzs,4325
|
|
7
7
|
pymax/formatter.py,sha256=RJ_5VbY7Li8UM3xL1AvcXo8v1iYnY8GvDDkreaFqtnY,860
|
|
8
8
|
pymax/formatting.py,sha256=XRtuXJGweuNZevJFdPxksDftIrfuMGEA-AOUc_v6IhQ,2484
|
|
9
|
-
pymax/interfaces.py,sha256=
|
|
9
|
+
pymax/interfaces.py,sha256=5-0RM0tH3eBJrVZkjL494lKTTwvIVmOTJiQxDWZo-7A,8829
|
|
10
10
|
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
11
|
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
-
pymax/payloads.py,sha256
|
|
13
|
-
pymax/types.py,sha256=
|
|
12
|
+
pymax/payloads.py,sha256=tlqPT-JFexfklp9idSrbpU3bALcQuiDne9H9vTnwL4w,8022
|
|
13
|
+
pymax/types.py,sha256=_Ee1aycVwe4nfBO_hGMnpm9lQDGzkPOkT9vME8OL-sA,36650
|
|
14
14
|
pymax/mixins/__init__.py,sha256=5sXJME34S1EssuDETaN4DLRH7vhMw_Q3Jmay9myAIZM,775
|
|
15
15
|
pymax/mixins/auth.py,sha256=e90vIpEOwAjUxgYMYaG7R6jR_5t9rKsei_mTBQUirL4,14716
|
|
16
16
|
pymax/mixins/channel.py,sha256=W52YnBay1sUYXxF9oAWsz44ZUh_s45jSvKmAyjTbULM,5357
|
|
17
|
-
pymax/mixins/group.py,sha256=
|
|
17
|
+
pymax/mixins/group.py,sha256=MaiZCQ9R-wWbuEPXyqf4JZAGNmxu_tXCs8qU3JEoYek,15911
|
|
18
18
|
pymax/mixins/handler.py,sha256=ETnI8fA386LYJGjWtUhhWzQHREUA78di1aO1oWwtscA,12523
|
|
19
19
|
pymax/mixins/message.py,sha256=AznKKmTMxdzsYl8IecT43RjWpGvlQM85GzSNGFbI8BA,33279
|
|
20
20
|
pymax/mixins/scheduler.py,sha256=rcMfgfZnzu5V6MkcCg6uRgbi-jkc7UyqOjemulydWbc,964
|
|
21
|
-
pymax/mixins/self.py,sha256=
|
|
22
|
-
pymax/mixins/socket.py,sha256=
|
|
21
|
+
pymax/mixins/self.py,sha256=27ranVw5rdv1qSplhV5mdUvHc4qSxHi63f1gWOjR9B4,9182
|
|
22
|
+
pymax/mixins/socket.py,sha256=tk2puQPsT2Dltg1j6kUSHIbXs64p6q-jiIIcs73Y1Sk,23174
|
|
23
23
|
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
24
24
|
pymax/mixins/user.py,sha256=RSZd4t-aq8P2k3cVzNVWBkUf-_xTWILrBzwxLRgk1pw,9450
|
|
25
25
|
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
26
|
-
pymax/mixins/websocket.py,sha256=
|
|
27
|
-
pymax/static/constant.py,sha256
|
|
28
|
-
pymax/static/enum.py,sha256=
|
|
29
|
-
maxapi_python-1.2.
|
|
30
|
-
maxapi_python-1.2.
|
|
31
|
-
maxapi_python-1.2.
|
|
32
|
-
maxapi_python-1.2.
|
|
26
|
+
pymax/mixins/websocket.py,sha256=fL8IcsLkvksizSAg98UaFlDPZGyGbYtbtdHnlDzfR9g,18056
|
|
27
|
+
pymax/static/constant.py,sha256=nM0svv3VpsVxK-RqoADn9qsTdQvB-IYv0Sgv-bQcWs4,1116
|
|
28
|
+
pymax/static/enum.py,sha256=mraN6FK_gcVXdTHBdK8Zf6-92AzVFGAWRnQLA4rzYBA,4672
|
|
29
|
+
maxapi_python-1.2.3.dist-info/METADATA,sha256=0NSb43LEMf9-M1KLeETofzdcjsGJ68lpZ15xP9GouI0,6753
|
|
30
|
+
maxapi_python-1.2.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
31
|
+
maxapi_python-1.2.3.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
32
|
+
maxapi_python-1.2.3.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -116,6 +116,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
|
|
|
116
116
|
self.dialogs: list[Dialog] = []
|
|
117
117
|
self.channels: list[Channel] = []
|
|
118
118
|
self.me: Me | None = None
|
|
119
|
+
self.contacts: list[User] = []
|
|
119
120
|
self._users: dict[int, User] = {}
|
|
120
121
|
|
|
121
122
|
self._work_dir: str = work_dir
|
|
@@ -295,7 +296,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
|
|
|
295
296
|
if self._token is None:
|
|
296
297
|
await self._login()
|
|
297
298
|
|
|
298
|
-
await self._sync()
|
|
299
|
+
await self._sync(self.user_agent)
|
|
299
300
|
|
|
300
301
|
await self._post_login_tasks(sync=False)
|
|
301
302
|
|
pymax/files.py
CHANGED
|
@@ -46,6 +46,11 @@ class Photo(BaseFile):
|
|
|
46
46
|
} # FIXME: костыль ✅
|
|
47
47
|
|
|
48
48
|
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
49
|
+
if path:
|
|
50
|
+
self.file_name = Path(path).name
|
|
51
|
+
elif url:
|
|
52
|
+
self.file_name = Path(url).name
|
|
53
|
+
|
|
49
54
|
super().__init__(url, path)
|
|
50
55
|
|
|
51
56
|
def validate_photo(self) -> tuple[str, str] | None:
|
pymax/interfaces.py
CHANGED
pymax/mixins/group.py
CHANGED
|
@@ -95,9 +95,7 @@ class GroupMixin(ClientProtocol):
|
|
|
95
95
|
operation="add",
|
|
96
96
|
).model_dump(by_alias=True)
|
|
97
97
|
|
|
98
|
-
data = await self._send_and_wait(
|
|
99
|
-
opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
|
|
100
|
-
)
|
|
98
|
+
data = await self._send_and_wait(opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload)
|
|
101
99
|
|
|
102
100
|
if data.get("payload", {}).get("error"):
|
|
103
101
|
MixinsUtils.handle_error(data)
|
|
@@ -155,9 +153,7 @@ class GroupMixin(ClientProtocol):
|
|
|
155
153
|
clean_msg_period=clean_msg_period,
|
|
156
154
|
).model_dump(by_alias=True)
|
|
157
155
|
|
|
158
|
-
data = await self._send_and_wait(
|
|
159
|
-
opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
|
|
160
|
-
)
|
|
156
|
+
data = await self._send_and_wait(opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload)
|
|
161
157
|
|
|
162
158
|
if data.get("payload", {}).get("error"):
|
|
163
159
|
MixinsUtils.handle_error(data)
|
|
@@ -293,6 +289,33 @@ class GroupMixin(ClientProtocol):
|
|
|
293
289
|
|
|
294
290
|
return chat
|
|
295
291
|
|
|
292
|
+
async def resolve_group_by_link(self, link: str) -> Chat | None:
|
|
293
|
+
"""
|
|
294
|
+
Разрешает группу по ссылке
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
link (str): Ссылка на группу.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Chat | None: Объект чата группы или None, если не найдено.
|
|
301
|
+
"""
|
|
302
|
+
proceed_link = self._process_chat_join_link(link)
|
|
303
|
+
if proceed_link is None:
|
|
304
|
+
raise ValueError("Invalid group link")
|
|
305
|
+
|
|
306
|
+
data = await self._send_and_wait(
|
|
307
|
+
opcode=Opcode.LINK_INFO,
|
|
308
|
+
payload={
|
|
309
|
+
"link": proceed_link,
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if data.get("payload", {}).get("error"):
|
|
314
|
+
MixinsUtils.handle_error(data)
|
|
315
|
+
|
|
316
|
+
chat = Chat.from_dict(data["payload"].get("chat", {}))
|
|
317
|
+
return chat
|
|
318
|
+
|
|
296
319
|
async def rework_invite_link(self, chat_id: int) -> Chat:
|
|
297
320
|
"""
|
|
298
321
|
Пересоздает ссылку для приглашения в группу
|
|
@@ -329,14 +352,10 @@ class GroupMixin(ClientProtocol):
|
|
|
329
352
|
chat_id for chat_id in chat_ids if await self._get_chat(chat_id) is None
|
|
330
353
|
]
|
|
331
354
|
if missed_chat_ids:
|
|
332
|
-
payload = GetChatInfoPayload(chat_ids=missed_chat_ids).model_dump(
|
|
333
|
-
by_alias=True
|
|
334
|
-
)
|
|
355
|
+
payload = GetChatInfoPayload(chat_ids=missed_chat_ids).model_dump(by_alias=True)
|
|
335
356
|
else:
|
|
336
357
|
chats: list[Chat] = [
|
|
337
|
-
chat
|
|
338
|
-
for chat_id in chat_ids
|
|
339
|
-
if (chat := await self._get_chat(chat_id)) is not None
|
|
358
|
+
chat for chat_id in chat_ids if (chat := await self._get_chat(chat_id)) is not None
|
|
340
359
|
]
|
|
341
360
|
return chats
|
|
342
361
|
|
pymax/mixins/self.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from http import HTTPStatus
|
|
1
3
|
from typing import Any
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
2
5
|
from uuid import uuid4
|
|
3
6
|
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
4
9
|
from pymax.exceptions import Error
|
|
10
|
+
from pymax.files import Photo
|
|
5
11
|
from pymax.interfaces import ClientProtocol
|
|
6
12
|
from pymax.mixins.utils import MixinsUtils
|
|
7
13
|
from pymax.payloads import (
|
|
@@ -10,17 +16,60 @@ from pymax.payloads import (
|
|
|
10
16
|
DeleteFolderPayload,
|
|
11
17
|
GetFolderPayload,
|
|
12
18
|
UpdateFolderPayload,
|
|
19
|
+
UploadPayload,
|
|
13
20
|
)
|
|
14
21
|
from pymax.static.enum import Opcode
|
|
15
|
-
from pymax.types import Folder, FolderList, FolderUpdate
|
|
22
|
+
from pymax.types import Folder, FolderList, FolderUpdate, Me
|
|
16
23
|
|
|
17
24
|
|
|
18
25
|
class SelfMixin(ClientProtocol):
|
|
26
|
+
async def _request_photo_upload_url(self) -> str:
|
|
27
|
+
self.logger.info("Requesting profile photo upload URL")
|
|
28
|
+
|
|
29
|
+
data = await self._send_and_wait(
|
|
30
|
+
opcode=Opcode.PHOTO_UPLOAD,
|
|
31
|
+
payload=UploadPayload(profile=True).model_dump(by_alias=True),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if data.get("payload", {}).get("error"):
|
|
35
|
+
MixinsUtils.handle_error(data)
|
|
36
|
+
|
|
37
|
+
return data["payload"]["url"]
|
|
38
|
+
|
|
39
|
+
async def _upload_profile_photo(self, upload_url: str, photo: Photo) -> str:
|
|
40
|
+
self.logger.info("Uploading profile photo")
|
|
41
|
+
|
|
42
|
+
parsed_url = urlparse(upload_url)
|
|
43
|
+
photo_id = parse_qs(parsed_url.query)["photoIds"][0]
|
|
44
|
+
|
|
45
|
+
form = aiohttp.FormData()
|
|
46
|
+
form.add_field(
|
|
47
|
+
"file",
|
|
48
|
+
await photo.read(),
|
|
49
|
+
filename=photo.file_name,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async with (
|
|
53
|
+
aiohttp.ClientSession() as session,
|
|
54
|
+
session.post(upload_url, data=form) as response,
|
|
55
|
+
):
|
|
56
|
+
if response.status != HTTPStatus.OK:
|
|
57
|
+
raise Error(
|
|
58
|
+
"Failed to upload profile photo.", message="UploadError", title="Upload Error"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.logger.info("Upload successful")
|
|
62
|
+
data = await response.json()
|
|
63
|
+
return data["photos"][photo_id][
|
|
64
|
+
"token"
|
|
65
|
+
] # TODO: сделать нормальную типизацию и чекнинг ответа
|
|
66
|
+
|
|
19
67
|
async def change_profile(
|
|
20
68
|
self,
|
|
21
69
|
first_name: str,
|
|
22
70
|
last_name: str | None = None,
|
|
23
71
|
description: str | None = None,
|
|
72
|
+
photo: Photo | None = None,
|
|
24
73
|
) -> bool:
|
|
25
74
|
"""
|
|
26
75
|
Изменяет информацию профиля текущего пользователя.
|
|
@@ -35,20 +84,36 @@ class SelfMixin(ClientProtocol):
|
|
|
35
84
|
:rtype: bool
|
|
36
85
|
"""
|
|
37
86
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
87
|
+
if photo:
|
|
88
|
+
upload_url = await self._request_photo_upload_url()
|
|
89
|
+
photo_token = await self._upload_profile_photo(upload_url, photo)
|
|
90
|
+
|
|
91
|
+
payload = ChangeProfilePayload(
|
|
92
|
+
first_name=first_name,
|
|
93
|
+
last_name=last_name,
|
|
94
|
+
description=description,
|
|
95
|
+
photo_token=photo_token,
|
|
96
|
+
).model_dump(
|
|
97
|
+
by_alias=True,
|
|
98
|
+
exclude_none=True,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
payload = ChangeProfilePayload(
|
|
102
|
+
first_name=first_name,
|
|
103
|
+
last_name=last_name,
|
|
104
|
+
description=description,
|
|
105
|
+
).model_dump(
|
|
106
|
+
by_alias=True,
|
|
107
|
+
exclude_none=True,
|
|
108
|
+
)
|
|
46
109
|
|
|
47
110
|
data = await self._send_and_wait(opcode=Opcode.PROFILE, payload=payload)
|
|
48
111
|
|
|
49
112
|
if data.get("payload", {}).get("error"):
|
|
50
113
|
MixinsUtils.handle_error(data)
|
|
51
114
|
|
|
115
|
+
self.me = Me.from_dict(data["payload"]["profile"]["contact"])
|
|
116
|
+
|
|
52
117
|
return True
|
|
53
118
|
|
|
54
119
|
async def create_folder(
|
pymax/mixins/socket.py
CHANGED
|
@@ -28,6 +28,7 @@ from pymax.types import (
|
|
|
28
28
|
Message,
|
|
29
29
|
ReactionCounter,
|
|
30
30
|
ReactionInfo,
|
|
31
|
+
User,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
@@ -603,6 +604,15 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
603
604
|
self.channels.append(Channel.from_dict(raw_chat))
|
|
604
605
|
except Exception:
|
|
605
606
|
self.logger.exception("Error parsing chat entry (socket)")
|
|
607
|
+
|
|
608
|
+
for raw_user in raw_payload.get("contacts", []):
|
|
609
|
+
try:
|
|
610
|
+
user = User.from_dict(raw_user)
|
|
611
|
+
if user:
|
|
612
|
+
self.contacts.append(user)
|
|
613
|
+
except Exception:
|
|
614
|
+
self.logger.exception("Error parsing contact entry (socket)")
|
|
615
|
+
|
|
606
616
|
if raw_payload.get("profile", {}).get("contact"):
|
|
607
617
|
self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
|
|
608
618
|
self.logger.info(
|
pymax/mixins/websocket.py
CHANGED
|
@@ -27,6 +27,7 @@ from pymax.types import (
|
|
|
27
27
|
Message,
|
|
28
28
|
ReactionCounter,
|
|
29
29
|
ReactionInfo,
|
|
30
|
+
User,
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
|
|
@@ -50,9 +51,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
50
51
|
payload=payload,
|
|
51
52
|
).model_dump(by_alias=True)
|
|
52
53
|
|
|
53
|
-
self.logger.debug(
|
|
54
|
-
"make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
|
|
55
|
-
)
|
|
54
|
+
self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
|
|
56
55
|
return msg
|
|
57
56
|
|
|
58
57
|
async def _send_interactive_ping(self) -> None:
|
|
@@ -68,9 +67,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
68
67
|
self.logger.warning("Interactive ping failed", exc_info=True)
|
|
69
68
|
await asyncio.sleep(DEFAULT_PING_INTERVAL)
|
|
70
69
|
|
|
71
|
-
async def connect(
|
|
72
|
-
self, user_agent: UserAgentPayload | None = None
|
|
73
|
-
) -> dict[str, Any] | None:
|
|
70
|
+
async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any] | None:
|
|
74
71
|
"""
|
|
75
72
|
Устанавливает соединение WebSocket с сервером и выполняет handshake.
|
|
76
73
|
|
|
@@ -173,9 +170,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
173
170
|
fut = self._file_upload_waiters.pop(id_, None)
|
|
174
171
|
if fut and not fut.done():
|
|
175
172
|
fut.set_result(data)
|
|
176
|
-
self.logger.debug(
|
|
177
|
-
"Fulfilled file upload waiter for %s=%s", key, id_
|
|
178
|
-
)
|
|
173
|
+
self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
|
|
179
174
|
|
|
180
175
|
async def _handle_message_notifications(self, data: dict) -> None:
|
|
181
176
|
if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
|
|
@@ -359,9 +354,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
359
354
|
)
|
|
360
355
|
return data
|
|
361
356
|
except Exception:
|
|
362
|
-
self.logger.exception(
|
|
363
|
-
"Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
|
|
364
|
-
)
|
|
357
|
+
self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
|
|
365
358
|
raise RuntimeError("Send and wait failed")
|
|
366
359
|
finally:
|
|
367
360
|
self._pending.pop(msg["seq"], None)
|
|
@@ -442,7 +435,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
442
435
|
else:
|
|
443
436
|
return float(2**retry_count)
|
|
444
437
|
|
|
445
|
-
async def _sync(self) -> None:
|
|
438
|
+
async def _sync(self, user_agent: UserAgentPayload) -> None:
|
|
446
439
|
self.logger.info("Starting initial sync")
|
|
447
440
|
|
|
448
441
|
payload = SyncPayload(
|
|
@@ -453,6 +446,7 @@ class WebSocketMixin(ClientProtocol):
|
|
|
453
446
|
presence_sync=0,
|
|
454
447
|
drafts_sync=0,
|
|
455
448
|
chats_count=40,
|
|
449
|
+
user_agent=user_agent,
|
|
456
450
|
).model_dump(by_alias=True)
|
|
457
451
|
try:
|
|
458
452
|
data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
|
|
@@ -472,10 +466,16 @@ class WebSocketMixin(ClientProtocol):
|
|
|
472
466
|
except Exception:
|
|
473
467
|
self.logger.exception("Error parsing chat entry")
|
|
474
468
|
|
|
469
|
+
for raw_user in raw_payload.get("contacts", []):
|
|
470
|
+
try:
|
|
471
|
+
user = User.from_dict(raw_user)
|
|
472
|
+
if user:
|
|
473
|
+
self.contacts.append(user)
|
|
474
|
+
except Exception:
|
|
475
|
+
self.logger.exception("Error parsing contact entry")
|
|
476
|
+
|
|
475
477
|
if raw_payload.get("profile", {}).get("contact"):
|
|
476
|
-
self.me = Me.from_dict(
|
|
477
|
-
raw_payload.get("profile", {}).get("contact", {})
|
|
478
|
-
)
|
|
478
|
+
self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
|
|
479
479
|
|
|
480
480
|
self.logger.info(
|
|
481
481
|
"Sync completed: dialogs=%d chats=%d channels=%d",
|
pymax/payloads.py
CHANGED
|
@@ -39,6 +39,20 @@ class BaseWebSocketMessage(BaseModel):
|
|
|
39
39
|
payload: dict[str, Any]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class UserAgentPayload(CamelModel):
|
|
43
|
+
device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
|
|
44
|
+
locale: str = Field(default=DEFAULT_LOCALE)
|
|
45
|
+
device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
|
|
46
|
+
os_version: str = Field(default=DEFAULT_OS_VERSION)
|
|
47
|
+
device_name: str = Field(default=DEFAULT_DEVICE_NAME)
|
|
48
|
+
header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
|
|
49
|
+
app_version: str = Field(default=DEFAULT_APP_VERSION)
|
|
50
|
+
screen: str = Field(default=DEFAULT_SCREEN)
|
|
51
|
+
timezone: str = Field(default=DEFAULT_TIMEZONE)
|
|
52
|
+
client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID)
|
|
53
|
+
build_number: int = Field(default=DEFAULT_BUILD_NUMBER)
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
class RequestCodePayload(CamelModel):
|
|
43
57
|
phone: str
|
|
44
58
|
type: AuthType = AuthType.START_AUTH
|
|
@@ -59,6 +73,21 @@ class SyncPayload(CamelModel):
|
|
|
59
73
|
presence_sync: int = 0
|
|
60
74
|
drafts_sync: int = 0
|
|
61
75
|
chats_count: int = 40
|
|
76
|
+
user_agent: UserAgentPayload = Field(
|
|
77
|
+
default_factory=lambda: UserAgentPayload(
|
|
78
|
+
device_type=DEFAULT_DEVICE_TYPE,
|
|
79
|
+
locale=DEFAULT_LOCALE,
|
|
80
|
+
device_locale=DEFAULT_DEVICE_LOCALE,
|
|
81
|
+
os_version=DEFAULT_OS_VERSION,
|
|
82
|
+
device_name=DEFAULT_DEVICE_NAME,
|
|
83
|
+
header_user_agent=DEFAULT_USER_AGENT,
|
|
84
|
+
app_version=DEFAULT_APP_VERSION,
|
|
85
|
+
screen=DEFAULT_SCREEN,
|
|
86
|
+
timezone=DEFAULT_TIMEZONE,
|
|
87
|
+
client_session_id=DEFAULT_CLIENT_SESSION_ID,
|
|
88
|
+
build_number=DEFAULT_BUILD_NUMBER,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
62
91
|
|
|
63
92
|
|
|
64
93
|
class ReplyLink(CamelModel):
|
|
@@ -68,6 +97,7 @@ class ReplyLink(CamelModel):
|
|
|
68
97
|
|
|
69
98
|
class UploadPayload(CamelModel):
|
|
70
99
|
count: int = 1
|
|
100
|
+
profile: bool = False
|
|
71
101
|
|
|
72
102
|
|
|
73
103
|
class AttachPhotoPayload(CamelModel):
|
|
@@ -139,6 +169,8 @@ class ChangeProfilePayload(CamelModel):
|
|
|
139
169
|
first_name: str
|
|
140
170
|
last_name: str | None = None
|
|
141
171
|
description: str | None = None
|
|
172
|
+
photo_token: str | None = None
|
|
173
|
+
avatar_type: str = "USER_AVATAR" # TODO: вынести гада в энам
|
|
142
174
|
|
|
143
175
|
|
|
144
176
|
class ResolveLinkPayload(CamelModel):
|
|
@@ -276,20 +308,6 @@ class RemoveReactionPayload(CamelModel):
|
|
|
276
308
|
message_id: str
|
|
277
309
|
|
|
278
310
|
|
|
279
|
-
class UserAgentPayload(CamelModel):
|
|
280
|
-
device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
|
|
281
|
-
locale: str = Field(default=DEFAULT_LOCALE)
|
|
282
|
-
device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
|
|
283
|
-
os_version: str = Field(default=DEFAULT_OS_VERSION)
|
|
284
|
-
device_name: str = Field(default=DEFAULT_DEVICE_NAME)
|
|
285
|
-
header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
|
|
286
|
-
app_version: str = Field(default=DEFAULT_APP_VERSION)
|
|
287
|
-
screen: str = Field(default=DEFAULT_SCREEN)
|
|
288
|
-
timezone: str = Field(default=DEFAULT_TIMEZONE)
|
|
289
|
-
client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID)
|
|
290
|
-
build_number: int = Field(default=DEFAULT_BUILD_NUMBER)
|
|
291
|
-
|
|
292
|
-
|
|
293
311
|
class ReworkInviteLinkPayload(CamelModel):
|
|
294
312
|
revoke_private_link: bool = True
|
|
295
313
|
chat_id: int
|
pymax/static/constant.py
CHANGED
|
@@ -9,7 +9,7 @@ WEBSOCKET_ORIGIN: Final[Origin] = Origin("https://web.max.ru")
|
|
|
9
9
|
HOST: Final[str] = "api.oneme.ru"
|
|
10
10
|
PORT: Final[int] = 443
|
|
11
11
|
DEFAULT_TIMEOUT: Final[float] = 20.0
|
|
12
|
-
DEFAULT_DEVICE_TYPE: Final[str] = "
|
|
12
|
+
DEFAULT_DEVICE_TYPE: Final[str] = "DESKTOP"
|
|
13
13
|
DEFAULT_LOCALE: Final[str] = "ru"
|
|
14
14
|
DEFAULT_DEVICE_LOCALE: Final[str] = "ru"
|
|
15
15
|
DEFAULT_DEVICE_NAME: Final[str] = "Chrome"
|
pymax/static/enum.py
CHANGED
pymax/types.py
CHANGED
|
@@ -47,7 +47,7 @@ class Name:
|
|
|
47
47
|
def __init__(
|
|
48
48
|
self,
|
|
49
49
|
name: str | None,
|
|
50
|
-
first_name: None,
|
|
50
|
+
first_name: None | str,
|
|
51
51
|
last_name: str | None,
|
|
52
52
|
type: str | None,
|
|
53
53
|
) -> None:
|
|
@@ -90,16 +90,14 @@ class Names(Name):
|
|
|
90
90
|
def __init__(
|
|
91
91
|
self,
|
|
92
92
|
name: str | None,
|
|
93
|
-
first_name: None,
|
|
93
|
+
first_name: None | str,
|
|
94
94
|
last_name: str | None,
|
|
95
95
|
type: str | None,
|
|
96
96
|
) -> None:
|
|
97
97
|
"""
|
|
98
98
|
Синоним для класса Name.
|
|
99
99
|
"""
|
|
100
|
-
super().__init__(
|
|
101
|
-
name=name, first_name=first_name, last_name=last_name, type=type
|
|
102
|
-
)
|
|
100
|
+
super().__init__(name=name, first_name=first_name, last_name=last_name, type=type)
|
|
103
101
|
|
|
104
102
|
|
|
105
103
|
class Contact:
|
|
@@ -219,7 +217,7 @@ class StickerAttach:
|
|
|
219
217
|
def __init__(
|
|
220
218
|
self,
|
|
221
219
|
author_type: str,
|
|
222
|
-
lottie_url: str,
|
|
220
|
+
lottie_url: str | None,
|
|
223
221
|
url: str,
|
|
224
222
|
sticker_id: int,
|
|
225
223
|
tags: list[str] | None,
|
|
@@ -248,7 +246,7 @@ class StickerAttach:
|
|
|
248
246
|
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
249
247
|
return cls(
|
|
250
248
|
author_type=data["authorType"],
|
|
251
|
-
lottie_url=data
|
|
249
|
+
lottie_url=data.get("lottieUrl"),
|
|
252
250
|
url=data["url"],
|
|
253
251
|
sticker_id=data["stickerId"],
|
|
254
252
|
tags=data.get("tags"),
|
|
@@ -443,9 +441,7 @@ class VideoAttach:
|
|
|
443
441
|
|
|
444
442
|
|
|
445
443
|
class FileAttach:
|
|
446
|
-
def __init__(
|
|
447
|
-
self, file_id: int, name: str, size: int, token: str, type: AttachType
|
|
448
|
-
) -> None:
|
|
444
|
+
def __init__(self, file_id: int, name: str, size: int, token: str, type: AttachType) -> None:
|
|
449
445
|
self.file_id = file_id
|
|
450
446
|
self.name = name
|
|
451
447
|
self.size = size
|
|
@@ -553,9 +549,7 @@ class Me:
|
|
|
553
549
|
|
|
554
550
|
|
|
555
551
|
class Element:
|
|
556
|
-
def __init__(
|
|
557
|
-
self, type: FormattingType | str, length: int, from_: int | None = None
|
|
558
|
-
) -> None:
|
|
552
|
+
def __init__(self, type: FormattingType | str, length: int, from_: int | None = None) -> None:
|
|
559
553
|
self.type = type
|
|
560
554
|
self.length = length
|
|
561
555
|
self.from_ = from_
|
|
@@ -566,9 +560,7 @@ class Element:
|
|
|
566
560
|
|
|
567
561
|
@override
|
|
568
562
|
def __repr__(self) -> str:
|
|
569
|
-
return (
|
|
570
|
-
f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})"
|
|
571
|
-
)
|
|
563
|
+
return f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})"
|
|
572
564
|
|
|
573
565
|
@override
|
|
574
566
|
def __str__(self) -> str:
|
|
@@ -591,7 +583,9 @@ class MessageLink:
|
|
|
591
583
|
|
|
592
584
|
@override
|
|
593
585
|
def __repr__(self) -> str:
|
|
594
|
-
return
|
|
586
|
+
return (
|
|
587
|
+
f"MessageLink(chat_id={self.chat_id!r}, message={self.message!r}, type={self.type!r})"
|
|
588
|
+
)
|
|
595
589
|
|
|
596
590
|
@override
|
|
597
591
|
def __str__(self) -> str:
|
|
@@ -636,6 +630,36 @@ class ReactionInfo:
|
|
|
636
630
|
)
|
|
637
631
|
|
|
638
632
|
|
|
633
|
+
class ContactAttach:
|
|
634
|
+
def __init__(
|
|
635
|
+
self, contact_id: int, first_name: str, last_name: str, name: str, photo_url: str
|
|
636
|
+
) -> None:
|
|
637
|
+
self.contact_id = contact_id
|
|
638
|
+
self.first_name = first_name
|
|
639
|
+
self.last_name = last_name
|
|
640
|
+
self.name = name
|
|
641
|
+
self.photo_url = photo_url
|
|
642
|
+
self.type = AttachType.CONTACT
|
|
643
|
+
|
|
644
|
+
@classmethod
|
|
645
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
646
|
+
return cls(
|
|
647
|
+
contact_id=data["contactId"],
|
|
648
|
+
first_name=data["firstName"],
|
|
649
|
+
last_name=data["lastName"],
|
|
650
|
+
name=data["name"],
|
|
651
|
+
photo_url=data["photoUrl"],
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
@override
|
|
655
|
+
def __repr__(self) -> str:
|
|
656
|
+
return f"ContactAttach(contact_id={self.contact_id!r}, first_name={self.first_name!r}, last_name={self.last_name!r}, name={self.name!r}, photo_url={self.photo_url!r})"
|
|
657
|
+
|
|
658
|
+
@override
|
|
659
|
+
def __str__(self) -> str:
|
|
660
|
+
return f"ContactAttach: {self.name}"
|
|
661
|
+
|
|
662
|
+
|
|
639
663
|
class Message:
|
|
640
664
|
def __init__(
|
|
641
665
|
self,
|
|
@@ -658,6 +682,7 @@ class Message:
|
|
|
658
682
|
| ControlAttach
|
|
659
683
|
| StickerAttach
|
|
660
684
|
| AudioAttach
|
|
685
|
+
| ContactAttach
|
|
661
686
|
]
|
|
662
687
|
| None
|
|
663
688
|
),
|
|
@@ -679,7 +704,13 @@ class Message:
|
|
|
679
704
|
def from_dict(cls, data: dict[Any, Any]) -> Self:
|
|
680
705
|
message = data["message"] if data.get("message") else data
|
|
681
706
|
attaches: list[
|
|
682
|
-
PhotoAttach
|
|
707
|
+
PhotoAttach
|
|
708
|
+
| VideoAttach
|
|
709
|
+
| FileAttach
|
|
710
|
+
| ControlAttach
|
|
711
|
+
| StickerAttach
|
|
712
|
+
| AudioAttach
|
|
713
|
+
| ContactAttach
|
|
683
714
|
] = []
|
|
684
715
|
for a in message.get("attaches", []):
|
|
685
716
|
if a["_type"] == AttachType.PHOTO:
|
|
@@ -694,6 +725,8 @@ class Message:
|
|
|
694
725
|
attaches.append(StickerAttach.from_dict(a))
|
|
695
726
|
elif a["_type"] == AttachType.AUDIO:
|
|
696
727
|
attaches.append(AudioAttach.from_dict(a))
|
|
728
|
+
elif a["_type"] == AttachType.CONTACT:
|
|
729
|
+
attaches.append(ContactAttach.from_dict(a))
|
|
697
730
|
link_value = message.get("link")
|
|
698
731
|
if isinstance(link_value, dict):
|
|
699
732
|
link = MessageLink.from_dict(link_value)
|
|
@@ -778,9 +811,7 @@ class Dialog:
|
|
|
778
811
|
join_time=data["joinTime"],
|
|
779
812
|
created=data["created"],
|
|
780
813
|
last_message=(
|
|
781
|
-
Message.from_dict(data["lastMessage"])
|
|
782
|
-
if data.get("lastMessage")
|
|
783
|
-
else None
|
|
814
|
+
Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None
|
|
784
815
|
),
|
|
785
816
|
type=ChatType(data["type"]),
|
|
786
817
|
last_fire_delayed_error_time=data["lastFireDelayedErrorTime"],
|
|
@@ -865,14 +896,10 @@ class Chat:
|
|
|
865
896
|
@classmethod
|
|
866
897
|
def from_dict(cls, data: dict[Any, Any]) -> Self:
|
|
867
898
|
raw_admins = data.get("adminParticipants", {}) or {}
|
|
868
|
-
admin_participants: dict[int, dict[Any, Any]] = {
|
|
869
|
-
int(k): v for k, v in raw_admins.items()
|
|
870
|
-
}
|
|
899
|
+
admin_participants: dict[int, dict[Any, Any]] = {int(k): v for k, v in raw_admins.items()}
|
|
871
900
|
raw_participants = data.get("participants", {}) or {}
|
|
872
901
|
participants: dict[int, int] = {int(k): v for k, v in raw_participants.items()}
|
|
873
|
-
last_msg = (
|
|
874
|
-
Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None
|
|
875
|
-
)
|
|
902
|
+
last_msg = Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None
|
|
876
903
|
return cls(
|
|
877
904
|
participants_count=data.get("participantsCount", 0),
|
|
878
905
|
access=AccessType(data.get("access", AccessType.PUBLIC.value)),
|
|
@@ -1051,7 +1078,9 @@ class Session:
|
|
|
1051
1078
|
|
|
1052
1079
|
@override
|
|
1053
1080
|
def __str__(self) -> str:
|
|
1054
|
-
return
|
|
1081
|
+
return (
|
|
1082
|
+
f"Session: {self.client} from {self.location} at {self.time} (current={self.current})"
|
|
1083
|
+
)
|
|
1055
1084
|
|
|
1056
1085
|
|
|
1057
1086
|
class Folder:
|
|
File without changes
|
|
File without changes
|