maxapi-python 2.4.0__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 (47) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
  2. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +4 -2
  5. pymax/_data/apk_fingerprints.json +102 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +14 -2
  8. pymax/api/chats/payloads.py +2 -1
  9. pymax/api/chats/service.py +7 -0
  10. pymax/api/messages/__init__.py +1 -1
  11. pymax/api/messages/payloads.py +9 -3
  12. pymax/api/messages/service.py +106 -12
  13. pymax/api/uploads/payloads.py +33 -1
  14. pymax/api/uploads/service.py +77 -62
  15. pymax/app.py +23 -4
  16. pymax/auth/__init__.py +2 -0
  17. pymax/auth/exceptions.py +9 -0
  18. pymax/auth/providers.py +6 -2
  19. pymax/auth/sms.py +11 -1
  20. pymax/base.py +83 -9
  21. pymax/client.py +39 -10
  22. pymax/client_web.py +15 -12
  23. pymax/config.py +63 -21
  24. pymax/files/base.py +58 -1
  25. pymax/files/video.py +32 -40
  26. pymax/files/voice.py +18 -4
  27. pymax/fingerprint/fingerprint.py +5 -22
  28. pymax/fingerprint/models.py +1 -0
  29. pymax/infra/chat.py +9 -5
  30. pymax/infra/message.py +9 -3
  31. pymax/infra/protocol.py +5 -1
  32. pymax/session/__init__.py +1 -1
  33. pymax/session/models.py +30 -0
  34. pymax/session/protocol.py +33 -7
  35. pymax/session/store.py +73 -5
  36. pymax/transport/tcp.py +11 -3
  37. pymax/types/domain/attachments/call.py +7 -1
  38. pymax/types/domain/attachments/contact.py +3 -3
  39. pymax/types/domain/attachments/control.py +3 -0
  40. pymax/types/domain/attachments/enums.py +16 -0
  41. pymax/types/domain/attachments/video.py +3 -3
  42. pymax/types/domain/enums.py +7 -0
  43. pymax/types/domain/message.py +87 -7
  44. pymax/versions/__init__.py +2 -0
  45. pymax/versions/catalog.py +104 -0
  46. pymax/versions/exceptions.py +10 -0
  47. {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import time
4
5
  from collections.abc import Sequence
5
- from typing import TYPE_CHECKING, TypeAlias
6
+ from datetime import datetime, timedelta
7
+ from typing import TYPE_CHECKING, TypeAlias, TypeVar
6
8
 
7
9
  from pymax.api.binding import bind_api_model, bind_api_models
8
10
  from pymax.api.response import (
@@ -16,8 +18,10 @@ from pymax.api.uploads.payloads import (
16
18
  AttachFilePayload,
17
19
  AttachPhotoPayload,
18
20
  VideoAttachPayload,
21
+ VideoNoteAttachPayload,
22
+ VoiceAttachPayload,
19
23
  )
20
- from pymax.exceptions import UploadError
24
+ from pymax.exceptions import ApiError, UploadError
21
25
  from pymax.files import File, Photo, Video, VideoNote, Voice
22
26
  from pymax.formatting.markdown import Formatter
23
27
  from pymax.logging import get_logger
@@ -36,6 +40,7 @@ from .enums import ItemType, MessagePayloadKey, ReadAction
36
40
  from .payloads import (
37
41
  AddReactionPayload,
38
42
  ChatHistoryPayload,
43
+ DelayedAttributes,
39
44
  DeleteMessagePayload,
40
45
  EditMessagePayload,
41
46
  ForwardLink,
@@ -60,6 +65,9 @@ if TYPE_CHECKING:
60
65
 
61
66
  SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote
62
67
  SendAttachments: TypeAlias = Sequence[SendAttachment] | None
68
+ DateTimeUnion: TypeAlias = datetime | timedelta | int
69
+
70
+ T = TypeVar("T")
63
71
 
64
72
  logger = get_logger(__name__)
65
73
 
@@ -79,8 +87,12 @@ class MessageService:
79
87
 
80
88
  async def _upload_attachments(
81
89
  self, attachments: SendAttachments
82
- ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll]:
83
- result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = []
90
+ ) -> list[
91
+ AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
92
+ ]: # TODO: TypeAlias
93
+ result: list[
94
+ AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
95
+ ] = []
84
96
  if not attachments:
85
97
  return result
86
98
 
@@ -101,7 +113,7 @@ class MessageService:
101
113
 
102
114
  result.append(upload_result)
103
115
 
104
- elif isinstance(attachment, Video):
116
+ elif isinstance(attachment, (Video, VideoNote)):
105
117
  upload_result = await self.app.api.uploads.upload_video(attachment)
106
118
  if not upload_result:
107
119
  logger.error("Video uploading failed")
@@ -122,6 +134,62 @@ class MessageService:
122
134
 
123
135
  return result
124
136
 
137
+ def _convert_time(self, time: DateTimeUnion) -> int:
138
+ if isinstance(time, datetime):
139
+ return int(time.timestamp() * 1000)
140
+
141
+ if isinstance(time, timedelta):
142
+ return int((datetime.now() + time).timestamp() * 1000)
143
+
144
+ return time * 1000
145
+
146
+ async def _wait_for_upload_signal(
147
+ self,
148
+ waiters: dict[int, asyncio.Future[T]],
149
+ video_id: int,
150
+ ) -> None:
151
+ loop = asyncio.get_running_loop()
152
+ future: asyncio.Future[T] = loop.create_future()
153
+
154
+ waiters[video_id] = future
155
+ try:
156
+ await asyncio.wait_for(future, timeout=60)
157
+ except TimeoutError:
158
+ logger.warning(
159
+ "Timed out waiting for video processing notification video_id=%s",
160
+ video_id,
161
+ )
162
+ raise UploadError(f"Timed out waiting for video processing video_id={video_id}")
163
+ finally:
164
+ waiters.pop(video_id, None)
165
+
166
+ async def _process_attachment_error(
167
+ self,
168
+ attaches: list[
169
+ AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
170
+ ],
171
+ ):
172
+ attachment: VoiceAttachPayload | VideoNoteAttachPayload | None = None
173
+ for attach in attaches:
174
+ if isinstance(attach, (VoiceAttachPayload, VideoNoteAttachPayload)):
175
+ attachment = attach
176
+ break
177
+
178
+ if attachment is None:
179
+ raise ValueError(
180
+ "Unexpected internal state: Non voice/video_note attach is not ready. "
181
+ + "Please, report that issue"
182
+ )
183
+
184
+ if isinstance(attachment, VoiceAttachPayload):
185
+ await self._wait_for_upload_signal(
186
+ self.app.api.uploads.voice_upload_waiters, attachment.video_id
187
+ )
188
+ else:
189
+ await self._wait_for_upload_signal(
190
+ self.app.api.uploads.video_upload_waiters, attachment.video_id
191
+ )
192
+
125
193
  async def send_message(
126
194
  self,
127
195
  chat_id: int,
@@ -130,6 +198,7 @@ class MessageService:
130
198
  attachments: SendAttachments = None,
131
199
  *,
132
200
  notify: bool = True,
201
+ send_at: DateTimeUnion | None = None,
133
202
  ) -> Message:
134
203
  logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0)
135
204
 
@@ -142,19 +211,34 @@ class MessageService:
142
211
  else:
143
212
  clean_text, elements = None, []
144
213
 
214
+ attaches = await self._upload_attachments(attachments)
215
+
145
216
  frame = SendMessagePayload(
146
217
  chat_id=chat_id,
147
218
  message=SendMessagePayloadMessage(
148
219
  text=clean_text,
149
220
  cid=self._next_cid(),
150
221
  elements=elements,
151
- attaches=await self._upload_attachments(attachments),
222
+ attaches=attaches,
152
223
  link=ReplyLink(message_id=reply_to) if reply_to else None,
224
+ delayed_attributes=DelayedAttributes(
225
+ time_to_fire=self._convert_time(send_at),
226
+ notify_sender=notify,
227
+ )
228
+ if send_at
229
+ else None,
153
230
  ),
154
231
  notify=notify,
155
232
  )
156
233
 
157
- response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
234
+ try:
235
+ response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
236
+ except ApiError as e:
237
+ if e.error == "attachment.not.ready":
238
+ await self._process_attachment_error(attaches)
239
+ response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
240
+ else:
241
+ raise
158
242
 
159
243
  message = bind_api_model(
160
244
  self.app,
@@ -241,15 +325,25 @@ class MessageService:
241
325
  else:
242
326
  clean_text, elements = None, []
243
327
 
328
+ attaches = await self._upload_attachments(attachments)
329
+
244
330
  frame = EditMessagePayload(
245
331
  chat_id=chat_id,
246
332
  message_id=message_id,
247
333
  text=clean_text,
248
334
  elements=elements,
249
- attachments=await self._upload_attachments(attachments),
335
+ attachments=attaches,
250
336
  )
337
+ try:
338
+ response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
339
+ except ApiError as e:
340
+ if e.error == "attachment.not.ready":
341
+ await self._process_attachment_error(attaches)
342
+
343
+ response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
344
+ else:
345
+ raise
251
346
 
252
- response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
253
347
  message = require_payload_item_model(
254
348
  response,
255
349
  MessagePayloadKey.MESSAGE,
@@ -385,7 +479,7 @@ class MessageService:
385
479
  async def add_reaction(
386
480
  self,
387
481
  chat_id: int,
388
- message_id: str,
482
+ message_id: int,
389
483
  reaction: str,
390
484
  ) -> ReactionInfo | None:
391
485
  logger.info(
@@ -410,7 +504,7 @@ class MessageService:
410
504
  async def get_reactions(
411
505
  self,
412
506
  chat_id: int,
413
- message_ids: list[str],
507
+ message_ids: list[int],
414
508
  ) -> dict[str, ReactionInfo] | None:
415
509
  logger.info(
416
510
  "getting reactions chat_id=%s message_ids=%s",
@@ -438,7 +532,7 @@ class MessageService:
438
532
  async def remove_reaction(
439
533
  self,
440
534
  chat_id: int,
441
- message_id: str,
535
+ message_id: int,
442
536
  ) -> ReactionInfo | None:
443
537
  logger.info(
444
538
  "removing reaction chat_id=%s message_id=%s",
@@ -1,4 +1,6 @@
1
- from pydantic import Field
1
+ from typing import Any
2
+
3
+ from pydantic import Field, SerializerFunctionWrapHandler, model_serializer
2
4
 
3
5
  from pymax.api.models import CamelModel
4
6
  from pymax.types import AttachmentType
@@ -18,6 +20,36 @@ class VideoAttachPayload(CamelModel):
18
20
  duration: int | None = None
19
21
  wave: bytes | None = None
20
22
 
23
+ @model_serializer(mode="wrap")
24
+ def serialize_attachment(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
25
+ payload = handler(self)
26
+
27
+ if self.type == AttachmentType.AUDIO:
28
+ payload.pop("videoId", None)
29
+ payload.pop("videoType", None)
30
+ if not self.token:
31
+ payload.pop("token", None)
32
+ payload["audioId"] = self.video_id
33
+ elif self.video_type == 1:
34
+ if self.token:
35
+ payload.pop("videoId", None)
36
+ else:
37
+ payload.pop("token", None)
38
+ payload["videoId"] = self.video_id
39
+
40
+ return payload
41
+
42
+
43
+ class VoiceAttachPayload(VideoAttachPayload):
44
+ type: AttachmentType = Field(default=AttachmentType.AUDIO, serialization_alias="_type")
45
+ video_id: int = Field(exclude=True)
46
+ video_type: int = Field(default=0, exclude=True)
47
+
48
+
49
+ class VideoNoteAttachPayload(VideoAttachPayload):
50
+ video_id: int = Field(exclude=True)
51
+ video_type: int = 1
52
+
21
53
 
22
54
  class AttachFilePayload(CamelModel):
23
55
  type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type")
@@ -15,7 +15,7 @@ from pymax.exceptions import UploadError
15
15
  from pymax.files import File, Photo, Video, VideoNote, Voice
16
16
  from pymax.logging import get_logger
17
17
  from pymax.protocol import Opcode
18
- from pymax.types import AttachmentType, AudioUploadSignal
18
+ from pymax.types import AudioUploadSignal
19
19
 
20
20
  from .models import (
21
21
  FileUploadResponse,
@@ -27,6 +27,8 @@ from .payloads import (
27
27
  AttachPhotoPayload,
28
28
  UploadPayload,
29
29
  VideoAttachPayload,
30
+ VideoNoteAttachPayload,
31
+ VoiceAttachPayload,
30
32
  )
31
33
 
32
34
  if TYPE_CHECKING:
@@ -182,7 +184,7 @@ class UploadService:
182
184
  logger.debug("Photo upload complete photo_id=%s", photo_id)
183
185
  return AttachPhotoPayload(photo_token=token)
184
186
 
185
- async def upload_voice(self, voice: Voice) -> VideoAttachPayload:
187
+ async def upload_voice(self, voice: Voice) -> VoiceAttachPayload:
186
188
  logger.info("Uploading voice")
187
189
 
188
190
  payload = UploadPayload(
@@ -227,12 +229,23 @@ class UploadService:
227
229
  logger.exception("Failed to get voice size")
228
230
  raise UploadError("Failed to get voice size") from e
229
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
+
230
242
  headers = {
231
243
  "Content-Disposition": f"attachment; filename={quote(voice.name)}",
232
- "Content-Range": f"0-{file_size - 1}/{file_size}",
244
+ "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
233
245
  "Content-Length": str(file_size),
234
246
  "Connection": "keep-alive",
235
247
  "Content-Type": "application/octet-stream",
248
+ "User-Agent": quote(user_agent),
236
249
  }
237
250
 
238
251
  logger.debug(
@@ -240,15 +253,11 @@ class UploadService:
240
253
  headers["Content-Range"],
241
254
  )
242
255
 
243
- loop = asyncio.get_running_loop()
244
- future: asyncio.Future[AudioUploadSignal] = loop.create_future()
245
-
246
- timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
256
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
247
257
 
248
258
  video_id = upload_info.video_id
249
259
  token = upload_info.token
250
260
 
251
- self.voice_upload_waiters[video_id] = future
252
261
  logger.debug("Voice upload waiter registered voice_id=%s", video_id)
253
262
 
254
263
  try:
@@ -279,24 +288,29 @@ class UploadService:
279
288
  f"{response.status} voice_id={video_id}"
280
289
  )
281
290
 
282
- try:
283
- logger.debug(
284
- "Waiting for voice processing notification voice_id=%s",
285
- video_id,
286
- )
287
- await asyncio.wait_for(future, 60)
288
- except asyncio.TimeoutError:
289
- logger.warning(
290
- "Timed out waiting for voice processing notification voice_id=%s",
291
- video_id,
292
- )
293
- raise UploadError(
294
- f"Timed out waiting for voice processing voice_id={video_id}"
295
- )
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
+ # )
296
307
 
297
308
  logger.debug("Voice upload complete voice_id=%s", video_id)
298
- return VideoAttachPayload(
299
- type=AttachmentType.AUDIO, video_id=video_id, token=token
309
+ return VoiceAttachPayload(
310
+ video_id=video_id,
311
+ token=token,
312
+ duration=await voice.get_duration(),
313
+ wave=b"\x00" * 80,
300
314
  )
301
315
 
302
316
  except UploadError:
@@ -314,7 +328,9 @@ class UploadService:
314
328
  self.voice_upload_waiters.pop(video_id, None)
315
329
  logger.debug("Voice upload waiter removed voice_id=%s", video_id)
316
330
 
317
- async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload:
331
+ async def upload_video(
332
+ self, uploadable_video: Video | VideoNote
333
+ ) -> VideoAttachPayload | VideoNoteAttachPayload:
318
334
  logger.info("Uploading video")
319
335
  logger.debug("Preparing video upload payload")
320
336
 
@@ -366,10 +382,10 @@ class UploadService:
366
382
  file_size,
367
383
  )
368
384
 
369
- timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
385
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
370
386
 
371
387
  headers = {
372
- "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}",
388
+ "Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}",
373
389
  "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
374
390
  "Content-Length": str(file_size),
375
391
  "Connection": "keep-alive",
@@ -427,10 +443,9 @@ class UploadService:
427
443
  thumbhash += "=" * (-len(thumbhash) % 4)
428
444
  thumbhash = base64.b64decode(thumbhash)
429
445
 
430
- return VideoAttachPayload(
446
+ return VideoNoteAttachPayload(
431
447
  video_id=video_id,
432
448
  token=token,
433
- video_type=1,
434
449
  thumbhash=thumbhash,
435
450
  duration=await uploadable_video.get_duration(),
436
451
  )
@@ -532,51 +547,51 @@ class UploadService:
532
547
 
533
548
  file_id = upload_info.file_id
534
549
 
550
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
551
+
535
552
  self.file_upload_waiters[file_id] = future
536
553
  logger.debug("File upload waiter registered file_id=%s", file_id)
537
554
 
538
555
  try:
539
- async with aiohttp.ClientSession(
540
- proxy=self.app.config.proxy,
541
- ) as session:
542
- async with session.post(
556
+ async with (
557
+ aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session,
558
+ session.post(
543
559
  url=upload_info.url,
544
560
  headers=headers,
545
561
  data=file.iter_chunks(1024 * 1024),
546
- ) as response:
547
- logger.debug(
548
- "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",
549
573
  response.status,
550
574
  file_id,
551
575
  )
576
+ raise UploadError(
577
+ f"File upload failed with status {response.status} file_id={file_id}"
578
+ )
552
579
 
553
- if response.status != HTTPStatus.OK:
554
- logger.error(
555
- "File upload failed with status %s file_id=%s",
556
- response.status,
557
- file_id,
558
- )
559
- raise UploadError(
560
- f"File upload failed with status {response.status} file_id={file_id}"
561
- )
562
-
563
- try:
564
- logger.debug(
565
- "Waiting for file processing notification file_id=%s",
566
- file_id,
567
- )
568
- await asyncio.wait_for(future, 60)
569
- except asyncio.TimeoutError:
570
- logger.warning(
571
- "Timed out waiting for file processing notification file_id=%s",
572
- file_id,
573
- )
574
- raise UploadError(
575
- f"Timed out waiting for file processing file_id={file_id}"
576
- )
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}")
577
592
 
578
- logger.debug("File upload complete file_id=%s", file_id)
579
- return AttachFilePayload(file_id=file_id)
593
+ logger.debug("File upload complete file_id=%s", file_id)
594
+ return AttachFilePayload(file_id=file_id)
580
595
 
581
596
  except UploadError:
582
597
  raise
pymax/app.py CHANGED
@@ -12,8 +12,8 @@ from pymax.fingerprint import FingerprintGenerator
12
12
  from pymax.logging import get_logger
13
13
  from pymax.protocol import Command, InboundFrame, OutboundFrame
14
14
  from pymax.protocol.enums import Opcode
15
- from pymax.session import SessionStore
16
- from pymax.session.models import SessionInfo
15
+ from pymax.session import InMemoryStore, SessionStore
16
+ from pymax.session.models import SessionInfo, resolve_session_user_agent
17
17
  from pymax.telemetry import TelemetryService
18
18
  from pymax.types import MaxApiError, Message
19
19
  from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
@@ -38,9 +38,15 @@ class App(Generic[ClientT]):
38
38
  self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
39
39
  self.api = ApiFacade(self)
40
40
  self.config = config
41
- 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
+ )
42
46
  self.auth_flow = auth_flow
43
- self.fingerprint_generator = FingerprintGenerator()
47
+ self.fingerprint_generator: FingerprintGenerator | None = (
48
+ FingerprintGenerator(self.config.fingering) if self.config.fingering else None
49
+ )
44
50
 
45
51
  self.me: Profile | None = None
46
52
  self.chats: list[Chat] | None = None
@@ -77,6 +83,17 @@ class App(Generic[ClientT]):
77
83
  else:
78
84
  session_data.mt_instance_id = self.config.device.mt_instance_id
79
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
+
80
97
  try:
81
98
  logger.debug("opening connection")
82
99
  await self.connection.open()
@@ -105,6 +122,7 @@ class App(Generic[ClientT]):
105
122
  device_id=self.config.device.device_id,
106
123
  phone=self.config.phone or "",
107
124
  mt_instance_id=self.config.device.mt_instance_id,
125
+ user_agent=self.config.device.user_agent,
108
126
  )
109
127
  )
110
128
  else:
@@ -120,6 +138,7 @@ class App(Generic[ClientT]):
120
138
  device_id=self.config.device.device_id,
121
139
  phone=self.config.phone or "",
122
140
  mt_instance_id=self.config.device.mt_instance_id,
141
+ user_agent=self.config.device.user_agent,
123
142
  )
124
143
  )
125
144
  logger.info("new session saved")
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()
pymax/auth/sms.py CHANGED
@@ -6,6 +6,7 @@ from pymax.auth.base import AuthFlow
6
6
  from pymax.exceptions import ApiError
7
7
  from pymax.logging import get_logger
8
8
 
9
+ from .exceptions import PasswordAttemptsExceededError
9
10
  from .models import AuthResult
10
11
  from .providers import (
11
12
  ConsolePasswordProvider,
@@ -116,7 +117,10 @@ class SmsAuthFlow(AuthFlow):
116
117
  hint: str | None,
117
118
  ) -> str:
118
119
  logger.info("starting 2fa password authentication")
119
- while True:
120
+ attempt = 0
121
+ while (
122
+ app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
123
+ ):
120
124
  password = await self.password_provider.get_password(hint)
121
125
  logger.debug(
122
126
  "2fa password provider returned password_set=%s",
@@ -124,16 +128,19 @@ class SmsAuthFlow(AuthFlow):
124
128
  )
125
129
  if not password:
126
130
  logger.warning("2fa password is empty; retrying")
131
+ attempt += 1
127
132
  continue
128
133
 
129
134
  try:
130
135
  response = await app.api.auth.check_password(track_id, password)
131
136
  except ApiError as e:
132
137
  logger.error("2fa password check failed: %s", e)
138
+ attempt += 1
133
139
  continue
134
140
 
135
141
  if response.error:
136
142
  logger.error("2fa password check failed error=%s", response.error)
143
+ attempt += 1
137
144
  continue
138
145
 
139
146
  if response.login_token:
@@ -141,3 +148,6 @@ class SmsAuthFlow(AuthFlow):
141
148
  return response.login_token
142
149
 
143
150
  logger.error("2fa password response did not contain login token; retrying")
151
+ attempt += 1
152
+
153
+ raise PasswordAttemptsExceededError