maxapi-python 2.3.1__py3-none-any.whl → 2.4.1__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 (79) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
  3. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +9 -2
  5. pymax/_data/apk_fingerprints.json +767 -0
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/enums.py +1 -0
  8. pymax/api/auth/payloads.py +28 -2
  9. pymax/api/auth/service.py +100 -3
  10. pymax/api/chats/__init__.py +1 -0
  11. pymax/api/chats/enums.py +15 -0
  12. pymax/api/chats/payloads.py +18 -2
  13. pymax/api/chats/service.py +43 -1
  14. pymax/api/messages/__init__.py +1 -1
  15. pymax/api/messages/payloads.py +21 -7
  16. pymax/api/messages/service.py +161 -24
  17. pymax/api/self/__init__.py +2 -1
  18. pymax/api/self/enums.py +9 -0
  19. pymax/api/self/payloads.py +40 -1
  20. pymax/api/self/service.py +37 -0
  21. pymax/api/session/service.py +21 -8
  22. pymax/api/uploads/payloads.py +39 -1
  23. pymax/api/uploads/service.py +247 -55
  24. pymax/app.py +91 -18
  25. pymax/auth/__init__.py +2 -0
  26. pymax/auth/exceptions.py +9 -0
  27. pymax/auth/providers.py +6 -2
  28. pymax/auth/sms.py +11 -1
  29. pymax/base.py +90 -8
  30. pymax/client.py +39 -10
  31. pymax/client_web.py +17 -14
  32. pymax/config.py +98 -19
  33. pymax/connection/connection.py +29 -18
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +3 -1
  36. pymax/dispatch/resolvers.py +7 -1
  37. pymax/files/__init__.py +4 -1
  38. pymax/files/base.py +58 -1
  39. pymax/files/video.py +51 -1
  40. pymax/files/voice.py +53 -0
  41. pymax/fingerprint/__init__.py +1 -0
  42. pymax/fingerprint/fingerprint.py +30 -0
  43. pymax/fingerprint/models.py +12 -0
  44. pymax/infra/auth.py +4 -0
  45. pymax/infra/bots.py +3 -3
  46. pymax/infra/chat.py +47 -5
  47. pymax/infra/message.py +49 -14
  48. pymax/infra/protocol.py +5 -1
  49. pymax/infra/self.py +25 -1
  50. pymax/protocol/enums.py +13 -0
  51. pymax/protocol/tcp/payload.py +21 -1
  52. pymax/session/__init__.py +1 -1
  53. pymax/session/models.py +30 -0
  54. pymax/session/protocol.py +33 -7
  55. pymax/session/store.py +73 -5
  56. pymax/transport/tcp.py +17 -4
  57. pymax/transport/websocket.py +6 -3
  58. pymax/types/domain/__init__.py +2 -1
  59. pymax/types/domain/attachments/__init__.py +2 -1
  60. pymax/types/domain/attachments/call.py +7 -1
  61. pymax/types/domain/attachments/contact.py +3 -3
  62. pymax/types/domain/attachments/control.py +3 -0
  63. pymax/types/domain/attachments/enums.py +33 -1
  64. pymax/types/domain/attachments/poll.py +111 -0
  65. pymax/types/domain/attachments/sticker.py +1 -1
  66. pymax/types/domain/attachments/video.py +32 -11
  67. pymax/types/domain/chat.py +9 -9
  68. pymax/types/domain/enums.py +7 -0
  69. pymax/types/domain/handshake.py +16 -0
  70. pymax/types/domain/login.py +30 -1
  71. pymax/types/domain/message.py +108 -27
  72. pymax/types/domain/user.py +1 -3
  73. pymax/types/events/__init__.py +1 -0
  74. pymax/types/events/reaction.py +2 -2
  75. pymax/types/events/voice.py +5 -0
  76. pymax/versions/__init__.py +2 -0
  77. pymax/versions/catalog.py +104 -0
  78. pymax/versions/exceptions.py +10 -0
  79. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import base64
4
5
  from http import HTTPStatus
5
6
  from typing import TYPE_CHECKING
6
7
  from urllib.parse import parse_qs, quote, urlparse
@@ -11,9 +12,10 @@ from pydantic import ValidationError
11
12
  from pymax.api.response import payload_item
12
13
  from pymax.dispatch.enums import EventType
