maxapi-python 1.2.4__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. maxapi_python-2.0.0.dist-info/METADATA +217 -0
  2. maxapi_python-2.0.0.dist-info/RECORD +140 -0
  3. {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +50 -105
  5. pymax/api/__init__.py +17 -0
  6. pymax/api/auth/__init__.py +1 -0
  7. pymax/api/auth/enums.py +17 -0
  8. pymax/api/auth/payloads.py +129 -0
  9. pymax/api/auth/service.py +313 -0
  10. pymax/api/auth/types.py +13 -0
  11. pymax/api/chats/__init__.py +8 -0
  12. pymax/api/chats/enums.py +27 -0
  13. pymax/api/chats/payloads.py +103 -0
  14. pymax/api/chats/service.py +277 -0
  15. pymax/api/facade.py +32 -0
  16. pymax/api/messages/__init__.py +1 -0
  17. pymax/api/messages/enums.py +17 -0
  18. pymax/api/messages/payloads.py +92 -0
  19. pymax/api/messages/service.py +337 -0
  20. pymax/api/models.py +13 -0
  21. pymax/api/response.py +123 -0
  22. pymax/api/self/__init__.py +2 -0
  23. pymax/api/self/enums.py +11 -0
  24. pymax/api/self/payloads.py +41 -0
  25. pymax/api/self/service.py +142 -0
  26. pymax/api/session/__init__.py +1 -0
  27. pymax/api/session/enums.py +10 -0
  28. pymax/api/session/payloads.py +76 -0
  29. pymax/api/session/service.py +72 -0
  30. pymax/api/uploads/__init__.py +1 -0
  31. pymax/api/uploads/models.py +49 -0
  32. pymax/api/uploads/payloads.py +25 -0
  33. pymax/api/uploads/service.py +458 -0
  34. pymax/api/users/__init__.py +2 -0
  35. pymax/api/users/enums.py +12 -0
  36. pymax/api/users/payloads.py +16 -0
  37. pymax/api/users/service.py +124 -0
  38. pymax/app.py +273 -0
  39. pymax/auth/__init__.py +25 -0
  40. pymax/auth/base.py +37 -0
  41. pymax/auth/email.py +0 -0
  42. pymax/auth/models.py +5 -0
  43. pymax/auth/providers.py +127 -0
  44. pymax/auth/qr.py +135 -0
  45. pymax/auth/service.py +25 -0
  46. pymax/auth/sms.py +122 -0
  47. pymax/base.py +204 -0
  48. pymax/client.py +106 -0
  49. pymax/client_web.py +83 -0
  50. pymax/config.py +215 -0
  51. pymax/connection/__init__.py +1 -0
  52. pymax/connection/connection.py +205 -0
  53. pymax/connection/pending.py +46 -0
  54. pymax/connection/readers/__init__.py +2 -0
  55. pymax/connection/readers/base.py +6 -0
  56. pymax/connection/readers/tcp.py +29 -0
  57. pymax/connection/readers/ws.py +14 -0
  58. pymax/dispatch/__init__.py +10 -0
  59. pymax/dispatch/dispatcher.py +222 -0
  60. pymax/dispatch/enums.py +12 -0
  61. pymax/dispatch/mapping.py +73 -0
  62. pymax/dispatch/resolvers.py +52 -0
  63. pymax/dispatch/router.py +216 -0
  64. pymax/exceptions.py +22 -89
  65. pymax/files/__init__.py +9 -0
  66. pymax/files/base.py +82 -0
  67. pymax/files/file.py +76 -0
  68. pymax/files/photo.py +108 -0
  69. pymax/files/static.py +10 -0
  70. pymax/files/video.py +74 -0
  71. pymax/formatting/__init__.py +0 -0
  72. pymax/formatting/markdown.py +217 -0
  73. pymax/infra/__init__.py +1 -0
  74. pymax/infra/auth.py +55 -0
  75. pymax/infra/base.py +15 -0
  76. pymax/infra/chat.py +240 -0
  77. pymax/infra/message.py +252 -0
  78. pymax/infra/protocol.py +9 -0
  79. pymax/infra/self.py +139 -0
  80. pymax/infra/user.py +107 -0
  81. pymax/logging.py +129 -0
  82. pymax/protocol/__init__.py +11 -0
  83. pymax/protocol/base.py +13 -0
  84. pymax/{static/enum.py → protocol/enums.py} +36 -79
  85. pymax/protocol/models.py +33 -0
  86. pymax/protocol/tcp/__init__.py +1 -0
  87. pymax/protocol/tcp/compression.py +97 -0
  88. pymax/protocol/tcp/framing.py +68 -0
  89. pymax/protocol/tcp/payload.py +127 -0
  90. pymax/protocol/tcp/protocol.py +68 -0
  91. pymax/protocol/ws/__init__.py +1 -0
  92. pymax/protocol/ws/protocol.py +27 -0
  93. pymax/py.typed +0 -0
  94. pymax/routers.py +8 -0
  95. pymax/session/__init__.py +3 -0
  96. pymax/session/models.py +11 -0
  97. pymax/session/protocol.py +14 -0
  98. pymax/session/store.py +232 -0
  99. pymax/telemetry/__init__.py +3 -0
  100. pymax/telemetry/navigation.py +181 -0
  101. pymax/telemetry/payloads.py +142 -0
  102. pymax/telemetry/service.py +225 -0
  103. pymax/transport/__init__.py +0 -0
  104. pymax/transport/base.py +14 -0
  105. pymax/transport/tcp.py +93 -0
  106. pymax/transport/websocket.py +50 -0
  107. pymax/types/__init__.py +2 -0
  108. pymax/types/domain/__init__.py +11 -0
  109. pymax/types/domain/attachments/__init__.py +11 -0
  110. pymax/types/domain/attachments/audio.py +35 -0
  111. pymax/types/domain/attachments/call.py +26 -0
  112. pymax/types/domain/attachments/contact.py +32 -0
  113. pymax/types/domain/attachments/control.py +20 -0
  114. pymax/types/domain/attachments/enums.py +27 -0
  115. pymax/types/domain/attachments/file.py +56 -0
  116. pymax/types/domain/attachments/keyboards/__init__.py +1 -0
  117. pymax/types/domain/attachments/keyboards/inline.py +19 -0
  118. pymax/types/domain/attachments/photo.py +45 -0
  119. pymax/types/domain/attachments/share.py +29 -0
  120. pymax/types/domain/attachments/sticker.py +50 -0
  121. pymax/types/domain/attachments/video.py +90 -0
  122. pymax/types/domain/auth.py +161 -0
  123. pymax/types/domain/base.py +17 -0
  124. pymax/types/domain/chat.py +426 -0
  125. pymax/types/domain/element.py +24 -0
  126. pymax/types/domain/enums.py +24 -0
  127. pymax/types/domain/error.py +20 -0
  128. pymax/types/domain/folder.py +74 -0
  129. pymax/types/domain/login.py +35 -0
  130. pymax/types/domain/message.py +378 -0
  131. pymax/types/domain/name.py +20 -0
  132. pymax/types/domain/profile.py +15 -0
  133. pymax/types/domain/session.py +52 -0
  134. pymax/types/domain/sync.py +80 -0
  135. pymax/types/domain/user.py +117 -0
  136. pymax/types/events/__init__.py +3 -0
  137. pymax/types/events/file.py +5 -0
  138. pymax/types/events/message.py +37 -0
  139. pymax/types/events/video.py +5 -0
  140. maxapi_python-1.2.4.dist-info/METADATA +0 -205
  141. maxapi_python-1.2.4.dist-info/RECORD +0 -33
  142. pymax/core.py +0 -390
  143. pymax/crud.py +0 -96
  144. pymax/files.py +0 -138
  145. pymax/filters.py +0 -164
  146. pymax/formatter.py +0 -31
  147. pymax/formatting.py +0 -74
  148. pymax/interfaces.py +0 -552
  149. pymax/mixins/__init__.py +0 -40
  150. pymax/mixins/auth.py +0 -368
  151. pymax/mixins/channel.py +0 -130
  152. pymax/mixins/group.py +0 -458
  153. pymax/mixins/handler.py +0 -285
  154. pymax/mixins/message.py +0 -879
  155. pymax/mixins/scheduler.py +0 -28
  156. pymax/mixins/self.py +0 -259
  157. pymax/mixins/socket.py +0 -297
  158. pymax/mixins/telemetry.py +0 -112
  159. pymax/mixins/user.py +0 -219
  160. pymax/mixins/websocket.py +0 -142
  161. pymax/models.py +0 -8
  162. pymax/navigation.py +0 -187
  163. pymax/payloads.py +0 -367
  164. pymax/protocols.py +0 -123
  165. pymax/static/constant.py +0 -89
  166. pymax/types.py +0 -1220
  167. pymax/utils.py +0 -90
  168. {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
pymax/mixins/scheduler.py DELETED
@@ -1,28 +0,0 @@
1
- import asyncio
2
- import traceback
3
- from collections.abc import Awaitable, Callable
4
- from typing import Any
5
-
6
- from pymax.protocols import ClientProtocol
7
-
8
-
9
- class SchedulerMixin(ClientProtocol):
10
- async def _run_periodic(
11
- self, func: Callable[[], Any | Awaitable[Any]], interval: float
12
- ) -> None:
13
- while True:
14
- try:
15
- result = func()
16
- if asyncio.iscoroutine(result):
17
- await result
18
- except Exception as e:
19
- tb = traceback.format_exc()
20
- self.logger.error(f"Error in scheduled task {func}: {e}")
21
- raise
22
- await asyncio.sleep(interval)
23
-
24
- async def _start_scheduled_tasks(self) -> None:
25
- for func, interval in self._scheduled_tasks:
26
- task = asyncio.create_task(self._run_periodic(func, interval))
27
- self._background_tasks.add(task)
28
- task.add_done_callback(self._background_tasks.discard)
pymax/mixins/self.py DELETED
@@ -1,259 +0,0 @@
1
- import urllib.parse
2
- from http import HTTPStatus
3
- from typing import Any
4
- from urllib.parse import parse_qs, urlparse
5
- from uuid import uuid4
6
-
7
- import aiohttp
8
-
9
- from pymax.exceptions import Error
10
- from pymax.files import Photo
11
- from pymax.payloads import (
12
- ChangeProfilePayload,
13
- CreateFolderPayload,
14
- DeleteFolderPayload,
15
- GetFolderPayload,
16
- UpdateFolderPayload,
17
- UploadPayload,
18
- )
19
- from pymax.protocols import ClientProtocol
20
- from pymax.static.enum import Opcode
21
- from pymax.types import Folder, FolderList, FolderUpdate, Me
22
- from pymax.utils import MixinsUtils
23
-
24
-
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
-
67
- async def change_profile(
68
- self,
69
- first_name: str,
70
- last_name: str | None = None,
71
- description: str | None = None,
72
- photo: Photo | None = None,
73
- ) -> bool:
74
- """
75
- Изменяет информацию профиля текущего пользователя.
76
-
77
- :param first_name: Имя пользователя.
78
- :type first_name: str
79
- :param last_name: Фамилия пользователя. По умолчанию None.
80
- :type last_name: str | None
81
- :param description: Описание профиля. По умолчанию None.
82
- :type description: str | None
83
- :return: True, если профиль успешно изменен.
84
- :rtype: bool
85
- """
86
-
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
- )
109
-
110
- data = await self._send_and_wait(opcode=Opcode.PROFILE, payload=payload)
111
-
112
- if data.get("payload", {}).get("error"):
113
- MixinsUtils.handle_error(data)
114
-
115
- self.me = Me.from_dict(data["payload"]["profile"]["contact"])
116
-
117
- return True
118
-
119
- async def create_folder(
120
- self, title: str, chat_include: list[int], filters: list[Any] | None = None
121
- ) -> FolderUpdate:
122
- """
123
- Создает новую папку для группировки чатов.
124
-
125
- :param title: Название папки.
126
- :type title: str
127
- :param chat_include: Список ID чатов для включения в папку.
128
- :type chat_include: list[int]
129
- :param filters: Список фильтров для папки (опциональный параметр).
130
- :type filters: list[Any] | None
131
- :return: Объект FolderUpdate с информацией о созданной папке.
132
- :rtype: FolderUpdate
133
- """
134
- self.logger.info("Creating folder")
135
-
136
- payload = CreateFolderPayload(
137
- id=str(uuid4()),
138
- title=title,
139
- include=chat_include,
140
- filters=filters or [],
141
- ).model_dump(by_alias=True)
142
-
143
- data = await self._send_and_wait(opcode=Opcode.FOLDERS_UPDATE, payload=payload)
144
-
145
- if data.get("payload", {}).get("error"):
146
- MixinsUtils.handle_error(data)
147
-
148
- return FolderUpdate.from_dict(data.get("payload", {}))
149
-
150
- async def get_folders(self, folder_sync: int = 0) -> FolderList:
151
- """
152
- Получает список всех папок пользователя.
153
-
154
- :param folder_sync: Синхронизационный маркер папок. По умолчанию 0.
155
- :type folder_sync: int
156
- :return: Объект FolderList с информацией о папках.
157
- :rtype: FolderList
158
- """
159
- self.logger.info("Fetching folders")
160
-
161
- payload = GetFolderPayload(folder_sync=folder_sync).model_dump(by_alias=True)
162
-
163
- data = await self._send_and_wait(opcode=Opcode.FOLDERS_GET, payload=payload)
164
-
165
- if data.get("payload", {}).get("error"):
166
- MixinsUtils.handle_error(data)
167
-
168
- return FolderList.from_dict(data.get("payload", {}))
169
-
170
- async def update_folder(
171
- self,
172
- folder_id: str,
173
- title: str,
174
- chat_include: list[int] | None = None,
175
- filters: list[Any] | None = None,
176
- options: list[Any] | None = None,
177
- ) -> FolderUpdate | None:
178
- """
179
- Обновляет параметры существующей папки.
180
-
181
- :param folder_id: Идентификатор папки.
182
- :type folder_id: str
183
- :param title: Название папки.
184
- :type title: str
185
- :param chat_include: Список ID чатов для включения в папку.
186
- :type chat_include: list[int] | None
187
- :param filters: Список фильтров для папки.
188
- :type filters: list[Any] | None
189
- :param options: Список опций для папки.
190
- :type options: list[Any] | None
191
- :return: Объект FolderUpdate с результатом или None.
192
- :rtype: FolderUpdate | None
193
- """
194
- self.logger.info("Updating folder")
195
-
196
- payload = UpdateFolderPayload(
197
- id=folder_id,
198
- title=title,
199
- include=chat_include or [],
200
- filters=filters or [],
201
- options=options or [],
202
- ).model_dump(by_alias=True, exclude_none=True)
203
-
204
- data = await self._send_and_wait(opcode=Opcode.FOLDERS_UPDATE, payload=payload)
205
-
206
- if data.get("payload", {}).get("error"):
207
- MixinsUtils.handle_error(data)
208
-
209
- return FolderUpdate.from_dict(data.get("payload", {}))
210
-
211
- async def delete_folder(self, folder_id: str) -> FolderUpdate | None:
212
- """
213
- Удаляет папку.
214
-
215
- :param folder_id: Идентификатор папки для удаления.
216
- :type folder_id: str
217
- :return: Объект FolderUpdate с результатом операции или None.
218
- :rtype: FolderUpdate | None
219
- """
220
- self.logger.info("Deleting folder")
221
-
222
- payload = DeleteFolderPayload(folder_ids=[folder_id]).model_dump(by_alias=True)
223
- data = await self._send_and_wait(opcode=Opcode.FOLDERS_DELETE, payload=payload)
224
- if data.get("payload", {}).get("error"):
225
- MixinsUtils.handle_error(data)
226
-
227
- return FolderUpdate.from_dict(data.get("payload", {}))
228
-
229
- async def close_all_sessions(self) -> bool:
230
- """
231
- Закрывает все активные сессии, кроме текущей.
232
-
233
- :return: True, если операция выполнена успешно.
234
- :rtype: bool
235
- """
236
- self.logger.info("Closing all other sessions")
237
-
238
- data = await self._send_and_wait(opcode=Opcode.SESSIONS_CLOSE, payload={})
239
-
240
- if data.get("payload", {}).get("error"):
241
- MixinsUtils.handle_error(data)
242
-
243
- return True
244
-
245
- async def logout(self) -> bool:
246
- """
247
- Выполняет выход из текущей сессии.
248
-
249
- :return: True, если выход выполнен успешно.
250
- :rtype: bool
251
- """
252
- self.logger.info("Logging out")
253
-
254
- data = await self._send_and_wait(opcode=Opcode.LOGOUT, payload={})
255
-
256
- if data.get("payload", {}).get("error"):
257
- MixinsUtils.handle_error(data)
258
-
259
- return True
pymax/mixins/socket.py DELETED
@@ -1,297 +0,0 @@
1
- import asyncio
2
- import socket
3
- import ssl
4
- import sys
5
- import time
6
- from collections.abc import Callable
7
- from typing import Any
8
-
9
- import lz4.block
10
- import msgpack
11
- from typing_extensions import override
12
-
13
- from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError
14
- from pymax.filters import BaseFilter
15
- from pymax.interfaces import BaseTransport
16
- from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
- from pymax.protocols import ClientProtocol
18
- from pymax.static.constant import (
19
- DEFAULT_PING_INTERVAL,
20
- DEFAULT_TIMEOUT,
21
- RECV_LOOP_BACKOFF_DELAY,
22
- )
23
- from pymax.static.enum import ChatType, MessageStatus, Opcode
24
- from pymax.types import (
25
- Channel,
26
- Chat,
27
- Dialog,
28
- Me,
29
- Message,
30
- ReactionCounter,
31
- ReactionInfo,
32
- User,
33
- )
34
-
35
-
36
- class SocketMixin(BaseTransport):
37
- @property
38
- def sock(self) -> socket.socket:
39
- if self._socket is None or not self.is_connected:
40
- self.logger.critical("Socket not connected when access attempted")
41
- raise SocketNotConnectedError()
42
- return self._socket
43
-
44
- def _unpack_packet(self, data: bytes) -> dict[str, Any] | None:
45
- ver = int.from_bytes(data[0:1], "big")
46
- cmd = int.from_bytes(data[1:3], "big")
47
- seq = int.from_bytes(data[3:4], "big")
48
- opcode = int.from_bytes(data[4:6], "big")
49
- packed_len = int.from_bytes(data[6:10], "big", signed=False)
50
- comp_flag = packed_len >> 24
51
- payload_length = packed_len & 0xFFFFFF
52
- payload_bytes = data[10 : 10 + payload_length]
53
-
54
- payload = None
55
- if payload_bytes:
56
- if comp_flag != 0:
57
- # TODO: надо выяснить правильный размер распаковки
58
- # uncompressed_size = int.from_bytes(payload_bytes[0:4], "big")
59
- compressed_data = payload_bytes
60
- try:
61
- payload_bytes = lz4.block.decompress(
62
- compressed_data,
63
- uncompressed_size=99999,
64
- )
65
- except lz4.block.LZ4BlockError:
66
- return None
67
- payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
68
-
69
- return {
70
- "ver": ver,
71
- "cmd": cmd,
72
- "seq": seq,
73
- "opcode": opcode,
74
- "payload": payload,
75
- }
76
-
77
- def _pack_packet(
78
- self,
79
- ver: int,
80
- cmd: int,
81
- seq: int,
82
- opcode: int,
83
- payload: dict[str, Any],
84
- ) -> bytes:
85
- ver_b = ver.to_bytes(1, "big")
86
- cmd_b = cmd.to_bytes(2, "big")
87
- seq_b = seq.to_bytes(1, "big")
88
- opcode_b = opcode.to_bytes(2, "big")
89
- payload_bytes: bytes | None = msgpack.packb(payload)
90
- if payload_bytes is None:
91
- payload_bytes = b""
92
- payload_len = len(payload_bytes) & 0xFFFFFF
93
- self.logger.debug("Packing message: payload size=%d bytes", len(payload_bytes))
94
- payload_len_b = payload_len.to_bytes(4, "big")
95
- return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
96
-
97
- async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any]:
98
- """
99
- Устанавливает соединение с сервером и выполняет handshake.
100
-
101
- :param user_agent: Пользовательский агент для handshake. Если None, используется значение по умолчанию.
102
- :type user_agent: UserAgentPayload | None
103
- :return: Результат handshake.
104
- :rtype: dict[str, Any] | None
105
- """
106
- if user_agent is None:
107
- user_agent = UserAgentPayload()
108
- if sys.version_info[:2] == (3, 12):
109
- self.logger.warning(
110
- """
111
- ===============================================================
112
- ⚠️⚠️ \033[0;31mWARNING: Python 3.12 detected!\033[0m ⚠️⚠️
113
- Socket connections may be unstable, SSL issues are possible.
114
- ===============================================================
115
- """
116
- )
117
- self.logger.info("Connecting to socket %s:%s", self.host, self.port)
118
- loop = asyncio.get_running_loop()
119
- raw_sock = await loop.run_in_executor(
120
- None, lambda: socket.create_connection((self.host, self.port))
121
- )
122
- self._socket = self._ssl_context.wrap_socket(raw_sock, server_hostname=self.host)
123
- self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
124
- self.is_connected = True
125
- self._incoming = asyncio.Queue()
126
- self._outgoing = asyncio.Queue()
127
- self._pending = {}
128
- self._recv_task = asyncio.create_task(self._recv_loop())
129
- self._outgoing_task = asyncio.create_task(self._outgoing_loop())
130
- self.logger.info("Socket connected, starting handshake")
131
- return await self._handshake(user_agent)
132
-
133
- def _recv_exactly(self, sock: socket.socket, n: int) -> bytes:
134
- buf = bytearray()
135
- while len(buf) < n:
136
- chunk = sock.recv(n - len(buf))
137
- if not chunk:
138
- return bytes(buf)
139
- buf.extend(chunk)
140
- return bytes(buf)
141
-
142
- async def _parse_header(
143
- self, loop: asyncio.AbstractEventLoop, sock: socket.socket
144
- ) -> bytes | None:
145
- header = await loop.run_in_executor(None, lambda: self._recv_exactly(sock=sock, n=10))
146
- if not header or len(header) < 10:
147
- self.logger.info("Socket connection closed; exiting recv loop")
148
- self.is_connected = False
149
- try:
150
- sock.close()
151
- except Exception:
152
- return None
153
-
154
- return header
155
-
156
- async def _recv_data(
157
- self, loop: asyncio.AbstractEventLoop, header: bytes, sock: socket.socket
158
- ) -> list[dict[str, Any]] | None:
159
- packed_len = int.from_bytes(header[6:10], "big", signed=False)
160
- payload_length = packed_len & 0xFFFFFF
161
- remaining = payload_length
162
- payload = bytearray()
163
-
164
- while remaining > 0:
165
- min_read = min(remaining, 8192)
166
- chunk = await loop.run_in_executor(None, lambda: self._recv_exactly(sock, min_read))
167
- if not chunk:
168
- self.logger.error("Connection closed while reading payload")
169
- break
170
- payload.extend(chunk)
171
- remaining -= len(chunk)
172
-
173
- if remaining > 0:
174
- self.logger.error("Incomplete payload received; skipping packet")
175
- return None
176
-
177
- raw = header + payload
178
- if len(raw) < 10 + payload_length:
179
- self.logger.error(
180
- "Incomplete packet: expected %d bytes, got %d",
181
- 10 + payload_length,
182
- len(raw),
183
- )
184
- await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
185
- return None
186
-
187
- data = self._unpack_packet(raw)
188
- if not data:
189
- self.logger.warning("Failed to unpack packet, skipping")
190
- return None
191
-
192
- payload_objs = data.get("payload")
193
- return (
194
- [{**data, "payload": obj} for obj in payload_objs]
195
- if isinstance(payload_objs, list)
196
- else [data]
197
- )
198
-
199
- async def _recv_loop(self) -> None:
200
- if self._socket is None:
201
- self.logger.warning("Recv loop started without socket instance")
202
- return
203
-
204
- sock = self._socket
205
- loop = asyncio.get_running_loop()
206
-
207
- while True:
208
- try:
209
- header = await self._parse_header(loop, sock)
210
-
211
- if not header:
212
- break
213
-
214
- datas = await self._recv_data(loop, header, sock)
215
-
216
- if not datas:
217
- continue
218
-
219
- for data_item in datas:
220
- seq = data_item.get("seq")
221
-
222
- if self._handle_pending(seq, data_item):
223
- continue
224
-
225
- if self._incoming is not None:
226
- await self._handle_incoming_queue(data_item)
227
-
228
- await self._dispatch_incoming(data_item)
229
-
230
- except asyncio.CancelledError:
231
- self.logger.debug("Recv loop cancelled")
232
- raise
233
- except Exception:
234
- self.logger.exception("Error in recv_loop; backing off briefly")
235
- await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
236
-
237
- @override
238
- async def _send_and_wait(
239
- self,
240
- opcode: Opcode,
241
- payload: dict[str, Any],
242
- cmd: int = 0,
243
- timeout: float = DEFAULT_TIMEOUT,
244
- ) -> dict[str, Any]:
245
- if not self.is_connected or self._socket is None:
246
- raise SocketNotConnectedError
247
-
248
- sock = self.sock
249
- msg = self._make_message(opcode, payload, cmd)
250
- loop = asyncio.get_running_loop()
251
- fut: asyncio.Future[dict[str, Any]] = loop.create_future()
252
- self._pending[msg["seq"]] = fut
253
- try:
254
- self.logger.debug(
255
- "Sending frame opcode=%s cmd=%s seq=%s",
256
- opcode,
257
- cmd,
258
- msg["seq"],
259
- )
260
- packet = self._pack_packet(
261
- msg["ver"],
262
- msg["cmd"],
263
- msg["seq"],
264
- msg["opcode"],
265
- msg["payload"],
266
- )
267
- await loop.run_in_executor(None, lambda: sock.sendall(packet))
268
- data = await asyncio.wait_for(fut, timeout=timeout)
269
- self.logger.debug(
270
- "Received frame for seq=%s opcode=%s",
271
- data.get("seq"),
272
- data.get("opcode"),
273
- )
274
- return data
275
-
276
- except (ssl.SSLEOFError, ssl.SSLError, ConnectionError) as conn_err:
277
- self.logger.warning("Connection lost, reconnecting...")
278
- self.is_connected = False
279
- try:
280
- await self.connect(self.user_agent)
281
- except Exception as exc:
282
- self.logger.exception("Reconnect failed")
283
- raise exc from conn_err
284
- raise SocketNotConnectedError from conn_err
285
- except Exception as exc:
286
- self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
287
- raise SocketSendError from exc
288
-
289
- finally:
290
- self._pending.pop(msg["seq"], None)
291
-
292
- @override
293
- async def _get_chat(self, chat_id: int) -> Chat | None:
294
- for chat in self.chats:
295
- if chat.id == chat_id:
296
- return chat
297
- return None
pymax/mixins/telemetry.py DELETED
@@ -1,112 +0,0 @@
1
- import asyncio
2
- import random
3
- import time
4
-
5
- from pymax.exceptions import Error
6
- from pymax.navigation import Navigation
7
- from pymax.payloads import (
8
- NavigationEventParams,
9
- NavigationEventPayload,
10
- NavigationPayload,
11
- )
12
- from pymax.protocols import ClientProtocol
13
- from pymax.static.enum import Opcode
14
-
15
-
16
- class TelemetryMixin(ClientProtocol):
17
- async def _send_navigation_event(self, events: list[NavigationEventPayload]) -> None:
18
- try:
19
- payload = NavigationPayload(events=events).model_dump(by_alias=True)
20
- data = await self._send_and_wait(
21
- opcode=Opcode.LOG,
22
- payload=payload,
23
- )
24
- payload_data = data.get("payload", {})
25
- if payload_data and payload_data.get("error"):
26
- error = payload_data.get("error")
27
- self.logger.error("Navigation event error: %s", error)
28
- except Exception:
29
- self.logger.warning("Failed to send navigation event", exc_info=True)
30
- return
31
-
32
- async def _send_cold_start(self) -> None:
33
- if not self.me:
34
- self.logger.error("Cannot send cold start, user not set")
35
- return
36
-
37
- payload = NavigationEventPayload(
38
- event="COLD_START",
39
- time=int(time.time() * 1000),
40
- user_id=self.me.id,
41
- params=NavigationEventParams(
42
- action_id=self._action_id,
43
- screen_to=Navigation.get_screen_id("chats_list_tab"),
44
- screen_from=1,
45
- source_id=1,
46
- session_id=self._session_id,
47
- ),
48
- )
49
-
50
- self._action_id += 1
51
-
52
- await self._send_navigation_event([payload])
53
-
54
- async def _send_random_navigation(self) -> None:
55
- if not self.me:
56
- self.logger.error("Cannot send navigation event, user not set")
57
- return
58
-
59
- screen_from = self._current_screen
60
- screen_to = Navigation.get_random_navigation(screen_from)
61
-
62
- self._action_id += 1
63
- self._current_screen = screen_to
64
-
65
- payload = NavigationEventPayload(
66
- event="NAV",
67
- time=int(time.time() * 1000),
68
- user_id=self.me.id,
69
- params=NavigationEventParams(
70
- action_id=self._action_id,
71
- screen_from=Navigation.get_screen_id(screen_from),
72
- screen_to=Navigation.get_screen_id(screen_to),
73
- source_id=1,
74
- session_id=self._session_id,
75
- ),
76
- )
77
-
78
- await self._send_navigation_event([payload])
79
-
80
- def _get_random_sleep_time(self) -> int:
81
- # TODO: вынести в статик
82
- sleep_options = [
83
- (1000, 3000),
84
- (300, 1000),
85
- (60, 300),
86
- (5, 60),
87
- (5, 20),
88
- ]
89
-
90
- weights = [0.05, 0.10, 0.15, 0.20, 0.50]
91
-
92
- low, high = random.choices( # nosec B311
93
- sleep_options, weights=weights, k=1
94
- )[0]
95
- return random.randint(low, high) # nosec B311
96
-
97
- async def _start(self) -> None:
98
- if not self.is_connected:
99
- self.logger.error("Cannot start telemetry, client not connected")
100
- return
101
-
102
- await self._send_cold_start()
103
-
104
- try:
105
- while self.is_connected:
106
- await self._send_random_navigation()
107
- await asyncio.sleep(self._get_random_sleep_time())
108
-
109
- except asyncio.CancelledError:
110
- self.logger.debug("Telemetry task cancelled")
111
- except Exception:
112
- self.logger.warning("Telemetry task failed", exc_info=True)