maxapi-python 1.1.17__py3-none-any.whl → 1.1.19__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.1.17.dist-info → maxapi_python-1.1.19.dist-info}/METADATA +1 -1
- {maxapi_python-1.1.17.dist-info → maxapi_python-1.1.19.dist-info}/RECORD +11 -11
- pymax/core.py +114 -58
- pymax/interfaces.py +6 -0
- pymax/mixins/auth.py +56 -54
- pymax/mixins/handler.py +49 -1
- pymax/mixins/socket.py +72 -3
- pymax/mixins/websocket.py +117 -59
- pymax/payloads.py +4 -4
- {maxapi_python-1.1.17.dist-info → maxapi_python-1.1.19.dist-info}/WHEEL +0 -0
- {maxapi_python-1.1.17.dist-info → maxapi_python-1.1.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
-
pymax/core.py,sha256=
|
|
2
|
+
pymax/core.py,sha256=QEERtU32ODmEIf1XFCDhD18a29yRW5TB97NjtusMUCg,15644
|
|
3
3
|
pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
|
|
4
4
|
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
5
|
pymax/files.py,sha256=dRuOpvoJZWiH4xa_HVGyqQ-_Zzj-sVikElHmrPjwgs0,3166
|
|
6
6
|
pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
|
|
7
7
|
pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
|
|
8
8
|
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
9
|
-
pymax/interfaces.py,sha256=
|
|
9
|
+
pymax/interfaces.py,sha256=OqYTiTUs6HqTkx3I3CU34q-En8nLS1Rx2hRlmEq65V4,3643
|
|
10
10
|
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
11
|
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
-
pymax/payloads.py,sha256=
|
|
12
|
+
pymax/payloads.py,sha256=S1dJwDPanFfIdY_NlXN2epVyibmmL9bceltgLVmEtTA,6304
|
|
13
13
|
pymax/types.py,sha256=RaLn9bUpkxO0SKbDMIHnoFeqV6gqOl2pKDNCa2LxTRI,32102
|
|
14
14
|
pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
|
|
15
|
-
pymax/mixins/auth.py,sha256=
|
|
15
|
+
pymax/mixins/auth.py,sha256=zkMkALjvb2427g1DMcvUIKsOQgw1y8d-tEo3jlyNiWQ,6744
|
|
16
16
|
pymax/mixins/channel.py,sha256=dMuJRnbqZisN8kcPFCCe1sIOOBQl2uT4P49PpZXcoKE,5206
|
|
17
17
|
pymax/mixins/group.py,sha256=7oa7RpiqnlcnAsnIHOfSiujNYAzUZ9lkTy9NGW5KVUE,8654
|
|
18
|
-
pymax/mixins/handler.py,sha256=
|
|
18
|
+
pymax/mixins/handler.py,sha256=TuO5bHK6qwJ-Wdh3lMg6uWaG6IwNPOUTCnMw2PkCFjA,5872
|
|
19
19
|
pymax/mixins/message.py,sha256=ezU9d6r4MkYjH67gZ9SFLYPKqo4Nb6lswqDsEW5p-Bg,22329
|
|
20
20
|
pymax/mixins/self.py,sha256=tDQrUdUpsCu7qGkWLtKxTfTHPHU5_r3qsn-eptHG2KY,1198
|
|
21
|
-
pymax/mixins/socket.py,sha256=
|
|
21
|
+
pymax/mixins/socket.py,sha256=VsQSDyzP2xvQp-V8SgeOBn3vVQfWwi0m_wMIvlzu2lY,25517
|
|
22
22
|
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
23
23
|
pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
|
|
24
24
|
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
25
|
-
pymax/mixins/websocket.py,sha256=
|
|
25
|
+
pymax/mixins/websocket.py,sha256=Rfn3PFfmey2u3e3xnebNeT9VoxBF9Dq20xM8ljaDiII,19286
|
|
26
26
|
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
27
27
|
pymax/static/enum.py,sha256=c_QaLU0Ephe4SuKFIpwpmrf_HCutc34JJ6o4Ik1E6_g,4582
|
|
28
|
-
maxapi_python-1.1.
|
|
29
|
-
maxapi_python-1.1.
|
|
30
|
-
maxapi_python-1.1.
|
|
31
|
-
maxapi_python-1.1.
|
|
28
|
+
maxapi_python-1.1.19.dist-info/METADATA,sha256=2tRU1Um8ZR6LHzm83ze-bWs0V5FI6ru6DNYHbi4JBw8,6245
|
|
29
|
+
maxapi_python-1.1.19.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
30
|
+
maxapi_python-1.1.19.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
31
|
+
maxapi_python-1.1.19.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -7,7 +7,7 @@ import time
|
|
|
7
7
|
import traceback
|
|
8
8
|
from collections.abc import Awaitable
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
11
11
|
|
|
12
12
|
from typing_extensions import override
|
|
13
13
|
|
|
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
|
|
|
29
29
|
import websockets
|
|
30
30
|
|
|
31
31
|
from .filters import Filter
|
|
32
|
-
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
32
|
+
from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
logger = logging.getLogger(__name__)
|
|
@@ -143,6 +143,10 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
143
143
|
tuple[Callable[[Message], Any], Filter | None]
|
|
144
144
|
] = []
|
|
145
145
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
146
|
+
self._on_reaction_change_handlers: list[
|
|
147
|
+
tuple[Callable[[str, int, ReactionInfo], Any]]
|
|
148
|
+
] = []
|
|
149
|
+
self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
|
|
146
150
|
|
|
147
151
|
self._ssl_context = ssl.create_default_context()
|
|
148
152
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
@@ -232,6 +236,97 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
232
236
|
self._background_tasks.add(task)
|
|
233
237
|
return task
|
|
234
238
|
|
|
239
|
+
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
240
|
+
if sync:
|
|
241
|
+
await self._sync()
|
|
242
|
+
|
|
243
|
+
if self._on_start_handler:
|
|
244
|
+
self.logger.debug("Calling on_start handler")
|
|
245
|
+
result = self._on_start_handler()
|
|
246
|
+
if asyncio.iscoroutine(result):
|
|
247
|
+
await self._safe_execute(result, context="on_start handler")
|
|
248
|
+
|
|
249
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
250
|
+
ping_task.add_done_callback(self._log_task_exception)
|
|
251
|
+
self._background_tasks.add(ping_task)
|
|
252
|
+
|
|
253
|
+
if self._send_fake_telemetry:
|
|
254
|
+
telemetry_task = asyncio.create_task(self._start())
|
|
255
|
+
telemetry_task.add_done_callback(self._log_task_exception)
|
|
256
|
+
self._background_tasks.add(telemetry_task)
|
|
257
|
+
|
|
258
|
+
async def _cleanup_client(self):
|
|
259
|
+
for task in list(self._background_tasks):
|
|
260
|
+
task.cancel()
|
|
261
|
+
try:
|
|
262
|
+
await task
|
|
263
|
+
except asyncio.CancelledError:
|
|
264
|
+
pass
|
|
265
|
+
except Exception:
|
|
266
|
+
self.logger.debug(
|
|
267
|
+
"Background task raised during cancellation", exc_info=True
|
|
268
|
+
)
|
|
269
|
+
self._background_tasks.discard(task)
|
|
270
|
+
|
|
271
|
+
if self._recv_task:
|
|
272
|
+
self._recv_task.cancel()
|
|
273
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
274
|
+
await self._recv_task
|
|
275
|
+
self._recv_task = None
|
|
276
|
+
|
|
277
|
+
if self._outgoing_task:
|
|
278
|
+
self._outgoing_task.cancel()
|
|
279
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
280
|
+
await self._outgoing_task
|
|
281
|
+
self._outgoing_task = None
|
|
282
|
+
|
|
283
|
+
for fut in self._pending.values():
|
|
284
|
+
if not fut.done():
|
|
285
|
+
fut.set_exception(WebSocketNotConnectedError)
|
|
286
|
+
self._pending.clear()
|
|
287
|
+
|
|
288
|
+
if self._ws:
|
|
289
|
+
try:
|
|
290
|
+
await self._ws.close()
|
|
291
|
+
except Exception:
|
|
292
|
+
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
293
|
+
self._ws = None
|
|
294
|
+
|
|
295
|
+
self.is_connected = False
|
|
296
|
+
self.logger.info("Client start() cleaned up")
|
|
297
|
+
|
|
298
|
+
async def login_with_code(
|
|
299
|
+
self, temp_token: str, code: str, start: bool = False
|
|
300
|
+
) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
temp_token (str): Временный токен, полученный из request_code()
|
|
306
|
+
code (str): Код, введённый пользователем
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
str: Токен для входа
|
|
310
|
+
"""
|
|
311
|
+
resp = await self._send_code(code, temp_token)
|
|
312
|
+
token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
|
|
313
|
+
self._token = token
|
|
314
|
+
self._database.update_auth_token(self._device_id, token)
|
|
315
|
+
if start:
|
|
316
|
+
while True:
|
|
317
|
+
try:
|
|
318
|
+
await self._post_login_tasks()
|
|
319
|
+
await self._wait_forever()
|
|
320
|
+
except Exception:
|
|
321
|
+
self.logger.exception("Error during post-login tasks")
|
|
322
|
+
finally:
|
|
323
|
+
await self._cleanup_client()
|
|
324
|
+
|
|
325
|
+
self.logger.info("Reconnecting after post-login tasks failure")
|
|
326
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
327
|
+
else:
|
|
328
|
+
self.logger.info("Login successful, token saved to database, exiting...")
|
|
329
|
+
|
|
235
330
|
async def start(self) -> None:
|
|
236
331
|
"""
|
|
237
332
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
@@ -242,7 +337,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
242
337
|
while True:
|
|
243
338
|
try:
|
|
244
339
|
self.logger.info("Client starting")
|
|
245
|
-
await self.
|
|
340
|
+
await self.connect(self.user_agent)
|
|
246
341
|
|
|
247
342
|
if self.registration:
|
|
248
343
|
if not self.first_name:
|
|
@@ -257,70 +352,19 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
257
352
|
else:
|
|
258
353
|
await self._sync()
|
|
259
354
|
|
|
260
|
-
|
|
261
|
-
self.logger.debug("Calling on_start handler")
|
|
262
|
-
result = self._on_start_handler()
|
|
263
|
-
if asyncio.iscoroutine(result):
|
|
264
|
-
await self._safe_execute(result, context="on_start handler")
|
|
265
|
-
|
|
266
|
-
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
267
|
-
ping_task.add_done_callback(self._log_task_exception)
|
|
268
|
-
self._background_tasks.add(ping_task)
|
|
269
|
-
|
|
270
|
-
if self._send_fake_telemetry:
|
|
271
|
-
telemetry_task = asyncio.create_task(self._start())
|
|
272
|
-
telemetry_task.add_done_callback(self._log_task_exception)
|
|
273
|
-
self._background_tasks.add(telemetry_task)
|
|
355
|
+
await self._post_login_tasks(sync=False)
|
|
274
356
|
|
|
275
357
|
await self._wait_forever()
|
|
276
358
|
self.logger.info("WebSocket closed (wait_forever exited)")
|
|
277
359
|
|
|
278
|
-
except Exception:
|
|
360
|
+
except Exception as e:
|
|
279
361
|
self.logger.exception("Client start iteration failed")
|
|
362
|
+
raise e
|
|
280
363
|
|
|
281
364
|
finally:
|
|
282
365
|
self.logger.debug("Cleaning up background tasks and pending futures")
|
|
283
366
|
|
|
284
|
-
|
|
285
|
-
task.cancel()
|
|
286
|
-
try:
|
|
287
|
-
await task
|
|
288
|
-
except asyncio.CancelledError:
|
|
289
|
-
pass
|
|
290
|
-
except Exception:
|
|
291
|
-
self.logger.debug(
|
|
292
|
-
"Background task raised during cancellation", exc_info=True
|
|
293
|
-
)
|
|
294
|
-
self._background_tasks.discard(task)
|
|
295
|
-
|
|
296
|
-
if self._recv_task:
|
|
297
|
-
self._recv_task.cancel()
|
|
298
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
299
|
-
await self._recv_task
|
|
300
|
-
self._recv_task = None
|
|
301
|
-
|
|
302
|
-
if self._outgoing_task:
|
|
303
|
-
self._outgoing_task.cancel()
|
|
304
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
305
|
-
await self._outgoing_task
|
|
306
|
-
self._outgoing_task = None
|
|
307
|
-
|
|
308
|
-
for fut in self._pending.values():
|
|
309
|
-
if not fut.done():
|
|
310
|
-
fut.set_exception(WebSocketNotConnectedError)
|
|
311
|
-
self._pending.clear()
|
|
312
|
-
|
|
313
|
-
if self._ws:
|
|
314
|
-
try:
|
|
315
|
-
await self._ws.close()
|
|
316
|
-
except Exception:
|
|
317
|
-
self.logger.debug(
|
|
318
|
-
"Error closing ws during cleanup", exc_info=True
|
|
319
|
-
)
|
|
320
|
-
self._ws = None
|
|
321
|
-
|
|
322
|
-
self.is_connected = False
|
|
323
|
-
self.logger.info("Client start() cleaned up")
|
|
367
|
+
await self._cleanup_client()
|
|
324
368
|
|
|
325
369
|
if not self.reconnect:
|
|
326
370
|
self.logger.info("Reconnect disabled — exiting start()")
|
|
@@ -329,6 +373,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
329
373
|
self.logger.info("Reconnect enabled — restarting client")
|
|
330
374
|
await asyncio.sleep(self.reconnect_delay)
|
|
331
375
|
|
|
376
|
+
async def idle(self):
|
|
377
|
+
await asyncio.Event().wait()
|
|
378
|
+
|
|
379
|
+
async def __aenter__(self) -> Self:
|
|
380
|
+
self._create_safe_task(self.start(), name="start")
|
|
381
|
+
while not self.is_connected:
|
|
382
|
+
await asyncio.sleep(0.05)
|
|
383
|
+
return self
|
|
384
|
+
|
|
385
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
386
|
+
await self.close()
|
|
387
|
+
|
|
332
388
|
|
|
333
389
|
class SocketMaxClient(SocketMixin, MaxClient):
|
|
334
390
|
@override
|
pymax/interfaces.py
CHANGED
|
@@ -18,6 +18,8 @@ from .types import Channel, Chat, Dialog, Me, Message, User
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from uuid import UUID
|
|
20
20
|
|
|
21
|
+
from pymax.types import ReactionInfo
|
|
22
|
+
|
|
21
23
|
from .crud import Database
|
|
22
24
|
|
|
23
25
|
|
|
@@ -69,6 +71,10 @@ class ClientProtocol(ABC):
|
|
|
69
71
|
self._on_message_delete_handlers: list[
|
|
70
72
|
tuple[Callable[[Message], Any], Filter | None]
|
|
71
73
|
] = []
|
|
74
|
+
self._on_reaction_change_handlers: list[
|
|
75
|
+
tuple[Callable[[str, int, ReactionInfo], Any]]
|
|
76
|
+
] = []
|
|
77
|
+
self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
|
|
72
78
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
73
79
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
74
80
|
self._ssl_context: ssl.SSLContext
|
pymax/mixins/auth.py
CHANGED
|
@@ -15,71 +15,73 @@ class AuthMixin(ClientProtocol):
|
|
|
15
15
|
def _check_phone(self) -> bool:
|
|
16
16
|
return bool(re.match(PHONE_REGEX, self.phone))
|
|
17
17
|
|
|
18
|
-
async def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
self.logger.info("Requesting auth code")
|
|
18
|
+
async def request_code(self, phone: str, language: str = "ru") -> str:
|
|
19
|
+
"""
|
|
20
|
+
Запрашивает код аутентификации для указанного номера телефона и возвращает временный токен.
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
).model_dump(by_alias=True)
|
|
22
|
+
Note:
|
|
23
|
+
Использовать только в кастомном login flow.
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
if data.get("payload", {}).get("error"):
|
|
32
|
-
MixinsUtils.handle_error(data)
|
|
25
|
+
Args:
|
|
26
|
+
phone (str): Номер телефона в международном формате.
|
|
27
|
+
language (str, optional): Язык для сообщения с кодом. По умолчанию "ru".
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
Returns:
|
|
30
|
+
str: Временный токен для дальнейшей аутентификации.
|
|
31
|
+
"""
|
|
32
|
+
self.logger.info("Requesting auth code")
|
|
33
|
+
|
|
34
|
+
payload = RequestCodePayload(
|
|
35
|
+
phone=phone, type=AuthType.START_AUTH, language=language
|
|
36
|
+
).model_dump(by_alias=True)
|
|
37
|
+
|
|
38
|
+
data = await self._send_and_wait(opcode=Opcode.AUTH_REQUEST, payload=payload)
|
|
39
|
+
|
|
40
|
+
if data.get("payload", {}).get("error"):
|
|
41
|
+
MixinsUtils.handle_error(data)
|
|
42
|
+
|
|
43
|
+
self.logger.debug(
|
|
44
|
+
"Code request response opcode=%s seq=%s",
|
|
45
|
+
data.get("opcode"),
|
|
46
|
+
data.get("seq"),
|
|
47
|
+
)
|
|
48
|
+
payload_data = data.get("payload")
|
|
49
|
+
if isinstance(payload_data, dict):
|
|
50
|
+
return payload_data["token"]
|
|
51
|
+
else:
|
|
52
|
+
self.logger.error("Invalid payload data received")
|
|
53
|
+
raise ValueError("Invalid payload data received")
|
|
48
54
|
|
|
49
55
|
async def _send_code(self, code: str, token: str) -> dict[str, Any]:
|
|
50
|
-
|
|
51
|
-
self.logger.info("Sending verification code")
|
|
56
|
+
self.logger.info("Sending verification code")
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
payload = SendCodePayload(
|
|
59
|
+
token=token,
|
|
60
|
+
verify_code=code,
|
|
61
|
+
auth_token_type=AuthType.CHECK_CODE,
|
|
62
|
+
).model_dump(by_alias=True)
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
if data.get("payload", {}).get("error"):
|
|
61
|
-
MixinsUtils.handle_error(data)
|
|
64
|
+
data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
self.logger.error("
|
|
76
|
-
raise
|
|
66
|
+
if data.get("payload", {}).get("error"):
|
|
67
|
+
MixinsUtils.handle_error(data)
|
|
68
|
+
|
|
69
|
+
self.logger.debug(
|
|
70
|
+
"Send code response opcode=%s seq=%s",
|
|
71
|
+
data.get("opcode"),
|
|
72
|
+
data.get("seq"),
|
|
73
|
+
)
|
|
74
|
+
payload_data = data.get("payload")
|
|
75
|
+
if isinstance(payload_data, dict):
|
|
76
|
+
return payload_data
|
|
77
|
+
else:
|
|
78
|
+
self.logger.error("Invalid payload data received")
|
|
79
|
+
raise ValueError("Invalid payload data received")
|
|
77
80
|
|
|
78
81
|
async def _login(self) -> None:
|
|
79
82
|
self.logger.info("Starting login flow")
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
temp_token = request_code_payload.get("token")
|
|
84
|
+
temp_token = await self.request_code(self.phone)
|
|
83
85
|
if not temp_token or not isinstance(temp_token, str):
|
|
84
86
|
self.logger.critical("Failed to request code: token missing")
|
|
85
87
|
raise ValueError("Failed to request code")
|
|
@@ -136,7 +138,7 @@ class AuthMixin(ClientProtocol):
|
|
|
136
138
|
async def _register(self, first_name: str, last_name: str | None = None) -> None:
|
|
137
139
|
self.logger.info("Starting registration flow")
|
|
138
140
|
|
|
139
|
-
request_code_payload = await self.
|
|
141
|
+
request_code_payload = await self.request_code(self.phone)
|
|
140
142
|
temp_token = request_code_payload.get("token")
|
|
141
143
|
|
|
142
144
|
if not temp_token or not isinstance(temp_token, str):
|
pymax/mixins/handler.py
CHANGED
|
@@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from pymax.interfaces import ClientProtocol, Filter
|
|
5
|
-
from pymax.types import Message
|
|
5
|
+
from pymax.types import Chat, Message, ReactionInfo
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class HandlerMixin(ClientProtocol):
|
|
@@ -85,6 +85,39 @@ class HandlerMixin(ClientProtocol):
|
|
|
85
85
|
|
|
86
86
|
return decorator
|
|
87
87
|
|
|
88
|
+
def on_reaction_change(
|
|
89
|
+
self,
|
|
90
|
+
handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]],
|
|
91
|
+
) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Устанавливает обработчик изменения реакций на сообщения.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
handler: Функция или coroutine с аргументами (message_id: str, chat_id: int, reaction_info: ReactionInfo).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Установленный обработчик.
|
|
100
|
+
"""
|
|
101
|
+
self._on_reaction_change_handlers.append((handler,))
|
|
102
|
+
self.logger.debug("on_reaction_change handler set: %r", handler)
|
|
103
|
+
return handler
|
|
104
|
+
|
|
105
|
+
def on_chat_update(
|
|
106
|
+
self, handler: Callable[[Chat], Any | Awaitable[Any]]
|
|
107
|
+
) -> Callable[[Chat], Any | Awaitable[Any]]:
|
|
108
|
+
"""
|
|
109
|
+
Устанавливает обработчик обновления информации о чате.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
handler: Функция или coroutine с аргументом (chat: Chat).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Установленный обработчик.
|
|
116
|
+
"""
|
|
117
|
+
self._on_chat_update_handlers.append((handler,))
|
|
118
|
+
self.logger.debug("on_chat_update handler set: %r", handler)
|
|
119
|
+
return handler
|
|
120
|
+
|
|
88
121
|
def on_start(
|
|
89
122
|
self, handler: Callable[[], Any | Awaitable[Any]]
|
|
90
123
|
) -> Callable[[], Any | Awaitable[Any]]:
|
|
@@ -116,3 +149,18 @@ class HandlerMixin(ClientProtocol):
|
|
|
116
149
|
self.logger.debug("add_on_start_handler (alias) used")
|
|
117
150
|
self._on_start_handler = handler
|
|
118
151
|
return handler
|
|
152
|
+
|
|
153
|
+
def add_reaction_change_handler(
|
|
154
|
+
self,
|
|
155
|
+
handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]],
|
|
156
|
+
) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]:
|
|
157
|
+
self.logger.debug("add_reaction_change_handler (alias) used")
|
|
158
|
+
self._on_reaction_change_handlers.append((handler,))
|
|
159
|
+
return handler
|
|
160
|
+
|
|
161
|
+
def add_chat_update_handler(
|
|
162
|
+
self, handler: Callable[[Chat], Any | Awaitable[Any]]
|
|
163
|
+
) -> Callable[[Chat], Any | Awaitable[Any]]:
|
|
164
|
+
self.logger.debug("add_chat_update_handler (alias) used")
|
|
165
|
+
self._on_chat_update_handlers.append((handler,))
|
|
166
|
+
return handler
|
pymax/mixins/socket.py
CHANGED
|
@@ -11,7 +11,7 @@ import msgpack
|
|
|
11
11
|
from typing_extensions import override
|
|
12
12
|
|
|
13
13
|
from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError
|
|
14
|
-
from pymax.filters import Filter
|
|
14
|
+
from pymax.filters import Filter
|
|
15
15
|
from pymax.interfaces import ClientProtocol
|
|
16
16
|
from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
|
|
17
17
|
from pymax.static.constant import (
|
|
@@ -19,8 +19,16 @@ from pymax.static.constant import (
|
|
|
19
19
|
DEFAULT_TIMEOUT,
|
|
20
20
|
RECV_LOOP_BACKOFF_DELAY,
|
|
21
21
|
)
|
|
22
|
-
from pymax.static.enum import MessageStatus, Opcode
|
|
23
|
-
from pymax.types import
|
|
22
|
+
from pymax.static.enum import ChatType, MessageStatus, Opcode
|
|
23
|
+
from pymax.types import (
|
|
24
|
+
Channel,
|
|
25
|
+
Chat,
|
|
26
|
+
Dialog,
|
|
27
|
+
Me,
|
|
28
|
+
Message,
|
|
29
|
+
ReactionCounter,
|
|
30
|
+
ReactionInfo,
|
|
31
|
+
)
|
|
24
32
|
|
|
25
33
|
|
|
26
34
|
class SocketMixin(ClientProtocol):
|
|
@@ -276,6 +284,67 @@ Socket connections may be unstable, SSL issues are possible.
|
|
|
276
284
|
)
|
|
277
285
|
except Exception:
|
|
278
286
|
self.logger.exception("Error in on_message_handler")
|
|
287
|
+
|
|
288
|
+
if (
|
|
289
|
+
data_item.get("opcode")
|
|
290
|
+
== Opcode.NOTIF_MSG_REACTIONS_CHANGED
|
|
291
|
+
):
|
|
292
|
+
try:
|
|
293
|
+
for (
|
|
294
|
+
reaction_handler,
|
|
295
|
+
) in self._on_reaction_change_handlers:
|
|
296
|
+
payload = data_item.get("payload", {})
|
|
297
|
+
|
|
298
|
+
chat_id = payload.get("chatId")
|
|
299
|
+
message_id = payload.get("messageId")
|
|
300
|
+
|
|
301
|
+
total_count = payload.get("totalCount")
|
|
302
|
+
your_reaction = payload.get("yourReaction")
|
|
303
|
+
counters = [
|
|
304
|
+
ReactionCounter.from_dict(c)
|
|
305
|
+
for c in payload.get("counters", [])
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
chat_id
|
|
310
|
+
and message_id
|
|
311
|
+
and (
|
|
312
|
+
total_count is not None
|
|
313
|
+
or your_reaction
|
|
314
|
+
or counters
|
|
315
|
+
)
|
|
316
|
+
):
|
|
317
|
+
reaction_info = ReactionInfo(
|
|
318
|
+
total_count=total_count,
|
|
319
|
+
your_reaction=your_reaction,
|
|
320
|
+
counters=counters,
|
|
321
|
+
)
|
|
322
|
+
result = reaction_handler(
|
|
323
|
+
message_id, chat_id, reaction_info
|
|
324
|
+
)
|
|
325
|
+
if asyncio.iscoroutine(result):
|
|
326
|
+
await result
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
self.logger.exception(
|
|
330
|
+
"Error in on_reaction_change_handler: %s", e
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if data_item.get("opcode") == Opcode.NOTIF_CHAT:
|
|
334
|
+
try:
|
|
335
|
+
for (
|
|
336
|
+
chat_update_handler,
|
|
337
|
+
) in self._on_chat_update_handlers:
|
|
338
|
+
payload = data_item.get("payload", {})
|
|
339
|
+
chat = Chat.from_dict(payload.get("chat", {}))
|
|
340
|
+
if chat:
|
|
341
|
+
result = chat_update_handler(chat)
|
|
342
|
+
if asyncio.iscoroutine(result):
|
|
343
|
+
await result
|
|
344
|
+
except Exception as e:
|
|
345
|
+
self.logger.exception(
|
|
346
|
+
"Error in on_chat_update_handler: %s", e
|
|
347
|
+
)
|
|
279
348
|
except asyncio.CancelledError:
|
|
280
349
|
self.logger.debug("Recv loop cancelled")
|
|
281
350
|
break
|
pymax/mixins/websocket.py
CHANGED
|
@@ -10,6 +10,7 @@ from typing_extensions import override
|
|
|
10
10
|
from pymax.exceptions import LoginError, WebSocketNotConnectedError
|
|
11
11
|
from pymax.filters import Filter
|
|
12
12
|
from pymax.interfaces import ClientProtocol
|
|
13
|
+
from pymax.mixins.utils import MixinsUtils
|
|
13
14
|
from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
|
|
14
15
|
from pymax.static.constant import (
|
|
15
16
|
DEFAULT_PING_INTERVAL,
|
|
@@ -18,7 +19,15 @@ from pymax.static.constant import (
|
|
|
18
19
|
WEBSOCKET_ORIGIN,
|
|
19
20
|
)
|
|
20
21
|
from pymax.static.enum import ChatType, MessageStatus, Opcode
|
|
21
|
-
from pymax.types import
|
|
22
|
+
from pymax.types import (
|
|
23
|
+
Channel,
|
|
24
|
+
Chat,
|
|
25
|
+
Dialog,
|
|
26
|
+
Me,
|
|
27
|
+
Message,
|
|
28
|
+
ReactionCounter,
|
|
29
|
+
ReactionInfo,
|
|
30
|
+
)
|
|
22
31
|
|
|
23
32
|
|
|
24
33
|
class WebSocketMixin(ClientProtocol):
|
|
@@ -59,45 +68,54 @@ class WebSocketMixin(ClientProtocol):
|
|
|
59
68
|
self.logger.warning("Interactive ping failed", exc_info=True)
|
|
60
69
|
await asyncio.sleep(DEFAULT_PING_INTERVAL)
|
|
61
70
|
|
|
62
|
-
async def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
self.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
async def connect(
|
|
72
|
+
self, user_agent: UserAgentPayload | None = None
|
|
73
|
+
) -> dict[str, Any] | None:
|
|
74
|
+
"""
|
|
75
|
+
Устанавливает соединение WebSocket с сервером и выполняет handshake.
|
|
76
|
+
"""
|
|
77
|
+
if user_agent is None:
|
|
78
|
+
user_agent = UserAgentPayload()
|
|
79
|
+
|
|
80
|
+
self.logger.info("Connecting to WebSocket %s", self.uri)
|
|
81
|
+
|
|
82
|
+
if self._ws is not None or self.is_connected:
|
|
83
|
+
self.logger.warning("WebSocket already connected")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
self._ws = await websockets.connect(
|
|
87
|
+
self.uri,
|
|
88
|
+
origin=WEBSOCKET_ORIGIN,
|
|
89
|
+
user_agent_header=user_agent.header_user_agent,
|
|
90
|
+
proxy=self.proxy,
|
|
91
|
+
)
|
|
92
|
+
self.is_connected = True
|
|
93
|
+
self._incoming = asyncio.Queue()
|
|
94
|
+
self._outgoing = asyncio.Queue()
|
|
95
|
+
self._pending = {}
|
|
96
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
97
|
+
self._outgoing_task = asyncio.create_task(self._outgoing_loop())
|
|
98
|
+
self.logger.info("WebSocket connected, starting handshake")
|
|
99
|
+
return await self._handshake(user_agent)
|
|
82
100
|
|
|
83
101
|
async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
self.logger.debug(
|
|
103
|
+
"Sending handshake with user_agent keys=%s",
|
|
104
|
+
user_agent.model_dump().keys(),
|
|
105
|
+
)
|
|
106
|
+
resp = await self._send_and_wait(
|
|
107
|
+
opcode=Opcode.SESSION_INIT,
|
|
108
|
+
payload={
|
|
109
|
+
"deviceId": str(self._device_id),
|
|
110
|
+
"userAgent": user_agent, # TODO: вынести в статик мб
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if resp.get("payload", {}).get("error"):
|
|
115
|
+
MixinsUtils.handle_error(resp)
|
|
116
|
+
|
|
117
|
+
self.logger.info("Handshake completed")
|
|
118
|
+
return resp
|
|
101
119
|
|
|
102
120
|
async def _process_message_handler(
|
|
103
121
|
self,
|
|
@@ -130,7 +148,6 @@ class WebSocketMixin(ClientProtocol):
|
|
|
130
148
|
except Exception:
|
|
131
149
|
self.logger.warning("JSON parse error", exc_info=True)
|
|
132
150
|
continue
|
|
133
|
-
|
|
134
151
|
seq = data.get("seq")
|
|
135
152
|
fut = self._pending.get(seq) if isinstance(seq, int) else None
|
|
136
153
|
|
|
@@ -161,9 +178,10 @@ class WebSocketMixin(ClientProtocol):
|
|
|
161
178
|
except Exception:
|
|
162
179
|
self.logger.exception("Error handling file upload notification")
|
|
163
180
|
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
if data.get("opcode") == Opcode.NOTIF_MESSAGE.value and (
|
|
182
|
+
self._on_message_handlers
|
|
183
|
+
or self._on_message_edit_handlers
|
|
184
|
+
or self._on_message_delete_handlers
|
|
167
185
|
):
|
|
168
186
|
try:
|
|
169
187
|
for handler, filter in self._on_message_handlers:
|
|
@@ -197,6 +215,62 @@ class WebSocketMixin(ClientProtocol):
|
|
|
197
215
|
except Exception:
|
|
198
216
|
self.logger.exception("Error in on_message_handler")
|
|
199
217
|
|
|
218
|
+
if data.get("opcode") == Opcode.NOTIF_MSG_REACTIONS_CHANGED:
|
|
219
|
+
try:
|
|
220
|
+
for (
|
|
221
|
+
reaction_handler,
|
|
222
|
+
) in self._on_reaction_change_handlers:
|
|
223
|
+
payload = data.get("payload", {})
|
|
224
|
+
|
|
225
|
+
chat_id = payload.get("chatId")
|
|
226
|
+
message_id = payload.get("messageId")
|
|
227
|
+
|
|
228
|
+
total_count = payload.get("totalCount")
|
|
229
|
+
your_reaction = payload.get("yourReaction")
|
|
230
|
+
counters = [
|
|
231
|
+
ReactionCounter.from_dict(c)
|
|
232
|
+
for c in payload.get("counters", [])
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
chat_id
|
|
237
|
+
and message_id
|
|
238
|
+
and (
|
|
239
|
+
total_count is not None
|
|
240
|
+
or your_reaction
|
|
241
|
+
or counters
|
|
242
|
+
)
|
|
243
|
+
):
|
|
244
|
+
reaction_info = ReactionInfo(
|
|
245
|
+
total_count=total_count,
|
|
246
|
+
your_reaction=your_reaction,
|
|
247
|
+
counters=counters,
|
|
248
|
+
)
|
|
249
|
+
result = reaction_handler(
|
|
250
|
+
message_id, chat_id, reaction_info
|
|
251
|
+
)
|
|
252
|
+
if asyncio.iscoroutine(result):
|
|
253
|
+
await result
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
self.logger.exception(
|
|
257
|
+
"Error in on_reaction_change_handler: %s", e
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if data.get("opcode") == Opcode.NOTIF_CHAT:
|
|
261
|
+
try:
|
|
262
|
+
for (chat_update_handler,) in self._on_chat_update_handlers:
|
|
263
|
+
payload = data.get("payload", {})
|
|
264
|
+
chat = Chat.from_dict(payload.get("chat", {}))
|
|
265
|
+
if chat:
|
|
266
|
+
result = chat_update_handler(chat)
|
|
267
|
+
if asyncio.iscoroutine(result):
|
|
268
|
+
await result
|
|
269
|
+
except Exception as e:
|
|
270
|
+
self.logger.exception(
|
|
271
|
+
"Error in on_chat_update_handler: %s", e
|
|
272
|
+
)
|
|
273
|
+
|
|
200
274
|
except websockets.exceptions.ConnectionClosed:
|
|
201
275
|
self.logger.info("WebSocket connection closed; exiting recv loop")
|
|
202
276
|
for fut in self._pending.values():
|
|
@@ -371,28 +445,12 @@ class WebSocketMixin(ClientProtocol):
|
|
|
371
445
|
drafts_sync=0,
|
|
372
446
|
chats_count=40,
|
|
373
447
|
).model_dump(by_alias=True)
|
|
374
|
-
|
|
375
448
|
try:
|
|
376
449
|
data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
|
|
377
450
|
raw_payload = data.get("payload", {})
|
|
378
451
|
|
|
379
452
|
if error := raw_payload.get("error"):
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if error == "login.token":
|
|
383
|
-
if self._ws:
|
|
384
|
-
await self._ws.close()
|
|
385
|
-
self.is_connected = False
|
|
386
|
-
self._ws = None
|
|
387
|
-
self._recv_task = None
|
|
388
|
-
raise LoginError(
|
|
389
|
-
raw_payload.get("error", "unknown"),
|
|
390
|
-
raw_payload.get("message", "No message provided"),
|
|
391
|
-
raw_payload.get("title", "Login Error"),
|
|
392
|
-
raw_payload.get("localizedMessage"),
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
return
|
|
453
|
+
MixinsUtils.handle_error(data)
|
|
396
454
|
|
|
397
455
|
for raw_chat in raw_payload.get("chats", []):
|
|
398
456
|
try:
|
pymax/payloads.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, Literal
|
|
2
2
|
|
|
3
3
|
from pydantic import AliasChoices, BaseModel, Field
|
|
4
4
|
|
|
@@ -30,7 +30,7 @@ class CamelModel(BaseModel):
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class BaseWebSocketMessage(BaseModel):
|
|
33
|
-
ver:
|
|
33
|
+
ver: Literal[10, 11] = 11
|
|
34
34
|
cmd: int
|
|
35
35
|
seq: int
|
|
36
36
|
opcode: int
|
|
@@ -195,14 +195,14 @@ class ChangeGroupProfilePayload(CamelModel):
|
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
class GetGroupMembersPayload(CamelModel):
|
|
198
|
-
type:
|
|
198
|
+
type: Literal["MEMBER"] = "MEMBER"
|
|
199
199
|
marker: int
|
|
200
200
|
chat_id: int
|
|
201
201
|
count: int
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
class SearchGroupMembersPayload(CamelModel):
|
|
205
|
-
type:
|
|
205
|
+
type: Literal["MEMBER"] = "MEMBER"
|
|
206
206
|
query: str
|
|
207
207
|
chat_id: int
|
|
208
208
|
|
|
File without changes
|
|
File without changes
|