13
14
  from pymax.exceptions import UploadError
14
- from pymax.files import File, Photo, Video
15
+ from pymax.files import File, Photo, Video, VideoNote, Voice
15
16
  from pymax.logging import get_logger
16
17
  from pymax.protocol import Opcode
18
+ from pymax.types import AudioUploadSignal
17
19
 
18
20
  from .models import (
19
21
  FileUploadResponse,
@@ -25,6 +27,8 @@ from .payloads import (
25
27
  AttachPhotoPayload,
26
28
  UploadPayload,
27
29
  VideoAttachPayload,
30
+ VideoNoteAttachPayload,
31
+ VoiceAttachPayload,
28
32
  )
29
33
 
30
34
  if TYPE_CHECKING:
@@ -40,14 +44,16 @@ class UploadService:
40
44
  self.app = app
41
45
  self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
42
46
  self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
47
+ self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {}
43
48
  self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
44
49
  self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
50
+ self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
45
51
 
46
52
  async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
47
53
  logger.info("Uploading photo")
48
54
  logger.debug("Preparing photo upload payload")
49
55
 
50
- payload = UploadPayload(profile=profile).model_dump()
56
+ payload = UploadPayload(profile=profile).to_payload()
51
57
 
52
58
  try:
53
59
  data = await self.app.invoke(
@@ -178,11 +184,160 @@ class UploadService:
178
184
  logger.debug("Photo upload complete photo_id=%s", photo_id)
179
185
  return AttachPhotoPayload(photo_token=token)
180
186
 
181
- async def upload_video(self, video: Video) -> VideoAttachPayload:
187
+ async def upload_voice(self, voice: Voice) -> VoiceAttachPayload:
188
+ logger.info("Uploading voice")
189
+
190
+ payload = UploadPayload(
191
+ type=2,
192
+ uploader_type=1,
193
+ ).to_payload()
194
+
195
+ try:
196
+ data = await self.app.invoke(
197
+ Opcode.VIDEO_UPLOAD,
198
+ payload=payload,
199
+ )
200
+ except Exception as e:
201
+ logger.exception("Failed to request voice upload URL")
202
+ raise UploadError("Failed to request voice upload URL") from e
203
+
204
+ try:
205
+ response = VideoUploadResponse.model_validate(data.payload)
206
+ except ValidationError as e:
207
+ logger.exception("Invalid voice upload response model")
208
+ logger.debug("Invalid voice upload payload=%r", data.payload)
209
+ raise UploadError("Invalid voice upload response model") from e
210
+ except Exception as e:
211
+ logger.exception("Failed to parse voice upload response")
212
+ logger.debug("Invalid voice upload payload=%r", data.payload)
213
+ raise UploadError("Failed to parse voice upload response") from e
214
+
215
+ try:
216
+ upload_info = response.info[0]
217
+ except IndexError as e:
218
+ logger.error("voice upload response info is empty")
219
+ logger.debug("voice upload response=%r", response)
220
+ raise UploadError("voice upload response info is empty") from e
221
+ except Exception as e:
222
+ logger.exception("Failed to get voice upload info")
223
+ logger.debug("voice upload response=%r", response)
224
+ raise UploadError("Failed to get voice upload info") from e
225
+
226
+ try:
227
+ file_size = await voice.size()
228
+ except Exception as e:
229
+ logger.exception("Failed to get voice size")
230
+ raise UploadError("Failed to get voice size") from e
231
+
232
+ # ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
233
+ # OKMessages/{app_version} ({os_version}; {device_name}; {screen})
234
+
235
+ user_agent = (
236
+ f"OKMessages/{self.app.config.app_version}"
237
+ + f" ({self.app.config.device.user_agent.os_version};"
238
+ + f" {self.app.config.device.user_agent.device_name};"
239
+ + f" {self.app.config.device.user_agent.screen})"
240
+ )
241
+
242
+ headers = {
243
+ "Content-Disposition": f"attachment; filename={quote(voice.name)}",
244
+ "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
245
+ "Content-Length": str(file_size),
246
+ "Connection": "keep-alive",
247
+ "Content-Type": "application/octet-stream",
248
+ "User-Agent": quote(user_agent),
249
+ }
250
+
251
+ logger.debug(
252
+ "Voice upload headers prepared content_range=%s",
253
+ headers["Content-Range"],
254
+ )
255
+
256
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
257
+
258
+ video_id = upload_info.video_id
259
+ token = upload_info.token
260
+
261
+ logger.debug("Voice upload waiter registered voice_id=%s", video_id)
262
+
263
+ try:
264
+ async with aiohttp.ClientSession(
265
+ timeout=timeout, proxy=self.app.config.proxy
266
+ ) as session:
267
+ logger.debug("Starting voice upload HTTP request voice_id=%s", video_id)
268
+
269
+ async with session.post(
270
+ url=upload_info.url,
271
+ headers=headers,
272
+ data=voice.iter_chunks(1024 * 1024),
273
+ ) as response:
274
+ logger.debug(
275
+ "Voice upload HTTP response status=%s voice_id=%s",
276
+ response.status,
277
+ video_id,
278
+ )
279
+
280
+ if response.status != HTTPStatus.OK:
281
+ logger.error(
282
+ "Voice upload failed with status %s video_id=%s",
283
+ response.status,
284
+ video_id,
285
+ )
286
+ raise UploadError(
287
+ "Voice upload failed with status "
288
+ f"{response.status} voice_id={video_id}"
289
+ )
290
+
291
+ # print(await response.read()) # TODO: Error handing (yes, with 200 status 💔)
292
+
293
+ # try:
294
+ # logger.debug(
295
+ # "Waiting for voice processing notification voice_id=%s",
296
+ # video_id,
297
+ # )
298
+ # await asyncio.wait_for(future, 60)
299
+ # except asyncio.TimeoutError:
300
+ # logger.warning(
301
+ # "Timed out waiting for voice processing notification voice_id=%s",
302
+ # video_id,
303
+ # )
304
+ # raise UploadError(
305
+ # f"Timed out waiting for voice processing voice_id={video_id}"
306
+ # )
307
+
308
+ logger.debug("Voice upload complete voice_id=%s", video_id)
309
+ return VoiceAttachPayload(
310
+ video_id=video_id,
311
+ token=token,
312
+ duration=await voice.get_duration(),
313
+ wave=b"\x00" * 80,
314
+ )
315
+
316
+ except UploadError:
317
+ raise
318
+ except aiohttp.ClientError as e:
319
+ logger.exception("HTTP error during voice upload voice_id=%s", video_id)
320
+ raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e
321
+ except asyncio.TimeoutError as e:
322
+ logger.exception("Timed out during voice upload voice_id=%s", video_id)
323
+ raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e
324
+ except Exception as e:
325
+ logger.exception("Unexpected error during voice upload voice_id=%s", video_id)
326
+ raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e
327
+ finally:
328
+ self.voice_upload_waiters.pop(video_id, None)
329
+ logger.debug("Voice upload waiter removed voice_id=%s", video_id)
330
+
331
+ async def upload_video(
332
+ self, uploadable_video: Video | VideoNote
333
+ ) -> VideoAttachPayload | VideoNoteAttachPayload:
182
334
  logger.info("Uploading video")
183
335
  logger.debug("Preparing video upload payload")
184
336
 
185
- payload = UploadPayload().model_dump()
337
+ if isinstance(uploadable_video, VideoNote):
338
+ payload = UploadPayload(type=1, uploader_type=1).to_payload()
339
+ else:
340
+ payload = UploadPayload().to_payload()
186
341
 
187
342
  try:
188
343
  data = await self.app.invoke(
@@ -216,7 +371,7 @@ class UploadService:
216
371
  raise UploadError("Failed to get video upload info") from e
217
372
 
218
373
  try:
219
- file_size = await video.size()
374
+ file_size = await uploadable_video.size()
220
375
  except Exception as e:
221
376
  logger.exception("Failed to get video size")
222
377
  raise UploadError("Failed to get video size") from e
@@ -227,11 +382,11 @@ class UploadService:
227
382
  file_size,
228
383
  )
229
384
 
230
- timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
385
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
231
386
 
232
387
  headers = {
233
- "Content-Disposition": f"attachment; filename={quote(video.name)}",
234
- "Content-Range": f"0-{file_size - 1}/{file_size}",
388
+ "Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}",
389
+ "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
235
390
  "Content-Length": str(file_size),
236
391
  "Connection": "keep-alive",
237
392
  }
@@ -240,15 +395,16 @@ class UploadService:
240
395
  "Video upload headers prepared content_range=%s",
241
396
  headers["Content-Range"],
242
397
  )
243
-
244
- loop = asyncio.get_running_loop()
245
- future: asyncio.Future[VideoUploadSignal] = loop.create_future()
246
-
247
- video_id = upload_info.video_id
248
398
  token = upload_info.token
399
+ video_id = upload_info.video_id
400
+
401
+ future = None
402
+ if not isinstance(uploadable_video, VideoNote):
403
+ loop = asyncio.get_running_loop()
404
+ future = loop.create_future()
405
+ self.video_upload_waiters[video_id] = future
249
406
 
250
- self.video_upload_waiters[video_id] = future
251
- logger.debug("Video upload waiter registered video_id=%s", video_id)
407
+ logger.debug("Video upload waiter registered video_id=%s", video_id)
252
408
 
253
409
  try:
254
410
  async with aiohttp.ClientSession(
@@ -259,7 +415,7 @@ class UploadService:
259
415
  async with session.post(
260
416
  url=upload_info.url,
261
417
  headers=headers,
262
- data=video.iter_chunks(1024 * 1024),
418
+ data=uploadable_video.iter_chunks(1024 * 1024),
263
419
  ) as response:
264
420
  logger.debug(
265
421
  "Video upload HTTP response status=%s video_id=%s",
@@ -279,11 +435,32 @@ class UploadService:
279
435
  )
280
436
 
281
437
  try:
282
- logger.debug(
283
- "Waiting for video processing notification video_id=%s",
284
- video_id,
285
- )
286
- await asyncio.wait_for(future, 60)
438
+ if isinstance(uploadable_video, VideoNote):
439
+ data = await response.json(content_type=None)
440
+
441
+ thumbhash = data.get("thumbhash")
442
+ if thumbhash:
443
+ thumbhash += "=" * (-len(thumbhash) % 4)
444
+ thumbhash = base64.b64decode(thumbhash)
445
+
446
+ return VideoNoteAttachPayload(
447
+ video_id=video_id,
448
+ token=token,
449
+ thumbhash=thumbhash,
450
+ duration=await uploadable_video.get_duration(),
451
+ )
452
+ else:
453
+ if future is None:
454
+ raise ValueError(
455
+ "Unexpected internal state: "
456
+ + "future is missing in UploadService.upload_video."
457
+ + f" video type = {type(uploadable_video)}"
458
+ )
459
+ logger.debug(
460
+ "Waiting for video processing notification video_id=%s",
461
+ video_id,
462
+ )
463
+ await asyncio.wait_for(future, 60)
287
464
  except asyncio.TimeoutError:
288
465
  logger.warning(
289
466
  "Timed out waiting for video processing notification video_id=%s",
@@ -294,6 +471,7 @@ class UploadService:
294
471
  )
295
472
 
296
473
  logger.debug("Video upload complete video_id=%s", video_id)
474
+
297
475
  return VideoAttachPayload(video_id=video_id, token=token)
298
476
 
299
477
  except UploadError:
@@ -314,7 +492,7 @@ class UploadService:
314
492
  async def upload_file(self, file: File) -> AttachFilePayload:
315
493
  logger.info("Uploading file")
316
494
 
317
- payload = UploadPayload().model_dump()
495
+ payload = UploadPayload().to_payload()
318
496
 
319
497
  try:
320
498
  data = await self.app.invoke(
@@ -369,51 +547,51 @@ class UploadService:
369
547
 
370
548
  file_id = upload_info.file_id
371
549
 
550
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
551
+
372
552
  self.file_upload_waiters[file_id] = future
373
553
  logger.debug("File upload waiter registered file_id=%s", file_id)
374
554
 
375
555
  try:
376
- async with aiohttp.ClientSession(
377
- proxy=self.app.config.proxy,
378
- ) as session:
379
- async with session.post(
556
+ async with (
557
+ aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session,
558
+ session.post(
380
559
  url=upload_info.url,
381
560
  headers=headers,
382
561
  data=file.iter_chunks(1024 * 1024),
383
- ) as response:
384
- logger.debug(
385
- "File upload HTTP response status=%s file_id=%s",
562
+ ) as response,
563
+ ):
564
+ logger.debug(
565
+ "File upload HTTP response status=%s file_id=%s",
566
+ response.status,
567
+ file_id,
568
+ )
569
+
570
+ if response.status != HTTPStatus.OK:
571
+ logger.error(
572
+ "File upload failed with status %s file_id=%s",
386
573
  response.status,
387
574
  file_id,
388
575
  )
576
+ raise UploadError(
577
+ f"File upload failed with status {response.status} file_id={file_id}"
578
+ )
389
579
 
390
- if response.status != HTTPStatus.OK:
391
- logger.error(
392
- "File upload failed with status %s file_id=%s",
393
- response.status,
394
- file_id,
395
- )
396
- raise UploadError(
397
- f"File upload failed with status {response.status} file_id={file_id}"
398
- )
399
-
400
- try:
401
- logger.debug(
402
- "Waiting for file processing notification file_id=%s",
403
- file_id,
404
- )
405
- await asyncio.wait_for(future, 60)
406
- except asyncio.TimeoutError:
407
- logger.warning(
408
- "Timed out waiting for file processing notification file_id=%s",
409
- file_id,
410
- )
411
- raise UploadError(
412
- f"Timed out waiting for file processing file_id={file_id}"
413
- )
580
+ try:
581
+ logger.debug(
582
+ "Waiting for file processing notification file_id=%s",
583
+ file_id,
584
+ )
585
+ await asyncio.wait_for(future, 60)
586
+ except asyncio.TimeoutError:
587
+ logger.warning(
588
+ "Timed out waiting for file processing notification file_id=%s",
589
+ file_id,
590
+ )
591
+ raise UploadError(f"Timed out waiting for file processing file_id={file_id}")
414
592
 
415
- logger.debug("File upload complete file_id=%s", file_id)
416
- return AttachFilePayload(file_id=file_id)
593
+ logger.debug("File upload complete file_id=%s", file_id)
594
+ return AttachFilePayload(file_id=file_id)
417
595
 
418
596
  except UploadError:
419
597
  raise
@@ -430,6 +608,20 @@ class UploadService:
430
608
  self.file_upload_waiters.pop(file_id, None)
431
609
  logger.debug("File upload waiter removed file=%s", file_id)
432
610
 
611
+ async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None:
612
+ future = self.voice_upload_waiters.pop(attach.audio_id, None)
613
+
614
+ if not future:
615
+ logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id)
616
+ return
617
+
618
+ if future.done():
619
+ logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id)
620
+ return
621
+
622
+ future.set_result(attach)
623
+ logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id)
624
+
433
625
  async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
434
626
  logger.debug("Received attach event video_id=%s", attach.video_id)
435
627
 
pymax/app.py CHANGED
@@ -8,14 +8,16 @@ from pymax.connection import ConnectionManager
8
8
  from pymax.dispatch import Dispatcher
9
9
  from pymax.dispatch.router import EventType, Router
10
10
  from pymax.exceptions import ApiError
11
+ from pymax.fingerprint import FingerprintGenerator
11
12
  from pymax.logging import get_logger
12
13
  from pymax.protocol import Command, InboundFrame, OutboundFrame
13
14
  from pymax.protocol.enums import Opcode
14
- from pymax.session import SessionStore
15
- from pymax.session.models import SessionInfo
15
+ from pymax.session import InMemoryStore, SessionStore
16
+ from pymax.session.models import SessionInfo, resolve_session_user_agent
16
17
  from pymax.telemetry import TelemetryService
17
18
  from pymax.types import MaxApiError, Message
18
- from pymax.types.domain import Chat, Profile, User
19
+ from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
20
+ from pymax.types.domain.login import LoginResponse
19
21
 
20
22
  if TYPE_CHECKING:
21
23
  from pymax.base import BaseClient
@@ -36,8 +38,15 @@ class App(Generic[ClientT]):
36
38
  self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
37
39
  self.api = ApiFacade(self)
38
40
  self.config = config
39
- self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
41
+ self.store = (
42
+ (self.config.store or SessionStore(config.work_dir, config.session_name))
43
+ if self.config.persist_session
44
+ else InMemoryStore()
45
+ )
40
46
  self.auth_flow = auth_flow
47
+ self.fingerprint_generator: FingerprintGenerator | None = (
48
+ FingerprintGenerator(self.config.fingering) if self.config.fingering else None
49
+ )
41
50
 
42
51
  self.me: Profile | None = None
43
52
  self.chats: list[Chat] | None = None
@@ -46,6 +55,7 @@ class App(Generic[ClientT]):
46
55
  self.messages: dict[int, list[Message]] = {}
47
56
 
48
57
  self.session: SessionInfo | None = None
58
+ self.handshake_response: HandshakeResponse | None = None
49
59
 
50
60
  self.started = False
51
61
  self._ping_task: asyncio.Task[None] | None = None
@@ -73,6 +83,17 @@ class App(Generic[ClientT]):
73
83
  else:
74
84
  session_data.mt_instance_id = self.config.device.mt_instance_id
75
85
 
86
+ if self.config.restore_user_agent_from_session:
87
+ user_agent = resolve_session_user_agent(
88
+ self.config.device.user_agent,
89
+ session_data.user_agent,
90
+ )
91
+ else:
92
+ user_agent = self.config.device.user_agent
93
+ self.config.device.user_agent = user_agent
94
+ if session_data.user_agent != user_agent:
95
+ session_data = session_data.model_copy(update={"user_agent": user_agent})
96
+
76
97
  try:
77
98
  logger.debug("opening connection")
78
99
  await self.connection.open()
@@ -81,12 +102,14 @@ class App(Generic[ClientT]):
81
102
  session_data.device_id if session_data else self.config.device.device_id
82
103
  )
