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.
- {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +17 -9
- {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/RECORD +74 -45
- {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +5 -1
- pymax/_data/apk_fingerprints.json +272 -0
- pymax/api/auth/service.py +1 -0
- pymax/api/auth/types.py +3 -0
- pymax/api/bots/payloads.py +8 -0
- pymax/api/bots/service.py +13 -1
- pymax/api/chats/enums.py +1 -0
- pymax/api/chats/payloads.py +19 -0
- pymax/api/chats/service.py +34 -0
- pymax/api/facade.py +2 -0
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +47 -1
- pymax/api/messages/service.py +308 -24
- pymax/api/response.py +1 -1
- pymax/api/stories/__init__.py +2 -0
- pymax/api/stories/enums.py +19 -0
- pymax/api/stories/payloads.py +64 -0
- pymax/api/stories/service.py +198 -0
- pymax/api/uploads/payloads.py +10 -1
- pymax/api/uploads/service.py +94 -7
- pymax/app.py +16 -4
- pymax/auth/qr.py +11 -1
- pymax/base.py +70 -25
- pymax/client.py +0 -1
- pymax/config.py +38 -32
- pymax/di/__init__.py +2 -0
- pymax/di/context.py +18 -0
- pymax/di/resolver.py +38 -0
- pymax/dispatch/__init__.py +7 -0
- pymax/dispatch/dispatcher.py +143 -40
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +23 -3
- pymax/dispatch/middleware.py +32 -0
- pymax/dispatch/resolvers.py +4 -0
- pymax/dispatch/router.py +86 -34
- pymax/filters/__init__.py +15 -0
- pymax/filters/base.py +218 -0
- pymax/filters/containers/__init__.py +2 -0
- pymax/filters/containers/chat.py +240 -0
- pymax/filters/containers/message.py +147 -0
- pymax/filters/filters.py +211 -0
- pymax/filters/magic.py +13 -0
- pymax/filters/presets.py +88 -0
- pymax/filters/refs.py +56 -0
- pymax/formatting/markdown.py +55 -1
- pymax/fsm/__init__.py +3 -0
- pymax/fsm/context.py +30 -0
- pymax/fsm/protocol.py +14 -0
- pymax/fsm/state.py +70 -0
- pymax/fsm/storage.py +151 -0
- pymax/infra/base.py +2 -0
- pymax/infra/bots.py +20 -0
- pymax/infra/chat.py +28 -0
- pymax/infra/message.py +195 -6
- pymax/infra/stories.py +205 -0
- pymax/integrations/__init__.py +0 -0
- pymax/integrations/dishka.py +109 -0
- pymax/protocol/enums.py +2 -0
- pymax/protocol/tcp/payload.py +0 -2
- pymax/types/domain/__init__.py +23 -1
- pymax/types/domain/attachments/__init__.py +1 -1
- pymax/types/domain/attachments/keyboards/__init__.py +1 -1
- pymax/types/domain/attachments/keyboards/enums.py +12 -0
- pymax/types/domain/attachments/keyboards/inline.py +74 -3
- pymax/types/domain/callback.py +19 -0
- pymax/types/domain/comment.py +24 -0
- pymax/types/domain/message.py +91 -22
- pymax/types/domain/story.py +126 -0
- pymax/types/events/reaction.py +28 -4
- pymax/versions/catalog.py +1 -1
- {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))
|
pymax/api/uploads/payloads.py
CHANGED
|
@@ -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
|
pymax/api/uploads/service.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|