maxapi-python 1.1.18__py3-none-any.whl → 1.1.20__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.18.dist-info → maxapi_python-1.1.20.dist-info}/METADATA +1 -1
- maxapi_python-1.1.20.dist-info/RECORD +31 -0
- {maxapi_python-1.1.18.dist-info → maxapi_python-1.1.20.dist-info}/WHEEL +1 -1
- pymax/core.py +158 -63
- pymax/files.py +15 -12
- pymax/interfaces.py +15 -5
- pymax/mixins/auth.py +57 -55
- pymax/mixins/channel.py +6 -6
- pymax/mixins/group.py +60 -0
- pymax/mixins/handler.py +51 -2
- pymax/mixins/message.py +89 -12
- pymax/mixins/self.py +115 -1
- pymax/mixins/socket.py +125 -29
- pymax/mixins/websocket.py +127 -60
- pymax/payloads.py +36 -3
- pymax/static/enum.py +0 -1
- pymax/types.py +114 -2
- maxapi_python-1.1.18.dist-info/RECORD +0 -31
- {maxapi_python-1.1.18.dist-info → maxapi_python-1.1.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
+
pymax/core.py,sha256=LqX56a5BagUYl1vpB55Y1pLZQdMoC86t6mIQVlkVByo,17322
|
|
3
|
+
pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
|
|
4
|
+
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
|
+
pymax/files.py,sha256=AvFIr34Desq2p4CNWXIngRqeyTBKMT98VmcnI-zvUU0,3462
|
|
6
|
+
pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
|
|
7
|
+
pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
|
|
8
|
+
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
9
|
+
pymax/interfaces.py,sha256=Re8o5N7FSQ-5OgVlK4-WBltX27GheEbfFjoIYl9_u6I,3723
|
|
10
|
+
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
|
+
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
+
pymax/payloads.py,sha256=cEXY_cVL6SPyhoFTTZnn7dyUx9MMdtNT5SuQSQtL4rg,6983
|
|
13
|
+
pymax/types.py,sha256=_ARcVXLGHyiGAJKYPd6EU9QDKzz4VwS6kjTu3YEH_u4,35523
|
|
14
|
+
pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
|
|
15
|
+
pymax/mixins/auth.py,sha256=Emv-0WVB_orwv9L_V5gAHfp-VYVaVcbW6AlclW_K6W4,6731
|
|
16
|
+
pymax/mixins/channel.py,sha256=7c8GANyxZuNbIHNBVcPAmMa1qqA1IRf9cGPBS1oK_q4,5159
|
|
17
|
+
pymax/mixins/group.py,sha256=XWXNWluCvq4KkZWqv4sxLpzkXfH33U1yEP20_ZFtSM4,10624
|
|
18
|
+
pymax/mixins/handler.py,sha256=ZuYX8wSgNXJoSMArcwyHvY_bL9A7X0AXnAOz22ATA3k,5897
|
|
19
|
+
pymax/mixins/message.py,sha256=wYvkMPE9ORCSFd_9J-6ltf__4ELG_zaZ_Uey4rmCzHg,25460
|
|
20
|
+
pymax/mixins/self.py,sha256=3BdHfUyqw3dn3ctJX9_hilP1jOaTaunstZ7nH8Y_xcU,5436
|
|
21
|
+
pymax/mixins/socket.py,sha256=GEOscQKKC48bOAXXiDLoz8GusCLtjdxQnB5AK5xJbms,26997
|
|
22
|
+
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
23
|
+
pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
|
|
24
|
+
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
25
|
+
pymax/mixins/websocket.py,sha256=toiXt9qxx6yTgnWJdEOeNfp414MD4zmbHp1qVgVkjnY,19850
|
|
26
|
+
pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
|
|
27
|
+
pymax/static/enum.py,sha256=ddw5SEVfRb2J9TXOa5IGhssNd-7RyKfwZBKx_UionEM,4562
|
|
28
|
+
maxapi_python-1.1.20.dist-info/METADATA,sha256=9yhRv1m8PbJJhnUdloacxlK0jAE0veq6zfXDP-Ok5nk,6245
|
|
29
|
+
maxapi_python-1.1.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
30
|
+
maxapi_python-1.1.20.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
31
|
+
maxapi_python-1.1.20.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -7,12 +7,16 @@ 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
|
|
11
11
|
|
|
12
|
-
from typing_extensions import override
|
|
12
|
+
from typing_extensions import Self, override
|
|
13
13
|
|
|
14
14
|
from .crud import Database
|
|
15
|
-
from .exceptions import
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
InvalidPhoneError,
|
|
17
|
+
SocketNotConnectedError,
|
|
18
|
+
WebSocketNotConnectedError,
|
|
19
|
+
)
|
|
16
20
|
from .formatter import ColoredFormatter
|
|
17
21
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
18
22
|
from .payloads import UserAgentPayload
|
|
@@ -29,7 +33,7 @@ if TYPE_CHECKING:
|
|
|
29
33
|
import websockets
|
|
30
34
|
|
|
31
35
|
from .filters import Filter
|
|
32
|
-
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
36
|
+
from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
logger = logging.getLogger(__name__)
|
|
@@ -143,6 +147,10 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
143
147
|
tuple[Callable[[Message], Any], Filter | None]
|
|
144
148
|
] = []
|
|
145
149
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
150
|
+
self._on_reaction_change_handlers: list[
|
|
151
|
+
tuple[Callable[[str, int, ReactionInfo], Any]]
|
|
152
|
+
] = []
|
|
153
|
+
self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
|
|
146
154
|
|
|
147
155
|
self._ssl_context = ssl.create_default_context()
|
|
148
156
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
@@ -172,13 +180,13 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
172
180
|
handler.setFormatter(formatter)
|
|
173
181
|
self.logger.addHandler(handler)
|
|
174
182
|
|
|
175
|
-
async def _wait_forever(self):
|
|
183
|
+
async def _wait_forever(self) -> None:
|
|
176
184
|
try:
|
|
177
185
|
await self.ws.wait_closed()
|
|
178
186
|
except asyncio.CancelledError:
|
|
179
187
|
self.logger.debug("wait_closed cancelled")
|
|
180
188
|
|
|
181
|
-
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
189
|
+
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
182
190
|
"""
|
|
183
191
|
Безопасно выполняет пользовательскую корутину.
|
|
184
192
|
Логирует traceback, но не роняет event loop.
|
|
@@ -232,6 +240,97 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
232
240
|
self._background_tasks.add(task)
|
|
233
241
|
return task
|
|
234
242
|
|
|
243
|
+
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
244
|
+
if sync:
|
|
245
|
+
await self._sync()
|
|
246
|
+
|
|
247
|
+
if self._on_start_handler:
|
|
248
|
+
self.logger.debug("Calling on_start handler")
|
|
249
|
+
result = self._on_start_handler()
|
|
250
|
+
if asyncio.iscoroutine(result):
|
|
251
|
+
await self._safe_execute(result, context="on_start handler")
|
|
252
|
+
|
|
253
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
254
|
+
ping_task.add_done_callback(self._log_task_exception)
|
|
255
|
+
self._background_tasks.add(ping_task)
|
|
256
|
+
|
|
257
|
+
if self._send_fake_telemetry:
|
|
258
|
+
telemetry_task = asyncio.create_task(self._start())
|
|
259
|
+
telemetry_task.add_done_callback(self._log_task_exception)
|
|
260
|
+
self._background_tasks.add(telemetry_task)
|
|
261
|
+
|
|
262
|
+
async def _cleanup_client(self) -> None:
|
|
263
|
+
for task in list(self._background_tasks):
|
|
264
|
+
task.cancel()
|
|
265
|
+
try:
|
|
266
|
+
await task
|
|
267
|
+
except asyncio.CancelledError:
|
|
268
|
+
pass
|
|
269
|
+
except Exception:
|
|
270
|
+
self.logger.debug(
|
|
271
|
+
"Background task raised during cancellation", exc_info=True
|
|
272
|
+
)
|
|
273
|
+
self._background_tasks.discard(task)
|
|
274
|
+
|
|
275
|
+
if self._recv_task:
|
|
276
|
+
self._recv_task.cancel()
|
|
277
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
278
|
+
await self._recv_task
|
|
279
|
+
self._recv_task = None
|
|
280
|
+
|
|
281
|
+
if self._outgoing_task:
|
|
282
|
+
self._outgoing_task.cancel()
|
|
283
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
284
|
+
await self._outgoing_task
|
|
285
|
+
self._outgoing_task = None
|
|
286
|
+
|
|
287
|
+
for fut in self._pending.values():
|
|
288
|
+
if not fut.done():
|
|
289
|
+
fut.set_exception(WebSocketNotConnectedError)
|
|
290
|
+
self._pending.clear()
|
|
291
|
+
|
|
292
|
+
if self._ws:
|
|
293
|
+
try:
|
|
294
|
+
await self._ws.close()
|
|
295
|
+
except Exception:
|
|
296
|
+
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
297
|
+
self._ws = None
|
|
298
|
+
|
|
299
|
+
self.is_connected = False
|
|
300
|
+
self.logger.info("Client start() cleaned up")
|
|
301
|
+
|
|
302
|
+
async def login_with_code(
|
|
303
|
+
self, temp_token: str, code: str, start: bool = False
|
|
304
|
+
) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
temp_token (str): Временный токен, полученный из request_code()
|
|
310
|
+
code (str): Код, введённый пользователем
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
str: Токен для входа
|
|
314
|
+
"""
|
|
315
|
+
resp = await self._send_code(code, temp_token)
|
|
316
|
+
token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
|
|
317
|
+
self._token = token
|
|
318
|
+
self._database.update_auth_token(self._device_id, token)
|
|
319
|
+
if start:
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
await self._post_login_tasks()
|
|
323
|
+
await self._wait_forever()
|
|
324
|
+
except Exception:
|
|
325
|
+
self.logger.exception("Error during post-login tasks")
|
|
326
|
+
finally:
|
|
327
|
+
await self._cleanup_client()
|
|
328
|
+
|
|
329
|
+
self.logger.info("Reconnecting after post-login tasks failure")
|
|
330
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
331
|
+
else:
|
|
332
|
+
self.logger.info("Login successful, token saved to database, exiting...")
|
|
333
|
+
|
|
235
334
|
async def start(self) -> None:
|
|
236
335
|
"""
|
|
237
336
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
@@ -242,7 +341,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
242
341
|
while True:
|
|
243
342
|
try:
|
|
244
343
|
self.logger.info("Client starting")
|
|
245
|
-
await self.
|
|
344
|
+
await self.connect(self.user_agent)
|
|
246
345
|
|
|
247
346
|
if self.registration:
|
|
248
347
|
if not self.first_name:
|
|
@@ -257,70 +356,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
257
356
|
else:
|
|
258
357
|
await self._sync()
|
|
259
358
|
|
|
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)
|
|
359
|
+
await self._post_login_tasks(sync=False)
|
|
274
360
|
|
|
275
361
|
await self._wait_forever()
|
|
276
362
|
self.logger.info("WebSocket closed (wait_forever exited)")
|
|
277
|
-
|
|
278
|
-
except Exception:
|
|
363
|
+
except Exception as e:
|
|
279
364
|
self.logger.exception("Client start iteration failed")
|
|
365
|
+
raise e
|
|
280
366
|
|
|
281
367
|
finally:
|
|
282
368
|
self.logger.debug("Cleaning up background tasks and pending futures")
|
|
283
369
|
|
|
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")
|
|
370
|
+
await self._cleanup_client()
|
|
324
371
|
|
|
325
372
|
if not self.reconnect:
|
|
326
373
|
self.logger.info("Reconnect disabled — exiting start()")
|
|
@@ -352,3 +399,51 @@ class SocketMaxClient(SocketMixin, MaxClient):
|
|
|
352
399
|
self.logger.debug("Socket recv_task cancelled")
|
|
353
400
|
except Exception as e:
|
|
354
401
|
self.logger.exception("Socket recv_task failed: %s", e)
|
|
402
|
+
|
|
403
|
+
@override
|
|
404
|
+
async def _cleanup_client(self):
|
|
405
|
+
"""
|
|
406
|
+
Socket-specific cleanup: cancel background tasks, set pending futures
|
|
407
|
+
exceptions to SocketNotConnectedError, and close socket.
|
|
408
|
+
"""
|
|
409
|
+
from .exceptions import SocketNotConnectedError
|
|
410
|
+
|
|
411
|
+
for task in list(self._background_tasks):
|
|
412
|
+
task.cancel()
|
|
413
|
+
try:
|
|
414
|
+
await task
|
|
415
|
+
except asyncio.CancelledError:
|
|
416
|
+
pass
|
|
417
|
+
except Exception:
|
|
418
|
+
self.logger.debug(
|
|
419
|
+
"Background task raised during cancellation (socket)",
|
|
420
|
+
exc_info=True,
|
|
421
|
+
)
|
|
422
|
+
self._background_tasks.discard(task)
|
|
423
|
+
|
|
424
|
+
if self._recv_task:
|
|
425
|
+
self._recv_task.cancel()
|
|
426
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
427
|
+
await self._recv_task
|
|
428
|
+
self._recv_task = None
|
|
429
|
+
|
|
430
|
+
if self._outgoing_task:
|
|
431
|
+
self._outgoing_task.cancel()
|
|
432
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
433
|
+
await self._outgoing_task
|
|
434
|
+
self._outgoing_task = None
|
|
435
|
+
|
|
436
|
+
for fut in self._pending.values():
|
|
437
|
+
if not fut.done():
|
|
438
|
+
fut.set_exception(SocketNotConnectedError())
|
|
439
|
+
self._pending.clear()
|
|
440
|
+
|
|
441
|
+
if self._socket:
|
|
442
|
+
try:
|
|
443
|
+
self._socket.close()
|
|
444
|
+
except Exception:
|
|
445
|
+
self.logger.debug("Error closing socket during cleanup", exc_info=True)
|
|
446
|
+
self._socket = None
|
|
447
|
+
|
|
448
|
+
self.is_connected = False
|
|
449
|
+
self.logger.info("Client start() cleaned up (socket)")
|
pymax/files.py
CHANGED
|
@@ -9,9 +9,7 @@ from typing_extensions import override
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class BaseFile(ABC):
|
|
12
|
-
def __init__(
|
|
13
|
-
self, url: str | None = None, path: str | None = None
|
|
14
|
-
) -> None:
|
|
12
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
15
13
|
self.url = url
|
|
16
14
|
self.path = path
|
|
17
15
|
|
|
@@ -47,9 +45,7 @@ class Photo(BaseFile):
|
|
|
47
45
|
".bmp",
|
|
48
46
|
} # FIXME: костыль ✅
|
|
49
47
|
|
|
50
|
-
def __init__(
|
|
51
|
-
self, url: str | None = None, path: str | None = None
|
|
52
|
-
) -> None:
|
|
48
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
53
49
|
super().__init__(url, path)
|
|
54
50
|
|
|
55
51
|
def validate_photo(self) -> tuple[str, str] | None:
|
|
@@ -71,9 +67,7 @@ class Photo(BaseFile):
|
|
|
71
67
|
mime_type = mimetypes.guess_type(self.url)[0]
|
|
72
68
|
|
|
73
69
|
if not mime_type or not mime_type.startswith("image/"):
|
|
74
|
-
raise ValueError(
|
|
75
|
-
f"URL does not appear to be an image: {self.url}"
|
|
76
|
-
)
|
|
70
|
+
raise ValueError(f"URL does not appear to be an image: {self.url}")
|
|
77
71
|
|
|
78
72
|
return (extension[1:], mime_type)
|
|
79
73
|
return None
|
|
@@ -84,15 +78,24 @@ class Photo(BaseFile):
|
|
|
84
78
|
|
|
85
79
|
|
|
86
80
|
class Video(BaseFile):
|
|
81
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
82
|
+
self.file_name: str = ""
|
|
83
|
+
if path:
|
|
84
|
+
self.file_name = Path(path).name
|
|
85
|
+
elif url:
|
|
86
|
+
self.file_name = Path(url).name
|
|
87
|
+
|
|
88
|
+
if not self.file_name:
|
|
89
|
+
raise ValueError("Either url or path must be provided.")
|
|
90
|
+
super().__init__(url, path)
|
|
91
|
+
|
|
87
92
|
@override
|
|
88
93
|
async def read(self) -> bytes:
|
|
89
94
|
return await super().read()
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
class File(BaseFile):
|
|
93
|
-
def __init__(
|
|
94
|
-
self, url: str | None = None, path: str | None = None
|
|
95
|
-
) -> None:
|
|
98
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
96
99
|
self.file_name: str = ""
|
|
97
100
|
if path:
|
|
98
101
|
self.file_name = Path(path).name
|
pymax/interfaces.py
CHANGED
|
@@ -4,21 +4,24 @@ import ssl
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from collections.abc import Awaitable, Callable
|
|
6
6
|
from logging import Logger
|
|
7
|
-
from pathlib import Path
|
|
8
7
|
from typing import TYPE_CHECKING, Any, Literal
|
|
9
8
|
|
|
10
|
-
import websockets
|
|
11
|
-
|
|
12
|
-
from .filters import Filter
|
|
13
9
|
from .payloads import UserAgentPayload
|
|
14
10
|
from .static.constant import DEFAULT_TIMEOUT
|
|
15
11
|
from .static.enum import Opcode
|
|
16
12
|
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
17
13
|
|
|
18
14
|
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
19
16
|
from uuid import UUID
|
|
20
17
|
|
|
18
|
+
import websockets
|
|
19
|
+
|
|
20
|
+
from pymax import AttachType
|
|
21
|
+
from pymax.types import ReactionInfo
|
|
22
|
+
|
|
21
23
|
from .crud import Database
|
|
24
|
+
from .filters import Filter
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
class ClientProtocol(ABC):
|
|
@@ -50,7 +53,10 @@ class ClientProtocol(ABC):
|
|
|
50
53
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
51
54
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
52
55
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
53
|
-
self._file_upload_waiters: dict[
|
|
56
|
+
self._file_upload_waiters: dict[
|
|
57
|
+
int,
|
|
58
|
+
asyncio.Future[dict[str, Any]],
|
|
59
|
+
] = {}
|
|
54
60
|
self.user_agent = UserAgentPayload()
|
|
55
61
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
56
62
|
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
@@ -69,6 +75,10 @@ class ClientProtocol(ABC):
|
|
|
69
75
|
self._on_message_delete_handlers: list[
|
|
70
76
|
tuple[Callable[[Message], Any], Filter | None]
|
|
71
77
|
] = []
|
|
78
|
+
self._on_reaction_change_handlers: list[
|
|
79
|
+
tuple[Callable[[str, int, ReactionInfo], Any]]
|
|
80
|
+
] = []
|
|
81
|
+
self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
|
|
72
82
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
73
83
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
74
84
|
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,8 +138,8 @@ 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.
|
|
140
|
-
temp_token = request_code_payload
|
|
141
|
+
request_code_payload = await self.request_code(self.phone)
|
|
142
|
+
temp_token = request_code_payload
|
|
141
143
|
|
|
142
144
|
if not temp_token or not isinstance(temp_token, str):
|
|
143
145
|
self.logger.critical("Failed to request code: token missing")
|
pymax/mixins/channel.py
CHANGED
|
@@ -67,11 +67,11 @@ class ChannelMixin(ClientProtocol):
|
|
|
67
67
|
) -> tuple[list[Member], int | None]:
|
|
68
68
|
data = await self._send_and_wait(
|
|
69
69
|
opcode=Opcode.CHAT_MEMBERS,
|
|
70
|
-
payload=payload.model_dump(by_alias=True),
|
|
70
|
+
payload=payload.model_dump(by_alias=True, exclude_none=True),
|
|
71
71
|
)
|
|
72
72
|
response_payload = data.get("payload", {})
|
|
73
|
-
if
|
|
74
|
-
|
|
73
|
+
if data.get("payload", {}).get("error"):
|
|
74
|
+
MixinsUtils.handle_error(data)
|
|
75
75
|
marker = response_payload.get("marker")
|
|
76
76
|
if isinstance(marker, str):
|
|
77
77
|
marker = int(marker)
|
|
@@ -96,7 +96,7 @@ class ChannelMixin(ClientProtocol):
|
|
|
96
96
|
async def load_members(
|
|
97
97
|
self,
|
|
98
98
|
chat_id: int,
|
|
99
|
-
marker: int = DEFAULT_MARKER_VALUE,
|
|
99
|
+
marker: int | None = DEFAULT_MARKER_VALUE,
|
|
100
100
|
count: int = DEFAULT_CHAT_MEMBERS_LIMIT,
|
|
101
101
|
) -> tuple[list[Member], int | None]:
|
|
102
102
|
"""
|
|
@@ -106,11 +106,11 @@ class ChannelMixin(ClientProtocol):
|
|
|
106
106
|
chat_id (int): Идентификатор канала
|
|
107
107
|
marker (int, optional): Маркер для пагинации. По умолчанию DEFAULT_MARKER_VALUE
|
|
108
108
|
count (int, optional): Количество членов для загрузки. По умолчанию DEFAULT_CHAT_MEMBERS_LIMIT.
|
|
109
|
-
Данное значение лучше не менять, так как веб-клиент загружает именно столько.
|
|
110
109
|
|
|
111
110
|
Returns:
|
|
112
|
-
list[Member]: Список участников канала
|
|
111
|
+
tuple[list[Member], int | None]: Список участников канала и маркер для следующей страницы
|
|
113
112
|
"""
|
|
113
|
+
|
|
114
114
|
payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count)
|
|
115
115
|
return await self._query_members(payload)
|
|
116
116
|
|