maxapi-python 2.1.3__py3-none-any.whl → 2.3.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.
- {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/METADATA +3 -11
- {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/RECORD +64 -59
- pymax/__init__.py +18 -3
- pymax/api/auth/payloads.py +7 -0
- pymax/api/auth/service.py +33 -30
- pymax/api/binding.py +57 -0
- pymax/api/chats/payloads.py +6 -0
- pymax/api/chats/service.py +52 -47
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +16 -1
- pymax/api/messages/service.py +78 -34
- pymax/api/models.py +4 -6
- pymax/api/response.py +2 -2
- pymax/api/self/service.py +17 -26
- pymax/api/session/payloads.py +2 -9
- pymax/api/session/service.py +1 -3
- pymax/api/uploads/payloads.py +3 -9
- pymax/api/uploads/service.py +33 -99
- pymax/api/users/payloads.py +22 -0
- pymax/api/users/service.py +22 -17
- pymax/app.py +28 -6
- pymax/auth/qr.py +3 -9
- pymax/auth/sms.py +23 -11
- pymax/base.py +86 -4
- pymax/client.py +2 -1
- pymax/client_web.py +1 -2
- pymax/config.py +42 -3
- pymax/connection/connection.py +2 -0
- pymax/connection/readers/tcp.py +1 -3
- pymax/dispatch/__init__.py +12 -1
- pymax/dispatch/dispatcher.py +170 -34
- pymax/dispatch/enums.py +5 -0
- pymax/dispatch/mapping.py +34 -11
- pymax/dispatch/resolvers.py +18 -0
- pymax/dispatch/router.py +120 -4
- pymax/formatting/markdown.py +22 -13
- pymax/infra/chat.py +33 -0
- pymax/infra/message.py +69 -2
- pymax/infra/user.py +12 -1
- pymax/logging.py +2 -0
- pymax/protocol/tcp/compression.py +1 -3
- pymax/protocol/tcp/framing.py +1 -3
- pymax/protocol/ws/protocol.py +3 -9
- pymax/session/protocol.py +2 -6
- pymax/session/store.py +19 -24
- pymax/telemetry/navigation.py +1 -3
- pymax/telemetry/service.py +5 -17
- pymax/transport/tcp.py +1 -3
- pymax/types/domain/__init__.py +1 -1
- pymax/types/domain/attachments/unknown.py +1 -3
- pymax/types/domain/auth.py +24 -2
- pymax/types/domain/chat.py +58 -1
- pymax/types/domain/message.py +28 -2
- pymax/types/domain/presence.py +3 -3
- pymax/types/domain/sync.py +5 -21
- pymax/types/domain/user.py +8 -0
- pymax/types/events/__init__.py +4 -0
- pymax/types/events/mark.py +23 -0
- pymax/types/events/message.py +57 -5
- pymax/types/events/presence.py +15 -0
- pymax/types/events/reaction.py +21 -0
- pymax/types/events/typing.py +14 -0
- {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/WHEEL +0 -0
- {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/licenses/LICENSE +0 -0
pymax/api/uploads/service.py
CHANGED
|
@@ -38,22 +38,12 @@ logger = get_logger(__name__)
|
|
|
38
38
|
class UploadService:
|
|
39
39
|
def __init__(self, app: App) -> None:
|
|
40
40
|
self.app = app
|
|
41
|
-
self.video_upload_waiters: dict[
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
self.
|
|
45
|
-
int, asyncio.Future[FileUploadSignal]
|
|
46
|
-
] = {}
|
|
47
|
-
self.app.dispatcher.on_internal(EventType.VIDEO_READY)(
|
|
48
|
-
self.on_video_attach
|
|
49
|
-
)
|
|
50
|
-
self.app.dispatcher.on_internal(EventType.FILE_READY)(
|
|
51
|
-
self.on_file_attach
|
|
52
|
-
)
|
|
41
|
+
self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
|
|
42
|
+
self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
|
|
43
|
+
self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
|
|
44
|
+
self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
|
|
53
45
|
|
|
54
|
-
async def upload_photo(
|
|
55
|
-
self, photo: Photo, profile: bool = False
|
|
56
|
-
) -> AttachPhotoPayload:
|
|
46
|
+
async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
|
|
57
47
|
logger.info("Uploading photo")
|
|
58
48
|
logger.debug("Preparing photo upload payload")
|
|
59
49
|
|
|
@@ -72,9 +62,7 @@ class UploadService:
|
|
|
72
62
|
url = payload_item(data, "url", str) # TODO: ENUM!!!!
|
|
73
63
|
except Exception as e:
|
|
74
64
|
logger.exception("Failed to parse photo upload URL from response")
|
|
75
|
-
raise UploadError(
|
|
76
|
-
"Failed to parse photo upload URL from response"
|
|
77
|
-
) from e
|
|
65
|
+
raise UploadError("Failed to parse photo upload URL from response") from e
|
|
78
66
|
|
|
79
67
|
if not url:
|
|
80
68
|
logger.error("No upload URL received")
|
|
@@ -92,15 +80,11 @@ class UploadService:
|
|
|
92
80
|
except (KeyError, IndexError) as e:
|
|
93
81
|
logger.exception("Photo upload URL does not contain photoIds")
|
|
94
82
|
logger.debug("Invalid photo upload URL=%s", url)
|
|
95
|
-
raise UploadError(
|
|
96
|
-
"Photo upload URL does not contain photoIds"
|
|
97
|
-
) from e
|
|
83
|
+
raise UploadError("Photo upload URL does not contain photoIds") from e
|
|
98
84
|
except Exception as e:
|
|
99
85
|
logger.exception("Failed to parse photo id from upload URL")
|
|
100
86
|
logger.debug("Invalid photo upload URL=%s", url)
|
|
101
|
-
raise UploadError(
|
|
102
|
-
"Failed to parse photo id from upload URL"
|
|
103
|
-
) from e
|
|
87
|
+
raise UploadError("Failed to parse photo id from upload URL") from e
|
|
104
88
|
|
|
105
89
|
logger.debug("Photo upload id parsed photo_id=%s", photo_id)
|
|
106
90
|
|
|
@@ -144,27 +128,17 @@ class UploadService:
|
|
|
144
128
|
data=form,
|
|
145
129
|
) as response,
|
|
146
130
|
):
|
|
147
|
-
logger.debug(
|
|
148
|
-
"Photo upload HTTP response status=%s", response.status
|
|
149
|
-
)
|
|
131
|
+
logger.debug("Photo upload HTTP response status=%s", response.status)
|
|
150
132
|
|
|
151
133
|
if response.status != HTTPStatus.OK:
|
|
152
|
-
logger.error(
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
raise UploadError(
|
|
156
|
-
f"Photo upload failed with status {response.status}"
|
|
157
|
-
)
|
|
134
|
+
logger.error("Photo upload failed with status %s", response.status)
|
|
135
|
+
raise UploadError(f"Photo upload failed with status {response.status}")
|
|
158
136
|
|
|
159
137
|
try:
|
|
160
138
|
result = await response.json()
|
|
161
139
|
except Exception as e:
|
|
162
|
-
logger.exception(
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
raise UploadError(
|
|
166
|
-
"Failed to decode photo upload response JSON"
|
|
167
|
-
) from e
|
|
140
|
+
logger.exception("Failed to decode photo upload response JSON")
|
|
141
|
+
raise UploadError("Failed to decode photo upload response JSON") from e
|
|
168
142
|
|
|
169
143
|
except UploadError:
|
|
170
144
|
raise
|
|
@@ -280,9 +254,7 @@ class UploadService:
|
|
|
280
254
|
async with aiohttp.ClientSession(
|
|
281
255
|
timeout=timeout, proxy=self.app.config.proxy
|
|
282
256
|
) as session:
|
|
283
|
-
logger.debug(
|
|
284
|
-
"Starting video upload HTTP request video_id=%s", video_id
|
|
285
|
-
)
|
|
257
|
+
logger.debug("Starting video upload HTTP request video_id=%s", video_id)
|
|
286
258
|
|
|
287
259
|
async with session.post(
|
|
288
260
|
url=upload_info.url,
|
|
@@ -327,26 +299,14 @@ class UploadService:
|
|
|
327
299
|
except UploadError:
|
|
328
300
|
raise
|
|
329
301
|
except aiohttp.ClientError as e:
|
|
330
|
-
logger.exception(
|
|
331
|
-
|
|
332
|
-
)
|
|
333
|
-
raise UploadError(
|
|
334
|
-
f"HTTP error during video upload video_id={video_id}"
|
|
335
|
-
) from e
|
|
302
|
+
logger.exception("HTTP error during video upload video_id=%s", video_id)
|
|
303
|
+
raise UploadError(f"HTTP error during video upload video_id={video_id}") from e
|
|
336
304
|
except asyncio.TimeoutError as e:
|
|
337
|
-
logger.exception(
|
|
338
|
-
|
|
339
|
-
)
|
|
340
|
-
raise UploadError(
|
|
341
|
-
f"Timed out during video upload video_id={video_id}"
|
|
342
|
-
) from e
|
|
305
|
+
logger.exception("Timed out during video upload video_id=%s", video_id)
|
|
306
|
+
raise UploadError(f"Timed out during video upload video_id={video_id}") from e
|
|
343
307
|
except Exception as e:
|
|
344
|
-
logger.exception(
|
|
345
|
-
|
|
346
|
-
)
|
|
347
|
-
raise UploadError(
|
|
348
|
-
f"Unexpected error during video upload video_id={video_id}"
|
|
349
|
-
) from e
|
|
308
|
+
logger.exception("Unexpected error during video upload video_id=%s", video_id)
|
|
309
|
+
raise UploadError(f"Unexpected error during video upload video_id={video_id}") from e
|
|
350
310
|
finally:
|
|
351
311
|
self.video_upload_waiters.pop(video_id, None)
|
|
352
312
|
logger.debug("Video upload waiter removed video_id=%s", video_id)
|
|
@@ -458,70 +418,44 @@ class UploadService:
|
|
|
458
418
|
except UploadError:
|
|
459
419
|
raise
|
|
460
420
|
except aiohttp.ClientError as e:
|
|
461
|
-
logger.exception(
|
|
462
|
-
|
|
463
|
-
)
|
|
464
|
-
raise UploadError(
|
|
465
|
-
f"HTTP error during file upload file_id={file_id}"
|
|
466
|
-
) from e
|
|
421
|
+
logger.exception("HTTP error during file upload file_id=%s", file_id)
|
|
422
|
+
raise UploadError(f"HTTP error during file upload file_id={file_id}") from e
|
|
467
423
|
except asyncio.TimeoutError as e:
|
|
468
|
-
logger.exception(
|
|
469
|
-
|
|
470
|
-
)
|
|
471
|
-
raise UploadError(
|
|
472
|
-
f"Timed out during file upload file_id={file_id}"
|
|
473
|
-
) from e
|
|
424
|
+
logger.exception("Timed out during file upload file_id=%s", file_id)
|
|
425
|
+
raise UploadError(f"Timed out during file upload file_id={file_id}") from e
|
|
474
426
|
except Exception as e:
|
|
475
|
-
logger.exception(
|
|
476
|
-
|
|
477
|
-
)
|
|
478
|
-
raise UploadError(
|
|
479
|
-
f"Unexpected error during file upload file_id={file_id}"
|
|
480
|
-
) from e
|
|
427
|
+
logger.exception("Unexpected error during file upload file_id=%s", file_id)
|
|
428
|
+
raise UploadError(f"Unexpected error during file upload file_id={file_id}") from e
|
|
481
429
|
finally:
|
|
482
430
|
self.file_upload_waiters.pop(file_id, None)
|
|
483
431
|
logger.debug("File upload waiter removed file=%s", file_id)
|
|
484
432
|
|
|
485
|
-
async def on_video_attach(
|
|
486
|
-
self, attach: VideoUploadSignal, _: Client
|
|
487
|
-
) -> None:
|
|
433
|
+
async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
|
|
488
434
|
logger.debug("Received attach event video_id=%s", attach.video_id)
|
|
489
435
|
|
|
490
436
|
future = self.video_upload_waiters.pop(attach.video_id, None)
|
|
491
437
|
|
|
492
438
|
if not future:
|
|
493
|
-
logger.debug(
|
|
494
|
-
"No video upload waiter found video_id=%s", attach.video_id
|
|
495
|
-
)
|
|
439
|
+
logger.debug("No video upload waiter found video_id=%s", attach.video_id)
|
|
496
440
|
return
|
|
497
441
|
|
|
498
442
|
if future.done():
|
|
499
|
-
logger.debug(
|
|
500
|
-
"Video upload waiter already done video_id=%s", attach.video_id
|
|
501
|
-
)
|
|
443
|
+
logger.debug("Video upload waiter already done video_id=%s", attach.video_id)
|
|
502
444
|
return
|
|
503
445
|
|
|
504
446
|
future.set_result(attach)
|
|
505
|
-
logger.debug(
|
|
506
|
-
"Video upload waiter resolved video_id=%s", attach.video_id
|
|
507
|
-
)
|
|
447
|
+
logger.debug("Video upload waiter resolved video_id=%s", attach.video_id)
|
|
508
448
|
|
|
509
|
-
async def on_file_attach(
|
|
510
|
-
self, attach: FileUploadSignal, _: Client
|
|
511
|
-
) -> None:
|
|
449
|
+
async def on_file_attach(self, attach: FileUploadSignal, _: Client) -> None:
|
|
512
450
|
logger.debug("Received attach event file_id=%s", attach.file_id)
|
|
513
451
|
future = self.file_upload_waiters.pop(attach.file_id, None)
|
|
514
452
|
|
|
515
453
|
if not future:
|
|
516
|
-
logger.debug(
|
|
517
|
-
"No file upload waiter found file_id=%s", attach.file_id
|
|
518
|
-
)
|
|
454
|
+
logger.debug("No file upload waiter found file_id=%s", attach.file_id)
|
|
519
455
|
return
|
|
520
456
|
|
|
521
457
|
if future.done():
|
|
522
|
-
logger.debug(
|
|
523
|
-
"File upload waiter already done file_id=%s", attach.file_id
|
|
524
|
-
)
|
|
458
|
+
logger.debug("File upload waiter already done file_id=%s", attach.file_id)
|
|
525
459
|
return
|
|
526
460
|
|
|
527
461
|
future.set_result(attach)
|
pymax/api/users/payloads.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
1
3
|
from pymax.api.models import CamelModel
|
|
4
|
+
from pymax.types.domain import ContactInfo
|
|
2
5
|
|
|
3
6
|
from .enums import ContactAction
|
|
4
7
|
|
|
@@ -14,3 +17,22 @@ class SearchByPhonePayload(CamelModel):
|
|
|
14
17
|
class ContactActionPayload(CamelModel):
|
|
15
18
|
contact_id: int
|
|
16
19
|
action: ContactAction
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ContactPayload(CamelModel):
|
|
23
|
+
first_name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ImportContactsPayload(CamelModel):
|
|
27
|
+
contact_list: dict[str, _ContactPayload] # phone -> contact payload
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_contacts(cls, contacts: Iterable[ContactInfo]) -> "ImportContactsPayload":
|
|
31
|
+
return cls(
|
|
32
|
+
contact_list={
|
|
33
|
+
contact.phone: _ContactPayload(
|
|
34
|
+
first_name=contact.first_name,
|
|
35
|
+
)
|
|
36
|
+
for contact in contacts
|
|
37
|
+
}
|
|
38
|
+
)
|
pymax/api/users/service.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, Literal
|
|
4
4
|
|
|
5
|
+
from pymax.api.binding import bind_api_model
|
|
5
6
|
from pymax.api.response import (
|
|
6
7
|
parse_payload_list,
|
|
7
8
|
require_payload_dict,
|
|
@@ -9,12 +10,13 @@ from pymax.api.response import (
|
|
|
9
10
|
)
|
|
10
11
|
from pymax.logging import get_logger
|
|
11
12
|
from pymax.protocol import InboundFrame, Opcode
|
|
12
|
-
from pymax.types.domain import Session, User
|
|
13
|
+
from pymax.types.domain import ContactInfo, Session, User
|
|
13
14
|
|
|
14
15
|
from .enums import ContactAction, UserPayloadKey
|
|
15
16
|
from .payloads import (
|
|
16
17
|
ContactActionPayload,
|
|
17
18
|
FetchContactsPayload,
|
|
19
|
+
ImportContactsPayload,
|
|
18
20
|
SearchByPhonePayload,
|
|
19
21
|
)
|
|
20
22
|
|
|
@@ -30,13 +32,14 @@ class UserService:
|
|
|
30
32
|
self.app = app
|
|
31
33
|
|
|
32
34
|
def _cache_user(self, user: User) -> User:
|
|
35
|
+
user = bind_api_model(self.app, user)
|
|
33
36
|
self.app.users[user.id] = user
|
|
34
37
|
return user
|
|
35
38
|
|
|
36
39
|
def get_cached_user(self, user_id: int) -> User | None:
|
|
37
40
|
user = self.app.users.get(user_id)
|
|
38
41
|
logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
|
|
39
|
-
return user
|
|
42
|
+
return bind_api_model(self.app, user) if user is not None else None
|
|
40
43
|
|
|
41
44
|
async def get_users(self, user_ids: list[int]) -> list[User]:
|
|
42
45
|
cached = {
|
|
@@ -44,9 +47,7 @@ class UserService:
|
|
|
44
47
|
for user_id in user_ids
|
|
45
48
|
if (user := self.get_cached_user(user_id)) is not None
|
|
46
49
|
}
|
|
47
|
-
missing_ids = [
|
|
48
|
-
user_id for user_id in user_ids if user_id not in cached
|
|
49
|
-
]
|
|
50
|
+
missing_ids = [user_id for user_id in user_ids if user_id not in cached]
|
|
50
51
|
|
|
51
52
|
if missing_ids:
|
|
52
53
|
for user in await self.fetch_users(missing_ids):
|
|
@@ -64,15 +65,11 @@ class UserService:
|
|
|
64
65
|
async def fetch_users(self, user_ids: list[int]) -> list[User]:
|
|
65
66
|
logger.info("fetching users count=%s", len(user_ids))
|
|
66
67
|
frame = FetchContactsPayload(contact_ids=user_ids)
|
|
67
|
-
response = await self.app.invoke(
|
|
68
|
-
Opcode.CONTACT_INFO, frame.to_payload()
|
|
69
|
-
)
|
|
68
|
+
response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload())
|
|
70
69
|
|
|
71
70
|
users = [
|
|
72
71
|
self._cache_user(user)
|
|
73
|
-
for user in parse_payload_list(
|
|
74
|
-
response, UserPayloadKey.CONTACTS, User
|
|
75
|
-
)
|
|
72
|
+
for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User)
|
|
76
73
|
]
|
|
77
74
|
logger.debug("fetched users count=%s", len(users))
|
|
78
75
|
return users
|
|
@@ -97,12 +94,8 @@ class UserService:
|
|
|
97
94
|
response = await self.app.invoke(Opcode.SESSIONS_INFO, {})
|
|
98
95
|
return parse_payload_list(response, UserPayloadKey.SESSIONS, Session)
|
|
99
96
|
|
|
100
|
-
async def _contact_action(
|
|
101
|
-
self, payload
|
|
102
|
-
) -> InboundFrame:
|
|
103
|
-
response = await self.app.invoke(
|
|
104
|
-
Opcode.CONTACT_UPDATE, payload.to_payload()
|
|
105
|
-
)
|
|
97
|
+
async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame:
|
|
98
|
+
response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload())
|
|
106
99
|
require_payload_dict(response)
|
|
107
100
|
return response
|
|
108
101
|
|
|
@@ -130,5 +123,17 @@ class UserService:
|
|
|
130
123
|
self.app.users.pop(contact_id, None)
|
|
131
124
|
return True
|
|
132
125
|
|
|
126
|
+
async def import_contacts(self, contacts: list[ContactInfo]) -> list[User]:
|
|
127
|
+
frame = ImportContactsPayload.from_contacts(contacts)
|
|
128
|
+
|
|
129
|
+
response = await self.app.invoke(Opcode.SYNC, frame.to_payload())
|
|
130
|
+
|
|
131
|
+
users = parse_payload_list(
|
|
132
|
+
response, UserPayloadKey.CONTACTS, User
|
|
133
|
+
) # TODO: maybe also return phone mapping?
|
|
134
|
+
|
|
135
|
+
# {contacts: [...], phones: {data[0]: server_phone}}
|
|
136
|
+
return [self._cache_user(user) for user in users]
|
|
137
|
+
|
|
133
138
|
def get_chat_id(self, first_user_id: int, second_user_id: int) -> int:
|
|
134
139
|
return first_user_id ^ second_user_id
|
pymax/app.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from typing import Any, Generic, TypeVar
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
3
3
|
|
|
4
4
|
from pymax.api import ApiFacade
|
|
5
5
|
from pymax.auth import AuthFlow
|
|
6
6
|
from pymax.config import ClientConfig
|
|
7
7
|
from pymax.connection import ConnectionManager
|
|
8
8
|
from pymax.dispatch import Dispatcher
|
|
9
|
-
from pymax.dispatch.router import Router
|
|
9
|
+
from pymax.dispatch.router import EventType, Router
|
|
10
10
|
from pymax.exceptions import ApiError
|
|
11
11
|
from pymax.logging import get_logger
|
|
12
12
|
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
@@ -17,8 +17,11 @@ from pymax.telemetry import TelemetryService
|
|
|
17
17
|
from pymax.types import MaxApiError, Message
|
|
18
18
|
from pymax.types.domain import Chat, Profile, User
|
|
19
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pymax.base import BaseClient
|
|
22
|
+
|
|
20
23
|
logger = get_logger(__name__)
|
|
21
|
-
ClientT = TypeVar("ClientT")
|
|
24
|
+
ClientT = TypeVar("ClientT", bound="BaseClient")
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
class App(Generic[ClientT]):
|
|
@@ -124,9 +127,26 @@ class App(Generic[ClientT]):
|
|
|
124
127
|
self.session = session_data
|
|
125
128
|
|
|
126
129
|
logger.debug("logging in")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
response = await self.api.auth.login(
|
|
133
|
+
self.config.device.user_agent,
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
handled = False
|
|
137
|
+
if self.dispatcher.client is not None:
|
|
138
|
+
handled = await self.dispatcher.emit_error(
|
|
139
|
+
e,
|
|
140
|
+
EventType.ON_START,
|
|
141
|
+
None,
|
|
142
|
+
self.dispatcher.root_router,
|
|
143
|
+
None,
|
|
144
|
+
)
|
|
145
|
+
if not handled:
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
await self.close()
|
|
149
|
+
return
|
|
130
150
|
|
|
131
151
|
if response.token is not None and response.token != self.session.token:
|
|
132
152
|
await self.store.update_token(self.session.token, response.token)
|
|
@@ -205,8 +225,10 @@ class App(Generic[ClientT]):
|
|
|
205
225
|
payload_keys,
|
|
206
226
|
)
|
|
207
227
|
logger.debug("Request data=%s", frame.model_dump())
|
|
228
|
+
|
|
208
229
|
request_timeout = self.config.request_timeout if timeout is None else timeout
|
|
209
230
|
response = await self.connection.request(frame, timeout=request_timeout)
|
|
231
|
+
|
|
210
232
|
response_keys = sorted(response.payload.keys()) if response.payload else []
|
|
211
233
|
logger.debug(
|
|
212
234
|
"response opcode=%s cmd=%s seq=%s payload_keys=%s",
|
pymax/auth/qr.py
CHANGED
|
@@ -118,23 +118,17 @@ class QrAuthFlow(AuthFlow):
|
|
|
118
118
|
continue
|
|
119
119
|
|
|
120
120
|
try:
|
|
121
|
-
response = await app.api.auth.check_password(
|
|
122
|
-
track_id, password
|
|
123
|
-
)
|
|
121
|
+
response = await app.api.auth.check_password(track_id, password)
|
|
124
122
|
except ApiError as e:
|
|
125
123
|
logger.error("2fa password check failed: %s", e)
|
|
126
124
|
continue
|
|
127
125
|
|
|
128
126
|
if response.error:
|
|
129
|
-
logger.error(
|
|
130
|
-
"2fa password check failed error=%s", response.error
|
|
131
|
-
)
|
|
127
|
+
logger.error("2fa password check failed error=%s", response.error)
|
|
132
128
|
continue
|
|
133
129
|
|
|
134
130
|
if response.login_token:
|
|
135
131
|
logger.info("2fa password authentication completed")
|
|
136
132
|
return response.login_token
|
|
137
133
|
|
|
138
|
-
logger.error(
|
|
139
|
-
"2fa password response did not contain login token; retrying"
|
|
140
|
-
)
|
|
134
|
+
logger.error("2fa password response did not contain login token; retrying")
|
pymax/auth/sms.py
CHANGED
|
@@ -75,13 +75,31 @@ class SmsAuthFlow(AuthFlow):
|
|
|
75
75
|
logger.debug("sms code provider returned code_set=%s", bool(code))
|
|
76
76
|
result = await app.api.auth.send_code(start.token, code)
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if result.login_token:
|
|
79
|
+
token = result.login_token
|
|
80
|
+
elif not result.login_token and result.password_challenge:
|
|
80
81
|
token = await self._authenticate_with_password(
|
|
81
82
|
app,
|
|
82
83
|
track_id=result.password_challenge.track_id,
|
|
83
84
|
hint=result.password_challenge.hint,
|
|
84
85
|
)
|
|
86
|
+
elif result.register_token:
|
|
87
|
+
if not app.config.registration_config:
|
|
88
|
+
raise RuntimeError("RegistrationConfig is required to register a new account")
|
|
89
|
+
else:
|
|
90
|
+
registration_config = app.config.registration_config
|
|
91
|
+
response = await app.api.auth.confirm_registration(
|
|
92
|
+
first_name=registration_config.first_name,
|
|
93
|
+
last_name=registration_config.last_name,
|
|
94
|
+
token=result.register_token,
|
|
95
|
+
)
|
|
96
|
+
token = response.token
|
|
97
|
+
else:
|
|
98
|
+
logger.error(
|
|
99
|
+
"Authentication failed: server returned no login token, "
|
|
100
|
+
"password challenge, or registration token"
|
|
101
|
+
)
|
|
102
|
+
token = None
|
|
85
103
|
|
|
86
104
|
logger.info(
|
|
87
105
|
"sms authentication completed token_set=%s",
|
|
@@ -109,23 +127,17 @@ class SmsAuthFlow(AuthFlow):
|
|
|
109
127
|
continue
|
|
110
128
|
|
|
111
129
|
try:
|
|
112
|
-
response = await app.api.auth.check_password(
|
|
113
|
-
track_id, password
|
|
114
|
-
)
|
|
130
|
+
response = await app.api.auth.check_password(track_id, password)
|
|
115
131
|
except ApiError as e:
|
|
116
132
|
logger.error("2fa password check failed: %s", e)
|
|
117
133
|
continue
|
|
118
134
|
|
|
119
135
|
if response.error:
|
|
120
|
-
logger.error(
|
|
121
|
-
"2fa password check failed error=%s", response.error
|
|
122
|
-
)
|
|
136
|
+
logger.error("2fa password check failed error=%s", response.error)
|
|
123
137
|
continue
|
|
124
138
|
|
|
125
139
|
if response.login_token:
|
|
126
140
|
logger.info("2fa password authentication completed")
|
|
127
141
|
return response.login_token
|
|
128
142
|
|
|
129
|
-
logger.error(
|
|
130
|
-
"2fa password response did not contain login token; retrying"
|
|
131
|
-
)
|
|
143
|
+
logger.error("2fa password response did not contain login token; retrying")
|
pymax/base.py
CHANGED
|
@@ -5,7 +5,8 @@ from abc import ABC, abstractmethod
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
6
|
from uuid import uuid4
|
|
7
7
|
|
|
8
|
-
from pymax.dispatch import Router
|
|
8
|
+
from pymax.dispatch import ErrorScope, Router
|
|
9
|
+
from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator
|
|
9
10
|
from pymax.infra import BaseMixin
|
|
10
11
|
from pymax.logging import get_logger
|
|
11
12
|
|
|
@@ -22,7 +23,15 @@ if TYPE_CHECKING:
|
|
|
22
23
|
StartDecorator,
|
|
23
24
|
)
|
|
24
25
|
from pymax.protocol import InboundFrame
|
|
25
|
-
from pymax.types import
|
|
26
|
+
from pymax.types import (
|
|
27
|
+
Chat,
|
|
28
|
+
MessageDeleteEvent,
|
|
29
|
+
MessageReadEvent,
|
|
30
|
+
PresenceEvent,
|
|
31
|
+
ReactionUpdateEvent,
|
|
32
|
+
TypingEvent,
|
|
33
|
+
User,
|
|
34
|
+
)
|
|
26
35
|
from pymax.types.domain import Message, Profile
|
|
27
36
|
|
|
28
37
|
logger = get_logger(__name__)
|
|
@@ -83,6 +92,7 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
83
92
|
sync=self.extra_config.sync,
|
|
84
93
|
store=self.extra_config.store,
|
|
85
94
|
proxy=self.extra_config.proxy,
|
|
95
|
+
registration_config=self.extra_config.registration_config,
|
|
86
96
|
device=DeviceConfig(
|
|
87
97
|
mt_instance_id=self.extra_config.mt_instance_id,
|
|
88
98
|
device_id=self.extra_config.device_id or str(uuid4()),
|
|
@@ -119,6 +129,10 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
119
129
|
while True:
|
|
120
130
|
try:
|
|
121
131
|
await self._app.start()
|
|
132
|
+
if not self._app.started:
|
|
133
|
+
await self.close()
|
|
134
|
+
return
|
|
135
|
+
|
|
122
136
|
await self._app.dispatcher.emit_start(self)
|
|
123
137
|
await self._connection.wait_closed()
|
|
124
138
|
except asyncio.CancelledError:
|
|
@@ -129,14 +143,21 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
129
143
|
EOFError,
|
|
130
144
|
OSError,
|
|
131
145
|
TimeoutError,
|
|
132
|
-
):
|
|
146
|
+
) as e:
|
|
133
147
|
await self.close()
|
|
148
|
+
await self._app.dispatcher.emit_disconnect(
|
|
149
|
+
e,
|
|
150
|
+
self.extra_config.reconnect,
|
|
151
|
+
self.extra_config.reconnect_delay,
|
|
152
|
+
)
|
|
153
|
+
|
|
134
154
|
if not self.extra_config.reconnect:
|
|
135
155
|
raise
|
|
136
156
|
|
|
137
|
-
logger.
|
|
157
|
+
logger.debug(
|
|
138
158
|
"client connection failed; reconnecting in %s seconds",
|
|
139
159
|
self.extra_config.reconnect_delay,
|
|
160
|
+
exc_info=True,
|
|
140
161
|
)
|
|
141
162
|
await asyncio.sleep(self.extra_config.reconnect_delay)
|
|
142
163
|
self._reset_runtime()
|
|
@@ -186,6 +207,34 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
186
207
|
"""Регистрирует обработчик удаления сообщений."""
|
|
187
208
|
return self._router.on_message_delete(*filters)
|
|
188
209
|
|
|
210
|
+
def on_message_read(
|
|
211
|
+
self,
|
|
212
|
+
*filters: FilterCallback[MessageReadEvent],
|
|
213
|
+
) -> HandlerDecorator[MessageReadEvent, ClientT]:
|
|
214
|
+
"""Регистрирует обработчик изменения отметки прочтения."""
|
|
215
|
+
return self._router.on_message_read(*filters)
|
|
216
|
+
|
|
217
|
+
def on_typing(
|
|
218
|
+
self,
|
|
219
|
+
*filters: FilterCallback[TypingEvent],
|
|
220
|
+
) -> HandlerDecorator[TypingEvent, ClientT]:
|
|
221
|
+
"""Регистрирует обработчик набора текста."""
|
|
222
|
+
return self._router.on_typing(*filters)
|
|
223
|
+
|
|
224
|
+
def on_presence(
|
|
225
|
+
self,
|
|
226
|
+
*filters: FilterCallback[PresenceEvent],
|
|
227
|
+
) -> HandlerDecorator[PresenceEvent, ClientT]:
|
|
228
|
+
"""Регистрирует обработчик изменения присутствия пользователя."""
|
|
229
|
+
return self._router.on_presence(*filters)
|
|
230
|
+
|
|
231
|
+
def on_reaction_update(
|
|
232
|
+
self,
|
|
233
|
+
*filters: FilterCallback[ReactionUpdateEvent],
|
|
234
|
+
) -> HandlerDecorator[ReactionUpdateEvent, ClientT]:
|
|
235
|
+
"""Регистрирует обработчик обновления реакций сообщения."""
|
|
236
|
+
return self._router.on_reaction_update(*filters)
|
|
237
|
+
|
|
189
238
|
def on_chat_update(
|
|
190
239
|
self,
|
|
191
240
|
*filters: FilterCallback[Chat],
|
|
@@ -200,6 +249,39 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
200
249
|
"""Регистрирует обработчик исходных входящих frame-ов."""
|
|
201
250
|
return self._router.on_raw(*filters)
|
|
202
251
|
|
|
252
|
+
def on_error(self, scope: ErrorScope = ErrorScope.GLOBAL) -> ErrorDecorator[ClientT]:
|
|
253
|
+
"""Регистрирует обработчик ошибок dispatch-а и запуска клиента."""
|
|
254
|
+
return self._router.on_error(scope)
|
|
255
|
+
|
|
256
|
+
def on_disconnect(self) -> DisconnectDecorator:
|
|
257
|
+
"""Регистрирует обработчик сетевого отключения перед reconnect."""
|
|
258
|
+
return self._router.on_disconnect()
|
|
259
|
+
|
|
203
260
|
def include_router(self, router: Router[ClientT]) -> None:
|
|
204
261
|
"""Подключает дочерний router к root router клиента."""
|
|
205
262
|
self._router.include_router(router)
|
|
263
|
+
|
|
264
|
+
async def relogin(self: ClientT, drop_config_token: bool = True, start: bool = True) -> None: # noqa: PYI019
|
|
265
|
+
"""Удаляет текущую локальную сессию и запускает авторизацию заново.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
drop_config_token: Сбросить token, переданный через ``ExtraConfig``.
|
|
269
|
+
start: Сразу запустить клиента после сброса runtime.
|
|
270
|
+
"""
|
|
271
|
+
store = self._app.store
|
|
272
|
+
session = self._app.session
|
|
273
|
+
|
|
274
|
+
if session is None:
|
|
275
|
+
raise RuntimeError("Cannot relogin before session is loaded")
|
|
276
|
+
|
|
277
|
+
await store.delete_session(session.token)
|
|
278
|
+
await self.close()
|
|
279
|
+
|
|
280
|
+
if drop_config_token:
|
|
281
|
+
self.extra_config.token = None
|
|
282
|
+
self._config.token = None
|
|
283
|
+
|
|
284
|
+
self._reset_runtime()
|
|
285
|
+
|
|
286
|
+
if start:
|
|
287
|
+
await self.start()
|
pymax/client.py
CHANGED
|
@@ -39,7 +39,7 @@ class Client(BaseClient["Client"]):
|
|
|
39
39
|
password_provider: Провайдер пароля 2FA, если аккаунт его требует.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
|
-
def __init__(
|
|
42
|
+
def __init__( # noqa: PLR0913
|
|
43
43
|
self,
|
|
44
44
|
phone: str,
|
|
45
45
|
session_name: str = "session.db",
|
|
@@ -49,6 +49,7 @@ class Client(BaseClient["Client"]):
|
|
|
49
49
|
sms_code_provider: SmsCodeProvider | None = None,
|
|
50
50
|
password_provider: PasswordProvider | None = None,
|
|
51
51
|
) -> None:
|
|
52
|
+
|
|
52
53
|
self.phone = phone
|
|
53
54
|
self.extra_config = extra_config or ExtraConfig()
|
|
54
55
|
self.session_name = session_name
|
pymax/client_web.py
CHANGED
|
@@ -54,8 +54,7 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
54
54
|
self._config = self._build_config(
|
|
55
55
|
phone=None,
|
|
56
56
|
user_agent=(
|
|
57
|
-
self.extra_config.user_agent
|
|
58
|
-
or self.extra_config.generate_web_user_agent()
|
|
57
|
+
self.extra_config.user_agent or self.extra_config.generate_web_user_agent()
|
|
59
58
|
),
|
|
60
59
|
)
|
|
61
60
|
|