maxapi-python 2.4.1__py3-none-any.whl → 2.5.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 (74) hide show
  1. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +17 -9
  2. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/RECORD +74 -45
  3. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +5 -1
  5. pymax/_data/apk_fingerprints.json +272 -0
  6. pymax/api/auth/service.py +1 -0
  7. pymax/api/auth/types.py +3 -0
  8. pymax/api/bots/payloads.py +8 -0
  9. pymax/api/bots/service.py +13 -1
  10. pymax/api/chats/enums.py +1 -0
  11. pymax/api/chats/payloads.py +19 -0
  12. pymax/api/chats/service.py +34 -0
  13. pymax/api/facade.py +2 -0
  14. pymax/api/messages/enums.py +1 -0
  15. pymax/api/messages/payloads.py +47 -1
  16. pymax/api/messages/service.py +308 -24
  17. pymax/api/response.py +1 -1
  18. pymax/api/stories/__init__.py +2 -0
  19. pymax/api/stories/enums.py +19 -0
  20. pymax/api/stories/payloads.py +64 -0
  21. pymax/api/stories/service.py +198 -0
  22. pymax/api/uploads/payloads.py +10 -1
  23. pymax/api/uploads/service.py +94 -7
  24. pymax/app.py +16 -4
  25. pymax/auth/qr.py +11 -1
  26. pymax/base.py +70 -25
  27. pymax/client.py +0 -1
  28. pymax/config.py +38 -32
  29. pymax/di/__init__.py +2 -0
  30. pymax/di/context.py +18 -0
  31. pymax/di/resolver.py +38 -0
  32. pymax/dispatch/__init__.py +7 -0
  33. pymax/dispatch/dispatcher.py +143 -40
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +23 -3
  36. pymax/dispatch/middleware.py +32 -0
  37. pymax/dispatch/resolvers.py +4 -0
  38. pymax/dispatch/router.py +86 -34
  39. pymax/filters/__init__.py +15 -0
  40. pymax/filters/base.py +218 -0
  41. pymax/filters/containers/__init__.py +2 -0
  42. pymax/filters/containers/chat.py +240 -0
  43. pymax/filters/containers/message.py +147 -0
  44. pymax/filters/filters.py +211 -0
  45. pymax/filters/magic.py +13 -0
  46. pymax/filters/presets.py +88 -0
  47. pymax/filters/refs.py +56 -0
  48. pymax/formatting/markdown.py +55 -1
  49. pymax/fsm/__init__.py +3 -0
  50. pymax/fsm/context.py +30 -0
  51. pymax/fsm/protocol.py +14 -0
  52. pymax/fsm/state.py +70 -0
  53. pymax/fsm/storage.py +151 -0
  54. pymax/infra/base.py +2 -0
  55. pymax/infra/bots.py +20 -0
  56. pymax/infra/chat.py +28 -0
  57. pymax/infra/message.py +195 -6
  58. pymax/infra/stories.py +205 -0
  59. pymax/integrations/__init__.py +0 -0
  60. pymax/integrations/dishka.py +109 -0
  61. pymax/protocol/enums.py +2 -0
  62. pymax/protocol/tcp/payload.py +0 -2
  63. pymax/types/domain/__init__.py +23 -1
  64. pymax/types/domain/attachments/__init__.py +1 -1
  65. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  66. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  67. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  68. pymax/types/domain/callback.py +19 -0
  69. pymax/types/domain/comment.py +24 -0
  70. pymax/types/domain/message.py +91 -22
  71. pymax/types/domain/story.py +126 -0
  72. pymax/types/events/reaction.py +28 -4
  73. pymax/versions/catalog.py +1 -1
  74. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,64 @@