83
104
  logger.debug("running handshake")
84
- await self.handshake(handshake_device_id)
105
+ handshake_response = await self.handshake(handshake_device_id)
85
106
  except (ConnectionError, EOFError, OSError, TimeoutError) as e:
86
107
  logger.exception("failed to connect or handshake")
87
108
  await self.connection.close()
88
109
  raise ConnectionError(f"Failed to connect and handshake: {e}") from e
89
110
 
111
+ self.handshake_response = handshake_response
112
+
90
113
  self._ping_task = asyncio.create_task(self._ping_loop())
91
114
 
92
115
  if not session_data:
@@ -99,6 +122,7 @@ class App(Generic[ClientT]):
99
122
  device_id=self.config.device.device_id,
100
123
  phone=self.config.phone or "",
101
124
  mt_instance_id=self.config.device.mt_instance_id,
125
+ user_agent=self.config.device.user_agent,
102
126
  )
103
127
  )
104
128
  else:
@@ -114,6 +138,7 @@ class App(Generic[ClientT]):
114
138
  device_id=self.config.device.device_id,
115
139
  phone=self.config.phone or "",
116
140
  mt_instance_id=self.config.device.mt_instance_id,
141
+ user_agent=self.config.device.user_agent,
117
142
  )
118
143
  )
