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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.2.1
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=Y4sXaUEtK4pVyYiKf5folyhW_xMFUJ3W-5Hc33b8v0E,14892
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=AvFIr34Desq2p4CNWXIngRqeyTBKMT98VmcnI-zvUU0,3462
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=wKF1z1QRw8LcjvM9rzSHWXTK6gPb6sDt2UGiQLvyMf8,8790
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=-GEJVXXlmJiFSTX4ToVNzmSZSrvSRe-BLOwYyRxGkWY,7280
13
- pymax/types.py,sha256=_ARcVXLGHyiGAJKYPd6EU9QDKzz4VwS6kjTu3YEH_u4,35523
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=LqI1QHmZlmtuQ0-4H1MrNeBV-O9SMDMfHT9f4B_2poE,15189
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=Be5L64eNYylGM-NmoxFpQZv1ohsC1Dx_Cs3Om__V96s,6976
22
- pymax/mixins/socket.py,sha256=tdHgd1NwWoEZhHCDd74XLOHFKUq-rladxhXV8Z_-APU,22860
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=m2swhSHIcFG6iABAik_oWxIpHfr0sxZ74I6VRU-iVO8,17809
27
- pymax/static/constant.py,sha256=-qJz65V_ESagy7DYHRj3QsfKwyAyVofWzZh0AqBJtXo,1112
28
- pymax/static/enum.py,sha256=Hk0e6zSbGOJC_9Aw7gNXX3hcavnjzQfDyr8vjW22cFo,4648
29
- maxapi_python-1.2.1.dist-info/METADATA,sha256=JU86mxRn8zdfsRGWFnNvJ6ea6KHKmmADCvDWvuvIZb4,5855
30
- maxapi_python-1.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
- maxapi_python-1.2.1.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
32
- maxapi_python-1.2.1.dist-info/RECORD,,
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
@@ -45,6 +45,7 @@ class ClientProtocol(ABC):
45
45
  self.phone: str
46
46
  self.dialogs: list[Dialog] = []
47
47
  self.channels: list[Channel] = []
48
+ self.contacts: list[User] = []
48
49
  self.me: Me | None = None
49
50
  self.host: str
50
51
  self.port: int
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
- payload = ChangeProfilePayload(
39
- first_name=first_name,
40
- last_name=last_name,
41
- description=description,
42
- ).model_dump(
43
- by_alias=True,
44
- exclude_none=True,
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] = "WEB"
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
@@ -196,6 +196,7 @@ class AttachType(str, Enum):
196
196
  STICKER = "STICKER"
197
197
  AUDIO = "AUDIO"
198
198
  CONTROL = "CONTROL"
199
+ CONTACT = "CONTACT"
199
200
 
200
201
 
201
202
  class FormattingType(str, Enum):
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["lottieUrl"],
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 f"MessageLink(chat_id={self.chat_id!r}, message={self.message!r}, type={self.type!r})"
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 | VideoAttach | FileAttach | ControlAttach | StickerAttach
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 f"Session: {self.client} from {self.location} at {self.time} (current={self.current})"
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: