maxapi-python 2.1.2__py3-none-any.whl → 2.2.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 (66) hide show
  1. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/METADATA +3 -11
  2. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/RECORD +66 -60
  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/service.py +34 -47
  8. pymax/api/messages/enums.py +1 -0
  9. pymax/api/messages/payloads.py +16 -1
  10. pymax/api/messages/service.py +85 -33
  11. pymax/api/models.py +4 -6
  12. pymax/api/response.py +2 -2
  13. pymax/api/self/service.py +17 -26
  14. pymax/api/session/payloads.py +2 -9
  15. pymax/api/session/service.py +1 -3
  16. pymax/api/uploads/payloads.py +3 -9
  17. pymax/api/uploads/service.py +33 -99
  18. pymax/api/users/service.py +8 -16
  19. pymax/app.py +20 -4
  20. pymax/auth/qr.py +3 -9
  21. pymax/auth/sms.py +23 -11
  22. pymax/base.py +38 -1
  23. pymax/client.py +3 -5
  24. pymax/client_web.py +1 -2
  25. pymax/config.py +42 -3
  26. pymax/connection/connection.py +48 -19
  27. pymax/connection/readers/tcp.py +1 -3
  28. pymax/dispatch/dispatcher.py +36 -18
  29. pymax/dispatch/enums.py +4 -0
  30. pymax/dispatch/mapping.py +34 -11
  31. pymax/dispatch/resolvers.py +18 -0
  32. pymax/dispatch/router.py +34 -0
  33. pymax/files/photo.py +4 -2
  34. pymax/formatting/markdown.py +22 -13
  35. pymax/infra/chat.py +12 -0
  36. pymax/infra/message.py +74 -3
  37. pymax/logging.py +35 -3
  38. pymax/protocol/tcp/compression.py +1 -3
  39. pymax/protocol/tcp/framing.py +1 -3
  40. pymax/protocol/tcp/payload.py +22 -42
  41. pymax/protocol/tcp/protocol.py +2 -8
  42. pymax/protocol/ws/protocol.py +3 -9
  43. pymax/session/protocol.py +2 -6
  44. pymax/session/store.py +8 -24
  45. pymax/telemetry/navigation.py +1 -3
  46. pymax/telemetry/service.py +5 -17
  47. pymax/transport/tcp.py +1 -3
  48. pymax/types/domain/attachments/__init__.py +1 -0
  49. pymax/types/domain/attachments/audio.py +4 -4
  50. pymax/types/domain/attachments/enums.py +1 -0
  51. pymax/types/domain/attachments/unknown.py +35 -0
  52. pymax/types/domain/attachments/video.py +2 -2
  53. pymax/types/domain/auth.py +24 -2
  54. pymax/types/domain/chat.py +38 -1
  55. pymax/types/domain/element.py +3 -3
  56. pymax/types/domain/message.py +34 -2
  57. pymax/types/domain/presence.py +3 -3
  58. pymax/types/domain/sync.py +5 -21
  59. pymax/types/events/__init__.py +4 -0
  60. pymax/types/events/mark.py +23 -0
  61. pymax/types/events/message.py +57 -5
  62. pymax/types/events/presence.py +15 -0
  63. pymax/types/events/reaction.py +21 -0
  64. pymax/types/events/typing.py +14 -0
  65. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.0.dist-info}/WHEEL +0 -0
  66. {maxapi_python-2.1.2.dist-info → maxapi_python-2.2.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)
@@ -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,
@@ -30,13 +31,14 @@ class UserService:
30
31
  self.app = app
31
32
 
32
33
  def _cache_user(self, user: User) -> User:
34
+ user = bind_api_model(self.app, user)
33
35
  self.app.users[user.id] = user
34
36
  return user
35
37
 
36
38
  def get_cached_user(self, user_id: int) -> User | None:
37
39
  user = self.app.users.get(user_id)
38
40
  logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
39
- return user
41
+ return bind_api_model(self.app, user) if user is not None else None
40
42
 
41
43
  async def get_users(self, user_ids: list[int]) -> list[User]:
42
44
  cached = {
@@ -44,9 +46,7 @@ class UserService:
44
46
  for user_id in user_ids
45
47
  if (user := self.get_cached_user(user_id)) is not None
46
48
  }
47
- missing_ids = [
48
- user_id for user_id in user_ids if user_id not in cached
49
- ]
49
+ missing_ids = [user_id for user_id in user_ids if user_id not in cached]
50
50
 
51
51
  if missing_ids:
52
52
  for user in await self.fetch_users(missing_ids):
@@ -64,15 +64,11 @@ class UserService:
64
64
  async def fetch_users(self, user_ids: list[int]) -> list[User]:
65
65
  logger.info("fetching users count=%s", len(user_ids))
66
66
  frame = FetchContactsPayload(contact_ids=user_ids)
67
- response = await self.app.invoke(
68
- Opcode.CONTACT_INFO, frame.to_payload()
69
- )
67
+ response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload())
70
68
 
71
69
  users = [
72
70
  self._cache_user(user)
73
- for user in parse_payload_list(
74
- response, UserPayloadKey.CONTACTS, User
75
- )
71
+ for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User)
76
72
  ]
77
73
  logger.debug("fetched users count=%s", len(users))
78
74
  return users
@@ -97,12 +93,8 @@ class UserService:
97
93
  response = await self.app.invoke(Opcode.SESSIONS_INFO, {})
98
94
  return parse_payload_list(response, UserPayloadKey.SESSIONS, Session)
99
95
 
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
- )
96
+ async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame:
97
+ response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload())
106
98
  require_payload_dict(response)
107
99
  return response
108
100
 
pymax/app.py CHANGED
@@ -49,6 +49,7 @@ class App(Generic[ClientT]):
49
49
  self._telemetry = TelemetryService(self) if config.telemetry else None
50
50
 
51
51
  self.connection.on_event = self.on_event
52
+ self.connection.on_close = self.on_connection_lost
52
53
  logger.debug(
53
54
  "app initialized session=%s work_dir=%s auth_flow=%s",
54
55
  config.session_name,
@@ -174,6 +175,7 @@ class App(Generic[ClientT]):
174
175
  await self.dispatcher.stop_startup_tasks()
175
176
  await self.connection.close()
176
177
  await self.store.close()
178
+
177
179
  self.started = False
178
180
 
179
181
  async def invoke(
@@ -203,10 +205,10 @@ class App(Generic[ClientT]):
203
205
  payload_keys,
204
206
  )
205
207
  logger.debug("Request data=%s", frame.model_dump())
206
- request_timeout = (
207
- self.config.request_timeout if timeout is None else timeout
208
- )
208
+
209
+ request_timeout = self.config.request_timeout if timeout is None else timeout
209
210
  response = await self.connection.request(frame, timeout=request_timeout)
211
+
210
212
  response_keys = sorted(response.payload.keys()) if response.payload else []
211
213
  logger.debug(
212
214
  "response opcode=%s cmd=%s seq=%s payload_keys=%s",
@@ -231,9 +233,23 @@ class App(Generic[ClientT]):
231
233
  except asyncio.CancelledError:
232
234
  raise
233
235
  except Exception as e:
234
- logger.exception("ping loop failed; closing transport")
236
+ logger.warning("ping loop failed; closing transport: %s", e)
235
237
  await self.connection.fail(ConnectionError(f"Ping failed: {e}"))
236
238
 
239
+ def on_connection_lost(self, exc: Exception | None = None) -> None:
240
+ if self.started:
241
+ logger.warning("connection lost; marking app as stopped: %s", exc)
242
+
243
+ self.started = False
244
+
245
+ task = self._ping_task
246
+ if task is None or task.done():
247
+ return
248
+
249
+ current_task = asyncio.current_task()
250
+ if task is not current_task:
251
+ task.cancel()
252
+
237
253
  def _build_api_error(self, response: InboundFrame) -> ApiError:
238
254
  try:
239
255
  error = MaxApiError.model_validate(response.payload)
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
@@ -22,7 +22,15 @@ if TYPE_CHECKING:
22
22
  StartDecorator,
23
23
  )
24
24
  from pymax.protocol import InboundFrame
25
- from pymax.types import Chat, MessageDeleteEvent, User
25
+ from pymax.types import (
26
+ Chat,
27
+ MessageDeleteEvent,
28
+ MessageReadEvent,
29
+ PresenceEvent,
30
+ ReactionUpdateEvent,
31
+ TypingEvent,
32
+ User,
33
+ )
26
34
  from pymax.types.domain import Message, Profile
27
35
 
28
36
  logger = get_logger(__name__)
@@ -83,6 +91,7 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
83
91
  sync=self.extra_config.sync,
84
92
  store=self.extra_config.store,
85
93
  proxy=self.extra_config.proxy,
94
+ registration_config=self.extra_config.registration_config,
86
95
  device=DeviceConfig(
87
96
  mt_instance_id=self.extra_config.mt_instance_id,
88
97
  device_id=self.extra_config.device_id or str(uuid4()),
@@ -186,6 +195,34 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
186
195
  """Регистрирует обработчик удаления сообщений."""
187
196
  return self._router.on_message_delete(*filters)
188
197
 
198
+ def on_message_read(
199
+ self,
200
+ *filters: FilterCallback[MessageReadEvent],
201
+ ) -> HandlerDecorator[MessageReadEvent, ClientT]:
202
+ """Регистрирует обработчик изменения отметки прочтения."""
203
+ return self._router.on_message_read(*filters)
204
+
205
+ def on_typing(
206
+ self,
207
+ *filters: FilterCallback[TypingEvent],
208
+ ) -> HandlerDecorator[TypingEvent, ClientT]:
209
+ """Регистрирует обработчик набора текста."""
210
+ return self._router.on_typing(*filters)
211
+
212
+ def on_presence(
213
+ self,
214
+ *filters: FilterCallback[PresenceEvent],
215
+ ) -> HandlerDecorator[PresenceEvent, ClientT]:
216
+ """Регистрирует обработчик изменения присутствия пользователя."""
217
+ return self._router.on_presence(*filters)
218
+
219
+ def on_reaction_update(
220
+ self,
221
+ *filters: FilterCallback[ReactionUpdateEvent],
222
+ ) -> HandlerDecorator[ReactionUpdateEvent, ClientT]:
223
+ """Регистрирует обработчик обновления реакций сообщения."""
224
+ return self._router.on_reaction_update(*filters)
225
+
189
226
  def on_chat_update(
190
227
  self,
191
228
  *filters: FilterCallback[Chat],
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
@@ -66,10 +67,7 @@ class Client(BaseClient["Client"]):
66
67
 
67
68
  self._config = self._build_config(
68
69
  phone=phone,
69
- user_agent=(
70
- self.extra_config.user_agent
71
- or self.extra_config.generate_user_agent()
72
- ),
70
+ user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()),
73
71
  )
74
72
 
75
73
  if auth_flow is None:
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
 
pymax/config.py CHANGED
@@ -84,6 +84,21 @@ class DeviceConfig(BaseModel):
84
84
  client_session_id: int = Field(default_factory=lambda: randint(1, 70))
85
85
 
86
86
 
87
+ class RegistrationConfig(BaseModel):
88
+ """Данные профиля для регистрации нового аккаунта по SMS.
89
+
90
+ Передайте объект через ``ExtraConfig.registration_config``. Он используется
91
+ только если после подтверждения SMS-кода Max вернул токен регистрации.
92
+
93
+ Args:
94
+ first_name: Имя нового пользователя.
95
+ last_name: Фамилия нового пользователя.
96
+ """
97
+
98
+ first_name: str
99
+ last_name: str | None = None
100
+
101
+
87
102
  class ClientConfig(BaseModel):
88
103
  model_config = ConfigDict(arbitrary_types_allowed=True)
89
104
 
@@ -93,6 +108,7 @@ class ClientConfig(BaseModel):
93
108
  device: DeviceConfig
94
109
  token: str | None = None
95
110
  proxy: str | None = None
111
+ registration_config: RegistrationConfig | None = None
96
112
 
97
113
  host: str = "api.oneme.ru"
98
114
  port: int = 443
@@ -109,9 +125,7 @@ class ClientConfig(BaseModel):
109
125
 
110
126
  def ensure_config(self) -> None:
111
127
  if not self.phone:
112
- raise ValueError(
113
- "Phone must be provided when no saved session exists."
114
- )
128
+ raise ValueError("Phone must be provided when no saved session exists.")
115
129
 
116
130
 
117
131
  class ExtraConfig(BaseModel):
@@ -122,6 +136,8 @@ class ExtraConfig(BaseModel):
122
136
 
123
137
  Args:
124
138
  token: Готовый token для создания сессии без SMS/QR.
139
+ registration_config: Имя и фамилия для автоматического завершения
140
+ регистрации нового аккаунта по SMS.
125
141
  host: TCP host Max API.
126
142
  port: TCP port Max API.
127
143
  url: WebSocket URL для ``WebClient``.
@@ -156,6 +172,7 @@ class ExtraConfig(BaseModel):
156
172
  model_config = ConfigDict(arbitrary_types_allowed=True)
157
173
 
158
174
  token: str | None = None
175
+ registration_config: RegistrationConfig | None = None
159
176
 
160
177
  host: str = "api.oneme.ru"
161
178
  port: int = 443
@@ -221,3 +238,25 @@ class ExtraConfig(BaseModel):
221
238
  device_locale=locale,
222
239
  header_user_agent=DEFAULT_WEB_HEADER_USER_AGENT,
223
240
  )
241
+
242
+
243
+ # ignore. for future upd
244
+
245
+ # class TcpOptions(BaseModel):
246
+ # host: str = "api.oneme.ru"
247
+ # port: int = 443
248
+ # use_ssl: bool = True
249
+ # proxy: str | None = None
250
+
251
+
252
+ # class RuntimeOptions(BaseModel):
253
+ # request_timeout: float = 30.0
254
+ # reconnect: bool = True
255
+ # reconnect_delay: float = 1.0
256
+
257
+
258
+ # class DeviceOptions(BaseModel):
259
+ # device_id: str | None = None
260
+ # device_type: DeviceType = DeviceType.ANDROID
261
+ # user_agent: MobileUserAgentPayload | None = None
262
+ # mt_instance_id: str = Field(default_factory=lambda: str(uuid4()))