119
144
  logger.info("new session saved")
@@ -129,10 +154,26 @@ class App(Generic[ClientT]):
129
154
  logger.debug("logging in")
130
155
 
131
156
  try:
132
- response = await self.api.auth.login(
133
- self.config.device.user_agent,
134
- )
157
+ login_response, login2_response = await self.login()
158
+
159
+ if (
160
+ login2_response
161
+ and login_response.login2_flags
162
+ and login_response.login2_flags.profile_enabled
163
+ and login2_response.profile
164
+ ):
165
+ self.me = login2_response.profile
166
+ elif login_response.profile:
167
+ self.me = login_response.profile
168
+ elif login2_response and login2_response.profile:
169
+ self.me = login2_response.profile
170
+ else:
171
+ logger.error("Unexpected internal state: login response does not contain profile")
172
+ raise RuntimeError("Login response does not contain profile")
135
173
  except Exception as e:
174
+ if self._is_invalid_login_token_error(e):
175
+ raise
176
+
136
177
  handled = False
137
178
  if self.dispatcher.client is not None:
138
179
  handled = await self.dispatcher.emit_error(
@@ -148,15 +189,23 @@ class App(Generic[ClientT]):
148
189
  await self.close()
149
190
  return
150
191
 
151
- if response.token is not None and response.token != self.session.token:
152
- await self.store.update_token(self.session.token, response.token)
153
- self.session.token = response.token
192
+ if login_response.token is not None and login_response.token != self.session.token:
193
+ await self.store.update_token(self.session.token, login_response.token)
194
+ self.session.token = login_response.token
154
195
 
155
- self.me = response.profile
156
- self.chats = response.chats
196
+ self.chats = login_response.chats
157
197
  self.users[self.me.contact.id] = self.me.contact
158
- self.contacts = response.contacts
159
- self.messages = response.messages
198
+
199
+ if (
200
+ login2_response
201
+ and login_response.login2_flags
202
+ and login_response.login2_flags.contact_enabled
203
+ ):
204
+ self.contacts = login2_response.contacts
205
+ else:
206
+ self.contacts = login_response.contacts
207
+
208
+ self.messages = login_response.messages
160
209
 
161
210
  self.started = True
162
211
  logger.info(
@@ -168,13 +217,37 @@ class App(Generic[ClientT]):
168
217
  if self._telemetry:
169
218
  self._telemetry.start()
170
219
 
171
- async def handshake(self, device_id: str) -> None:
172
- await self.api.session.handshake(
220
+ @staticmethod
221
+ def _is_invalid_login_token_error(exc: Exception) -> bool:
222
+ return (
223
+ isinstance(exc, ApiError)
224
+ and exc.opcode == Opcode.LOGIN
225
+ and any(
226
+ err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL")
227
+ )
228
+ )
229
+
230
+ async def login(self) -> tuple[LoginResponse, Login2Response | None]:
231
+ login_response = await self.api.auth.login(
232
+ self.config.device.user_agent,
233
+ )
234
+
235
+ if login_response.login2_flags and login_response.login2_flags.enabled:
236
+ logger.debug("login2 required; proceeding with login2")
237
+ login2_response = await self.api.auth.mobile_login2(login_response.login2_flags)
238
+
239
+ return login_response, login2_response
240
+
241
+ return login_response, None
242
+
243
+ async def handshake(self, device_id: str) -> HandshakeResponse:
244
+ response = await self.api.session.handshake(
173
245
  self.config.device.mt_instance_id,
174
246
  self.config.device.user_agent,
175
247
  device_id,
176
248
  )
177
249
  logger.debug("handshake completed device_id=%s", device_id)
250
+ return response
178
251
 
179
252
  async def close(self) -> None:
180
253
  if self._telemetry:
@@ -246,7 +319,7 @@ class App(Generic[ClientT]):
246
319
  while True:
247
320
  await self.invoke(
248
321
  opcode=Opcode.PING,
249
- payload={"interactive": True},
322
+ payload={"interactive": self.config.interactive},
250
323
  timeout=self.config.request_timeout,
251
324
  )
252
325
  await asyncio.sleep(30)
pymax/auth/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from .base import AuthFlow
2
+ from .exceptions import PasswordAttemptsExceededError
2
3
  from .providers import (
3
4
  ConsolePasswordProvider,
4
5
  ConsoleQrHandler,
@@ -17,6 +18,7 @@ __all__ = (
17
18
  "ConsoleQrHandler",
18
19
  "ConsoleSmsCodeProvider",
19
20
  "EmailCodeProvider",
21
+ "PasswordAttemptsExceededError",
20
22
  "PasswordProvider",
21
23
  "QrAuthFlow",
22
24
  "QrHandler",
@@ -0,0 +1,9 @@
1
+ class PasswordAttemptsExceededError(Exception):
2
+ """Исчерпан лимит попыток ввода пароля 2FA.
3
+
4
+ Наследуется напрямую от ``Exception`` и не перехватывается как
5
+ ``PyMaxError``.
6
+ """
7
+
8
+ def __init__(self) -> None:
9
+ super().__init__("2FA password attempts exhausted")
pymax/auth/providers.py CHANGED
@@ -113,7 +113,11 @@ class EmailCodeProvider(Protocol):
113
113
 
114
114
 
115
115
  class ConsoleEmailCodeProvider:
116
- """Консольный provider кода подтверждения email для 2FA."""
116
+ """Консольный provider кода подтверждения email для 2FA.
117
+
118
+ Блокирующий ``input()`` выполняется в worker thread и не останавливает
119
+ asyncio event loop.
120
+ """
117
121
 
118
122
  async def get_code(self, email: str) -> str:
119
123
  """Запрашивает email-код в консоли.
@@ -124,4 +128,4 @@ class ConsoleEmailCodeProvider:
124
128
  Returns:
125
129
  Введенный пользователем код.
126
130
  """
127
- return input(f"Enter 2FA email code for {email}: ").strip()
131
+ return (await asyncio.to_thread(input, f"Enter 2FA email code for {email}: ")).strip()