1
+ from pydantic import Field
2
+
3
+ from pymax.api.models import CamelModel
4
+ from pymax.api.uploads.payloads import AttachPhotoPayload, StoryVideoAttachPayload
5
+ from pymax.types.domain.story import StoryLayer, StoryOwner
6
+
7
+ from .enums import StoryStatsFilter
8
+
9
+
10
+ class SendStoryPayloadStory(CamelModel):
11
+ cid: int
12
+ media: AttachPhotoPayload | StoryVideoAttachPayload
13
+ settings: int = 1
14
+ expiration: int = 172_800_000
15
+ layers: list[StoryLayer] = Field(default_factory=list)
16
+
17
+
18
+ class SendStoryPayload(CamelModel):
19
+ stories: list[SendStoryPayloadStory]
20
+
21
+
22
+ class GetStoriesPayload(CamelModel):
23
+ owners: list[StoryOwner]
24
+
25
+
26
+ class DeleteStoryPayload(CamelModel):
27
+ story_ids: list[int]
28
+
29
+
30
+ class StoriesListPayload(CamelModel):
31
+ cursor: str = "0"
32
+ count: int = 15
33
+
34
+
35
+ class GetStoriesByIdPayload(CamelModel):
36
+ owner: StoryOwner
37
+ story_ids: list[int]
38
+
39
+
40
+ class GetStoryDetailedStatsPayload(CamelModel):
41
+ story_id: int
42
+ marker: int = 0
43
+ filter: StoryStatsFilter
44
+
45
+
46
+ class MarkStoryPayload(CamelModel):
47
+ owner: StoryOwner
48
+ story_id: int
49
+
50
+
51
+ class EditStoryPayload(CamelModel):
52
+ story_id: int
53
+ settings: int
54
+
55
+
56
+ class StoryReactionPayload(CamelModel):
57
+ reaction_type: int = 0
58
+ id: str
59
+
60
+
61
+ class ReactStoryPayload(CamelModel):
62
+ owner: StoryOwner
63
+ story_id: int
64
+ reaction: StoryReactionPayload | None = None
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import TYPE_CHECKING
5
+
6
+ from pymax.api.response import parse_payload_list, payload_item, require_payload_item_model
7
+ from pymax.api.uploads.payloads import AttachPhotoPayload, StoryVideoAttachPayload
8
+ from pymax.exceptions import PyMaxError
9
+ from pymax.files import Photo, Video
10
+ from pymax.logging import get_logger
11
+ from pymax.protocol import Opcode
12
+ from pymax.types.domain.story import (
13
+ Story,
14
+ StoryClickableLink,
15
+ StoryCoordinates,
16
+ StoryDetailedStats,
17
+ StoryLayer,
18
+ StoryOwner,
19
+ StoryPreview,
20
+ )
21
+
22
+ from .enums import StoryPayloadKey, StoryStatsFilter
23
+ from .payloads import (
24
+ DeleteStoryPayload,
25
+ EditStoryPayload,
26
+ GetStoriesByIdPayload,
27
+ GetStoriesPayload,
28
+ GetStoryDetailedStatsPayload,
29
+ MarkStoryPayload,
30
+ ReactStoryPayload,
31
+ SendStoryPayload,
32
+ SendStoryPayloadStory,
33
+ StoriesListPayload,
34
+ StoryReactionPayload,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from pymax.app import App
39
+
40
+
41
+ logger = get_logger(__name__)
42
+
43
+
44
+ class StoriesService:
45
+ def __init__(self, app: App) -> None:
46
+ self.app = app
47
+ self._prev = int(time.time() * 1000)
48
+
49
+ def _next_cid(self) -> int:
50
+ now = int(time.time() * 1000)
51
+ cid = max(now, self._prev + 1)
52
+ self._prev = cid
53
+ return cid
54
+
55
+ async def send_story(
56
+ self,
57
+ media: Photo | Video,
58
+ *,
59
+ duration: int | None = None,
60
+ link: str | None = None,
61
+ ) -> Story:
62
+ logger.info("sending story media_type=%s", type(media).__name__)
63
+
64
+ attachment: AttachPhotoPayload | StoryVideoAttachPayload
65
+ if isinstance(media, Photo):
66
+ attachment = await self.app.api.uploads.upload_photo(media, upload_type=1)
67
+ elif isinstance(media, Video):
68
+ if duration is None or duration <= 0:
69
+ raise ValueError("Video story duration must be greater than zero")
70
+ attachment = await self.app.api.uploads.upload_story_video(media, duration)
71
+ else:
72
+ raise TypeError("Story media must be Photo or Video")
73
+
74
+ layers: list[StoryLayer] = []
75
+ if link is not None:
76
+ layers.append(
77
+ StoryLayer(
78
+ coordinates=StoryCoordinates(x=0.5, y=0.9, w=0.78, h=0.055),
79
+ clickable_link=StoryClickableLink(url=link),
80
+ )
81
+ )
82
+
83
+ frame = SendStoryPayload(
84
+ stories=[
85
+ SendStoryPayloadStory(
86
+ cid=self._next_cid(),
87
+ media=attachment,
88
+ layers=layers,
89
+ )
90
+ ]
91
+ )
92
+ response = await self.app.invoke(Opcode.STORIES_SEND, frame.to_payload())
93
+ stories = parse_payload_list(response, StoryPayloadKey.STORIES, Story)
94
+ if not stories:
95
+ raise PyMaxError("Story response does not contain a story")
96
+ return stories[0]
97
+
98
+ async def get_stories(self, owner_id: int, owner_type: int = 0) -> list[Story]:
99
+ logger.info("getting stories owner_id=%s", owner_id)
100
+ frame = GetStoriesPayload(owners=[StoryOwner(owner_id=owner_id, type=owner_type)])
101
+ response = await self.app.invoke(Opcode.STORIES_GET_BY_OWNER_ID, frame.to_payload())
102
+ peers = payload_item(response, StoryPayloadKey.PEER_STORIES) or []
103
+ return [
104
+ Story.model_validate(story) for peer in peers for story in peer.get("stories") or []
105
+ ]
106
+
107
+ async def delete_story(self, story_id: int) -> bool:
108
+ logger.info("deleting story story_id=%s", story_id)
109
+ frame = DeleteStoryPayload(story_ids=[story_id])
110
+ await self.app.invoke(Opcode.STORIES_DELETE, frame.to_payload())
111
+ return True
112
+
113
+ async def get_stories_feed(
114
+ self,
115
+ cursor: str = "0",
116
+ count: int = 15,
117
+ ) -> tuple[list[StoryPreview], str]:
118
+ logger.info("getting stories feed cursor=%s count=%s", cursor, count)
119
+ frame = StoriesListPayload(cursor=cursor, count=count)
120
+ response = await self.app.invoke(Opcode.STORIES_LIST, frame.to_payload())
121
+ previews = parse_payload_list(response, StoryPayloadKey.STORIES_PREVIEWS, StoryPreview)
122
+ next_cursor = payload_item(response, StoryPayloadKey.CURSOR, str) or ""
123
+ return previews, next_cursor
124
+
125
+ async def get_stories_by_id(
126
+ self,
127
+ owner_id: int,
128
+ story_ids: list[int],
129
+ owner_type: int = 0,
130
+ ) -> list[Story]:
131
+ logger.info("getting stories by id owner_id=%s count=%s", owner_id, len(story_ids))
132
+ frame = GetStoriesByIdPayload(
133
+ owner=StoryOwner(owner_id=owner_id, type=owner_type),
134
+ story_ids=story_ids,
135
+ )
136
+ response = await self.app.invoke(Opcode.STORIES_GET_BY_STORY_ID, frame.to_payload())
137
+ return parse_payload_list(response, StoryPayloadKey.STORIES, Story)
138
+
139
+ async def get_story_detailed_stats(
140
+ self,
141
+ story_id: int,
142
+ filter: StoryStatsFilter,
143
+ marker: int = 0,
144
+ ) -> tuple[list[StoryDetailedStats], int]:
145
+ logger.info("getting story detailed stats story_id=%s marker=%s", story_id, marker)
146
+ frame = GetStoryDetailedStatsPayload(story_id=story_id, filter=filter, marker=marker)
147
+ response = await self.app.invoke(Opcode.STORIES_GET_DETAILED_STATS, frame.to_payload())
148
+ stats = parse_payload_list(
149
+ response, StoryPayloadKey.STORY_DETAILED_STATS, StoryDetailedStats
150
+ )
151
+ next_marker = payload_item(response, StoryPayloadKey.MARKER, int) or 0
152
+ return stats, next_marker
153
+
154
+ async def read_story(self, story_id: int, owner_id: int, owner_type: int = 0) -> bool:
155
+ logger.info("marking story as read story_id=%s owner_id=%s", story_id, owner_id)
156
+ frame = MarkStoryPayload(
157
+ owner=StoryOwner(owner_id=owner_id, type=owner_type),
158
+ story_id=story_id,
159
+ )
160
+ await self.app.invoke(Opcode.STORIES_MARK, frame.to_payload())
161
+ return True
162
+
163
+ async def edit_story(self, story_id: int, settings: int) -> Story:
164
+ logger.info("editing story settings story_id=%s", story_id)
165
+ frame = EditStoryPayload(story_id=story_id, settings=settings)
166
+ response = await self.app.invoke(Opcode.STORIES_EDIT, frame.to_payload())
167
+ return require_payload_item_model(response, StoryPayloadKey.STORY, Story)
168
+
169
+ async def add_story_reaction(
170
+ self,
171
+ owner_id: int,
172
+ story_id: int,
173
+ reaction: str,
174
+ reaction_type: int = 0,
175
+ owner_type: int = 0,
176
+ ) -> bool:
177
+ logger.info("adding story reaction owner_id=%s story_id=%s", owner_id, story_id)
178
+ frame = ReactStoryPayload(
179
+ owner=StoryOwner(owner_id=owner_id, type=owner_type),
180
+ story_id=story_id,
181
+ reaction=StoryReactionPayload(reaction_type=reaction_type, id=reaction),
182
+ )
183
+ response = await self.app.invoke(Opcode.STORIES_REACT, frame.to_payload())
184
+ return bool(payload_item(response, StoryPayloadKey.SUCCESS, bool))
185
+
186
+ async def remove_story_reaction(
187
+ self,
188
+ owner_id: int,
189
+ story_id: int,
190
+ owner_type: int = 0,
191
+ ) -> bool:
192
+ logger.info("removing story reaction owner_id=%s story_id=%s", owner_id, story_id)
193
+ frame = ReactStoryPayload(
194
+ owner=StoryOwner(owner_id=owner_id, type=owner_type),
195
+ story_id=story_id,
196
+ )
197
+ response = await self.app.invoke(Opcode.STORIES_REACT, frame.to_payload())
198
+ return bool(payload_item(response, StoryPayloadKey.SUCCESS, bool))
@@ -1,4 +1,4 @@
1
- from typing import Any
1
+ from typing import Any, Literal
2
2
 
3
3
  from pydantic import Field, SerializerFunctionWrapHandler, model_serializer
4
4
 
@@ -11,6 +11,15 @@ class AttachPhotoPayload(CamelModel):
11
11
  photo_token: str
12
12
 
13
13
 
14
+ class StoryVideoAttachPayload(CamelModel):
15
+ type: Literal[AttachmentType.VIDEO] = Field(
16
+ default=AttachmentType.VIDEO, serialization_alias="_type"
17
+ )
18
+ video_type: Literal[2] = 2
19
+ duration: int
20
+ token: str
21
+
22
+
14
23
  class VideoAttachPayload(CamelModel):
15
24
  type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
16
25
  video_id: int
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import base64
5
+ import ssl
5
6
  from http import HTTPStatus
7
+ from importlib import resources
6
8
  from typing import TYPE_CHECKING
7
9
  from urllib.parse import parse_qs, quote, urlparse
8
10
 
@@ -25,6 +27,7 @@ from .models import (
25
27
  from .payloads import (
26
28
  AttachFilePayload,
27
29
  AttachPhotoPayload,
30
+ StoryVideoAttachPayload,
28
31
  UploadPayload,
29
32
  VideoAttachPayload,
30
33
  VideoNoteAttachPayload,
@@ -49,11 +52,22 @@ class UploadService:
49
52
  self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
50
53
  self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
51
54
 
52
- async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
55
+ self.ssl_ctx = ssl.create_default_context()
56
+ self.ssl_ctx.load_verify_locations(
57
+ str(resources.files("pymax._data") / "rootca_ssl_rsa2022.crt")
58
+ )
59
+
60
+ async def upload_photo(
61
+ self,
62
+ photo: Photo,
63
+ profile: bool = False,
64
+ *,
65
+ upload_type: int = 0,
66
+ ) -> AttachPhotoPayload:
53
67
  logger.info("Uploading photo")
54
68
  logger.debug("Preparing photo upload payload")
55
69
 
56
- payload = UploadPayload(profile=profile).to_payload()
70
+ payload = UploadPayload(profile=profile, type=upload_type).to_payload()
57
71
 
58
72
  try:
59
73
  data = await self.app.invoke(
@@ -82,11 +96,9 @@ class UploadService:
82
96
 
83
97
  try:
84
98
  parsed_url = urlparse(url)
85
- photo_id = str(parse_qs(parsed_url.query)["photoIds"][0])
86
- except (KeyError, IndexError) as e:
87
- logger.exception("Photo upload URL does not contain photoIds")
88
- logger.debug("Invalid photo upload URL=%s", url)
89
- raise UploadError("Photo upload URL does not contain photoIds") from e
99
+ query = parse_qs(parsed_url.query)
100
+ photo_ids = query.get("photos") or query.get("photoIds")
101
+ photo_id = str(photo_ids[0]) if photo_ids else None
90
102
  except Exception as e:
91
103
  logger.exception("Failed to parse photo id from upload URL")
92
104
  logger.debug("Invalid photo upload URL=%s", url)
@@ -132,6 +144,7 @@ class UploadService:
132
144
  session.post(
133
145
  url=url,
134
146
  data=form,
147
+ ssl=self.ssl_ctx,
135
148
  ) as response,
136
149
  ):
137
150
  logger.debug("Photo upload HTTP response status=%s", response.status)
@@ -165,6 +178,12 @@ class UploadService:
165
178
  logger.debug("Invalid photo upload response=%r", result)
166
179
  raise UploadError("Invalid photo upload response model") from e
167
180
 
181
+ if photo_id is None:
182
+ if len(model.photos) != 1:
183
+ logger.error("Expected one photo in upload response without a photo id")
184
+ raise UploadError("Expected one photo in upload response without a photo id")
185
+ photo_id = next(iter(model.photos))
186
+
168
187
  try:
169
188
  token = model.photos[photo_id].token
170
189
  except KeyError as e:
@@ -270,6 +289,7 @@ class UploadService:
270
289
  url=upload_info.url,
271
290
  headers=headers,
272
291
  data=voice.iter_chunks(1024 * 1024),
292
+ ssl=self.ssl_ctx,
273
293
  ) as response:
274
294
  logger.debug(
275
295
  "Voice upload HTTP response status=%s voice_id=%s",
@@ -416,6 +436,7 @@ class UploadService:
416
436
  url=upload_info.url,
417
437
  headers=headers,
418
438
  data=uploadable_video.iter_chunks(1024 * 1024),
439
+ ssl=self.ssl_ctx,
419
440
  ) as response:
420
441
  logger.debug(
421
442
  "Video upload HTTP response status=%s video_id=%s",
@@ -489,6 +510,71 @@ class UploadService:
489
510
  self.video_upload_waiters.pop(video_id, None)
490
511
  logger.debug("Video upload waiter removed video_id=%s", video_id)
491
512
 
513
+ async def upload_story_video(self, video: Video, duration: int) -> StoryVideoAttachPayload:
514
+ logger.info("Uploading story video")
515
+ payload = UploadPayload(type=3).to_payload()
516
+
517
+ try:
518
+ data = await self.app.invoke(Opcode.VIDEO_UPLOAD, payload=payload)
519
+ except Exception as e:
520
+ logger.exception("Failed to request story video upload URL")
521
+ raise UploadError("Failed to request story video upload URL") from e
522
+
523
+ try:
524
+ upload_info = VideoUploadResponse.model_validate(data.payload).info[0]
525
+ except (ValidationError, IndexError) as e:
526
+ logger.exception("Invalid story video upload response")
527
+ raise UploadError("Invalid story video upload response") from e
528
+
529
+ try:
530
+ file_size = await video.size()
531
+ except Exception as e:
532
+ logger.exception("Failed to get story video size")
533
+ raise UploadError("Failed to get story video size") from e
534
+
535
+ headers = {
536
+ "Content-Type": "application/octet-stream",
537
+ "Content-Disposition": f"attachment; filename={quote(str(video.name))}",
538
+ "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
539
+ "Content-Length": str(file_size),
540
+ }
541
+ timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
542
+
543
+ try:
544
+ async with (
545
+ aiohttp.ClientSession(timeout=timeout, proxy=self.app.config.proxy) as session,
546
+ session.post(
547
+ url=upload_info.url,
548
+ headers=headers,
549
+ data=video.iter_chunks(1024 * 1024),
550
+ ssl=self.ssl_ctx,
551
+ ) as response,
552
+ ):
553
+ logger.debug("Story video upload HTTP response status=%s", response.status)
554
+ if response.status != HTTPStatus.OK:
555
+ raise UploadError(f"Story video upload failed with status {response.status}")
556
+
557
+ result = await response.json(content_type=None)
558
+ attachment = StoryVideoAttachPayload(
559
+ duration=duration,
560
+ token=result[0]["token"],
561
+ )
562
+
563
+ except UploadError:
564
+ raise
565
+ except aiohttp.ClientError as e:
566
+ logger.exception("HTTP error during story video upload")
567
+ raise UploadError("HTTP error during story video upload") from e
568
+ except asyncio.TimeoutError as e:
569
+ logger.exception("Timed out during story video upload")
570
+ raise UploadError("Timed out during story video upload") from e
571
+ except Exception as e:
572
+ logger.exception("Unexpected error during story video upload")
573
+ raise UploadError("Unexpected error during story video upload") from e
574
+
575
+ logger.debug("Story video upload complete video_id=%s", upload_info.video_id)
576
+ return attachment
577
+
492
578
  async def upload_file(self, file: File) -> AttachFilePayload:
493
579
  logger.info("Uploading file")
494
580
 
@@ -559,6 +645,7 @@ class UploadService:
559
645
  url=upload_info.url,
560
646
  headers=headers,
561
647
  data=file.iter_chunks(1024 * 1024),
648
+ ssl=self.ssl_ctx,
562
649
  ) as response,
563
650
  ):
564
651
  logger.debug(
pymax/app.py CHANGED
@@ -9,6 +9,7 @@ from pymax.dispatch import Dispatcher
9
9
  from pymax.dispatch.router import EventType, Router
10
10
  from pymax.exceptions import ApiError
11
11
  from pymax.fingerprint import FingerprintGenerator
12
+ from pymax.fsm import InMemoryFSMStorage
12
13
  from pymax.logging import get_logger
13
14
  from pymax.protocol import Command, InboundFrame, OutboundFrame
14
15
  from pymax.protocol.enums import Opcode
@@ -43,6 +44,7 @@ class App(Generic[ClientT]):
43
44
  if self.config.persist_session
44
45
  else InMemoryStore()
45
46
  )
47
+ self.fsm_storage: InMemoryFSMStorage = InMemoryFSMStorage()
46
48
  self.auth_flow = auth_flow
47
49
  self.fingerprint_generator: FingerprintGenerator | None = (
48
50
  FingerprintGenerator(self.config.fingering) if self.config.fingering else None
@@ -57,7 +59,7 @@ class App(Generic[ClientT]):
57
59
  self.session: SessionInfo | None = None
58
60
  self.handshake_response: HandshakeResponse | None = None
59
61
 
60
- self.started = False
62
+ self.started: bool = False
61
63
  self._ping_task: asyncio.Task[None] | None = None
62
64
  self._telemetry = TelemetryService(self) if config.telemetry else None
63
65
 
@@ -186,7 +188,7 @@ class App(Generic[ClientT]):
186
188
  if not handled:
187
189
  raise
188
190
 
189
- await self.close()
191
+ await self.disconnect()
190
192
  return
191
193
 
192
194
  if login_response.token is not None and login_response.token != self.session.token:
@@ -208,6 +210,7 @@ class App(Generic[ClientT]):
208
210
  self.messages = login_response.messages
209
211
 
210
212
  self.started = True
213
+
211
214
  logger.info(
212
215
  "client started profile=%s chats=%s",
213
216
  self.me.contact.id,
@@ -249,7 +252,7 @@ class App(Generic[ClientT]):
249
252
  logger.debug("handshake completed device_id=%s", device_id)
250
253
  return response
251
254
 
252
- async def close(self) -> None:
255
+ async def disconnect(self) -> None:
253
256
  if self._telemetry:
254
257
  await self._telemetry.stop()
255
258
 
@@ -258,6 +261,7 @@ class App(Generic[ClientT]):
258
261
  self._ping_task = None
259
262
  if not task.done():
260
263
  task.cancel()
264
+
261
265
  try:
262
266
  await task
263
267
  except asyncio.CancelledError:
@@ -267,10 +271,13 @@ class App(Generic[ClientT]):
267
271
 
268
272
  await self.dispatcher.stop_startup_tasks()
269
273
  await self.connection.close()
270
- await self.store.close()
271
274
 
272
275
  self.started = False
273
276
 
277
+ async def close(self):
278
+ await self.disconnect()
279
+ await self.store.close()
280
+
274
281
  async def invoke(
275
282
  self,
276
283
  opcode: int,
@@ -374,6 +381,11 @@ class App(Generic[ClientT]):
374
381
  )
375
382
  return exc
376
383
 
384
+ def bind_connection(self, connection: ConnectionManager) -> None:
385
+ self.connection = connection
386
+ self.connection.on_event = self.on_event
387
+ self.connection.on_close = self.on_connection_lost
388
+
377
389
  async def on_event(self, frame: InboundFrame) -> None:
378
390
  logger.debug(
379
391
  "event received opcode=%s cmd=%s seq=%s",
pymax/auth/qr.py CHANGED
@@ -5,6 +5,7 @@ import time
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from pymax.auth.base import AuthFlow
8
+ from pymax.auth.exceptions import PasswordAttemptsExceededError
8
9
  from pymax.exceptions import ApiError
9
10
  from pymax.logging import get_logger
10
11
 
@@ -107,7 +108,10 @@ class QrAuthFlow(AuthFlow):
107
108
  hint: str | None,
108
109
  ) -> str:
109
110
  logger.info("starting 2fa password authentication")
110
- while True:
111
+ attempt = 0
112
+ while (
113
+ app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
114
+ ):
111
115
  password = await self.password_provider.get_password(hint)
112
116
  logger.debug(
113
117
  "2fa password provider returned password_set=%s",
@@ -115,16 +119,19 @@ class QrAuthFlow(AuthFlow):
115
119
  )
116
120
  if not password:
117
121
  logger.warning("2fa password is empty; retrying")
122
+ attempt += 1
118
123
  continue
119
124
 
120
125
  try:
121
126
  response = await app.api.auth.check_password(track_id, password)
122
127
  except ApiError as e:
123
128
  logger.error("2fa password check failed: %s", e)
129
+ attempt += 1
124
130
  continue
125
131
 
126
132
  if response.error:
127
133
  logger.error("2fa password check failed error=%s", response.error)
134
+ attempt += 1
128
135
  continue
129
136
 
130
137
  if response.login_token:
@@ -132,3 +139,6 @@ class QrAuthFlow(AuthFlow):
132
139
  return response.login_token
133
140
 
134
141
  logger.error("2fa password response did not contain login token; retrying")
142
+ attempt += 1
143
+
144
+ raise PasswordAttemptsExceededError