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.
Files changed (64) hide show
  1. {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/METADATA +3 -11
  2. {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/RECORD +64 -59
  3. pymax/__init__.py +18 -3
  4. pymax/api/auth/payloads.py +7 -0
  5. pymax/api/auth/service.py +33 -30
  6. pymax/api/binding.py +57 -0
  7. pymax/api/chats/payloads.py +6 -0
  8. pymax/api/chats/service.py +52 -47
  9. pymax/api/messages/enums.py +1 -0
  10. pymax/api/messages/payloads.py +16 -1
  11. pymax/api/messages/service.py +78 -34
  12. pymax/api/models.py +4 -6
  13. pymax/api/response.py +2 -2
  14. pymax/api/self/service.py +17 -26
  15. pymax/api/session/payloads.py +2 -9
  16. pymax/api/session/service.py +1 -3
  17. pymax/api/uploads/payloads.py +3 -9
  18. pymax/api/uploads/service.py +33 -99
  19. pymax/api/users/payloads.py +22 -0
  20. pymax/api/users/service.py +22 -17
  21. pymax/app.py +28 -6
  22. pymax/auth/qr.py +3 -9
  23. pymax/auth/sms.py +23 -11
  24. pymax/base.py +86 -4
  25. pymax/client.py +2 -1
  26. pymax/client_web.py +1 -2
  27. pymax/config.py +42 -3
  28. pymax/connection/connection.py +2 -0
  29. pymax/connection/readers/tcp.py +1 -3
  30. pymax/dispatch/__init__.py +12 -1
  31. pymax/dispatch/dispatcher.py +170 -34
  32. pymax/dispatch/enums.py +5 -0
  33. pymax/dispatch/mapping.py +34 -11
  34. pymax/dispatch/resolvers.py +18 -0
  35. pymax/dispatch/router.py +120 -4
  36. pymax/formatting/markdown.py +22 -13
  37. pymax/infra/chat.py +33 -0
  38. pymax/infra/message.py +69 -2
  39. pymax/infra/user.py +12 -1
  40. pymax/logging.py +2 -0
  41. pymax/protocol/tcp/compression.py +1 -3
  42. pymax/protocol/tcp/framing.py +1 -3
  43. pymax/protocol/ws/protocol.py +3 -9
  44. pymax/session/protocol.py +2 -6
  45. pymax/session/store.py +19 -24
  46. pymax/telemetry/navigation.py +1 -3
  47. pymax/telemetry/service.py +5 -17
  48. pymax/transport/tcp.py +1 -3
  49. pymax/types/domain/__init__.py +1 -1
  50. pymax/types/domain/attachments/unknown.py +1 -3
  51. pymax/types/domain/auth.py +24 -2
  52. pymax/types/domain/chat.py +58 -1
  53. pymax/types/domain/message.py +28 -2
  54. pymax/types/domain/presence.py +3 -3
  55. pymax/types/domain/sync.py +5 -21
  56. pymax/types/domain/user.py +8 -0
  57. pymax/types/events/__init__.py +4 -0
  58. pymax/types/events/mark.py +23 -0
  59. pymax/types/events/message.py +57 -5
  60. pymax/types/events/presence.py +15 -0
  61. pymax/types/events/reaction.py +21 -0
  62. pymax/types/events/typing.py +14 -0
  63. {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/WHEEL +0 -0
  64. {maxapi_python-2.1.3.dist-info → maxapi_python-2.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- int, asyncio.Future[VideoUploadSignal]
43
- ] = {}
44
- self.file_upload_waiters: dict[
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
- "Photo upload failed with status %s", response.status
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
- "Failed to decode photo upload response JSON"
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
- "HTTP error during video upload video_id=%s", video_id
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
- "Timed out during video upload video_id=%s", video_id
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
- "Unexpected error during video upload video_id=%s", video_id
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
- "HTTP error during file upload file_id=%s", file_id
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
- "Timed out during file upload file_id=%s", file_id
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
- "Unexpected error during file upload file_id=%s", file_id
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)
@@ -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
+ )
@@ -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: ContactActionPayload
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
- response = await self.api.auth.login(
128
- self.config.device.user_agent,
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
- token = result.login_token
79
- if not token and result.password_challenge:
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 Chat, MessageDeleteEvent, User
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.exception(
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