maxapi-python 2.4.0__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.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
- maxapi_python-2.5.0.dist-info/RECORD +195 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +8 -2
- pymax/_data/apk_fingerprints.json +374 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +15 -2
- 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 +21 -1
- pymax/api/chats/service.py +41 -0
- pymax/api/facade.py +2 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +56 -4
- pymax/api/messages/service.py +405 -27
- 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 +42 -1
- pymax/api/uploads/service.py +171 -69
- pymax/app.py +39 -8
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/qr.py +11 -1
- pymax/auth/sms.py +11 -1
- pymax/base.py +146 -27
- pymax/client.py +39 -11
- pymax/client_web.py +15 -12
- pymax/config.py +101 -53
- 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/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- 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/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -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 +37 -5
- pymax/infra/message.py +204 -9
- pymax/infra/protocol.py +5 -1
- 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/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +11 -3
- pymax/types/domain/__init__.py +23 -1
- pymax/types/domain/attachments/__init__.py +1 -1
- pymax/types/domain/attachments/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +16 -0
- 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/attachments/video.py +3 -3
- pymax/types/domain/callback.py +19 -0
- pymax/types/domain/comment.py +24 -0
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +158 -9
- pymax/types/domain/story.py +126 -0
- pymax/types/events/reaction.py +28 -4
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- maxapi_python-2.4.0.dist-info/RECORD +0 -161
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,6 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Literal
|
|
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
|
|
@@ -9,6 +11,15 @@ class AttachPhotoPayload(CamelModel):
|
|
|
9
11
|
photo_token: str
|
|
10
12
|
|
|
11
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
|
+
|
|
12
23
|
class VideoAttachPayload(CamelModel):
|
|
13
24
|
type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
|
|
14
25
|
video_id: int
|
|
@@ -18,6 +29,36 @@ class VideoAttachPayload(CamelModel):
|
|
|
18
29
|
duration: int | None = None
|
|
19
30
|
wave: bytes | None = None
|
|
20
31
|
|
|
32
|
+
@model_serializer(mode="wrap")
|
|
33
|
+
def serialize_attachment(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
|
34
|
+
payload = handler(self)
|
|
35
|
+
|
|
36
|
+
if self.type == AttachmentType.AUDIO:
|
|
37
|
+
payload.pop("videoId", None)
|
|
38
|
+
payload.pop("videoType", None)
|
|
39
|
+
if not self.token:
|
|
40
|
+
payload.pop("token", None)
|
|
41
|
+
payload["audioId"] = self.video_id
|
|
42
|
+
elif self.video_type == 1:
|
|
43
|
+
if self.token:
|
|
44
|
+
payload.pop("videoId", None)
|
|
45
|
+
else:
|
|
46
|
+
payload.pop("token", None)
|
|
47
|
+
payload["videoId"] = self.video_id
|
|
48
|
+
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VoiceAttachPayload(VideoAttachPayload):
|
|
53
|
+
type: AttachmentType = Field(default=AttachmentType.AUDIO, serialization_alias="_type")
|
|
54
|
+
video_id: int = Field(exclude=True)
|
|
55
|
+
video_type: int = Field(default=0, exclude=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VideoNoteAttachPayload(VideoAttachPayload):
|
|
59
|
+
video_id: int = Field(exclude=True)
|
|
60
|
+
video_type: int = 1
|
|
61
|
+
|
|
21
62
|
|
|
22
63
|
class AttachFilePayload(CamelModel):
|
|
23
64
|
type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type")
|
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
|
|
|
@@ -15,7 +17,7 @@ from pymax.exceptions import UploadError
|
|
|
15
17
|
from pymax.files import File, Photo, Video, VideoNote, Voice
|
|
16
18
|
from pymax.logging import get_logger
|
|
17
19
|
from pymax.protocol import Opcode
|
|
18
|
-
from pymax.types import
|
|
20
|
+
from pymax.types import AudioUploadSignal
|
|
19
21
|
|
|
20
22
|
from .models import (
|
|
21
23
|
FileUploadResponse,
|
|
@@ -25,8 +27,11 @@ from .models import (
|
|
|
25
27
|
from .payloads import (
|
|
26
28
|
AttachFilePayload,
|
|
27
29
|
AttachPhotoPayload,
|
|
30
|
+
StoryVideoAttachPayload,
|
|
28
31
|
UploadPayload,
|
|
29
32
|
VideoAttachPayload,
|
|
33
|
+
VideoNoteAttachPayload,
|
|
34
|
+
VoiceAttachPayload,
|
|
30
35
|
)
|
|
31
36
|
|
|
32
37
|
if TYPE_CHECKING:
|
|
@@ -47,11 +52,22 @@ class UploadService:
|
|
|
47
52
|
self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
|
|
48
53
|
self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
|
|
49
54
|
|
|
50
|
-
|
|
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:
|
|
51
67
|
logger.info("Uploading photo")
|
|
52
68
|
logger.debug("Preparing photo upload payload")
|
|
53
69
|
|
|
54
|
-
payload = UploadPayload(profile=profile).to_payload()
|
|
70
|
+
payload = UploadPayload(profile=profile, type=upload_type).to_payload()
|
|
55
71
|
|
|
56
72
|
try:
|
|
57
73
|
data = await self.app.invoke(
|
|
@@ -80,11 +96,9 @@ class UploadService:
|
|
|
80
96
|
|
|
81
97
|
try:
|
|
82
98
|
parsed_url = urlparse(url)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
logger.debug("Invalid photo upload URL=%s", url)
|
|
87
|
-
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
|
|
88
102
|
except Exception as e:
|
|
89
103
|
logger.exception("Failed to parse photo id from upload URL")
|
|
90
104
|
logger.debug("Invalid photo upload URL=%s", url)
|
|
@@ -130,6 +144,7 @@ class UploadService:
|
|
|
130
144
|
session.post(
|
|
131
145
|
url=url,
|
|
132
146
|
data=form,
|
|
147
|
+
ssl=self.ssl_ctx,
|
|
133
148
|
) as response,
|
|
134
149
|
):
|
|
135
150
|
logger.debug("Photo upload HTTP response status=%s", response.status)
|
|
@@ -163,6 +178,12 @@ class UploadService:
|
|
|
163
178
|
logger.debug("Invalid photo upload response=%r", result)
|
|
164
179
|
raise UploadError("Invalid photo upload response model") from e
|
|
165
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
|
+
|
|
166
187
|
try:
|
|
167
188
|
token = model.photos[photo_id].token
|
|
168
189
|
except KeyError as e:
|
|
@@ -182,7 +203,7 @@ class UploadService:
|
|
|
182
203
|
logger.debug("Photo upload complete photo_id=%s", photo_id)
|
|
183
204
|
return AttachPhotoPayload(photo_token=token)
|
|
184
205
|
|
|
185
|
-
async def upload_voice(self, voice: Voice) ->
|
|
206
|
+
async def upload_voice(self, voice: Voice) -> VoiceAttachPayload:
|
|
186
207
|
logger.info("Uploading voice")
|
|
187
208
|
|
|
188
209
|
payload = UploadPayload(
|
|
@@ -227,12 +248,23 @@ class UploadService:
|
|
|
227
248
|
logger.exception("Failed to get voice size")
|
|
228
249
|
raise UploadError("Failed to get voice size") from e
|
|
229
250
|
|
|
251
|
+
# ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
252
|
+
# OKMessages/{app_version} ({os_version}; {device_name}; {screen})
|
|
253
|
+
|
|
254
|
+
user_agent = (
|
|
255
|
+
f"OKMessages/{self.app.config.app_version}"
|
|
256
|
+
+ f" ({self.app.config.device.user_agent.os_version};"
|
|
257
|
+
+ f" {self.app.config.device.user_agent.device_name};"
|
|
258
|
+
+ f" {self.app.config.device.user_agent.screen})"
|
|
259
|
+
)
|
|
260
|
+
|
|
230
261
|
headers = {
|
|
231
262
|
"Content-Disposition": f"attachment; filename={quote(voice.name)}",
|
|
232
|
-
"Content-Range": f"0-{file_size - 1}/{file_size}",
|
|
263
|
+
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
233
264
|
"Content-Length": str(file_size),
|
|
234
265
|
"Connection": "keep-alive",
|
|
235
266
|
"Content-Type": "application/octet-stream",
|
|
267
|
+
"User-Agent": quote(user_agent),
|
|
236
268
|
}
|
|
237
269
|
|
|
238
270
|
logger.debug(
|
|
@@ -240,15 +272,11 @@ class UploadService:
|
|
|
240
272
|
headers["Content-Range"],
|
|
241
273
|
)
|
|
242
274
|
|
|
243
|
-
|
|
244
|
-
future: asyncio.Future[AudioUploadSignal] = loop.create_future()
|
|
245
|
-
|
|
246
|
-
timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
|
|
275
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
247
276
|
|
|
248
277
|
video_id = upload_info.video_id
|
|
249
278
|
token = upload_info.token
|
|
250
279
|
|
|
251
|
-
self.voice_upload_waiters[video_id] = future
|
|
252
280
|
logger.debug("Voice upload waiter registered voice_id=%s", video_id)
|
|
253
281
|
|
|
254
282
|
try:
|
|
@@ -261,6 +289,7 @@ class UploadService:
|
|
|
261
289
|
url=upload_info.url,
|
|
262
290
|
headers=headers,
|
|
263
291
|
data=voice.iter_chunks(1024 * 1024),
|
|
292
|
+
ssl=self.ssl_ctx,
|
|
264
293
|
) as response:
|
|
265
294
|
logger.debug(
|
|
266
295
|
"Voice upload HTTP response status=%s voice_id=%s",
|
|
@@ -279,24 +308,29 @@ class UploadService:
|
|
|
279
308
|
f"{response.status} voice_id={video_id}"
|
|
280
309
|
)
|
|
281
310
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
311
|
+
# print(await response.read()) # TODO: Error handing (yes, with 200 status 💔)
|
|
312
|
+
|
|
313
|
+
# try:
|
|
314
|
+
# logger.debug(
|
|
315
|
+
# "Waiting for voice processing notification voice_id=%s",
|
|
316
|
+
# video_id,
|
|
317
|
+
# )
|
|
318
|
+
# await asyncio.wait_for(future, 60)
|
|
319
|
+
# except asyncio.TimeoutError:
|
|
320
|
+
# logger.warning(
|
|
321
|
+
# "Timed out waiting for voice processing notification voice_id=%s",
|
|
322
|
+
# video_id,
|
|
323
|
+
# )
|
|
324
|
+
# raise UploadError(
|
|
325
|
+
# f"Timed out waiting for voice processing voice_id={video_id}"
|
|
326
|
+
# )
|
|
296
327
|
|
|
297
328
|
logger.debug("Voice upload complete voice_id=%s", video_id)
|
|
298
|
-
return
|
|
299
|
-
|
|
329
|
+
return VoiceAttachPayload(
|
|
330
|
+
video_id=video_id,
|
|
331
|
+
token=token,
|
|
332
|
+
duration=await voice.get_duration(),
|
|
333
|
+
wave=b"\x00" * 80,
|
|
300
334
|
)
|
|
301
335
|
|
|
302
336
|
except UploadError:
|
|
@@ -314,7 +348,9 @@ class UploadService:
|
|
|
314
348
|
self.voice_upload_waiters.pop(video_id, None)
|
|
315
349
|
logger.debug("Voice upload waiter removed voice_id=%s", video_id)
|
|
316
350
|
|
|
317
|
-
async def upload_video(
|
|
351
|
+
async def upload_video(
|
|
352
|
+
self, uploadable_video: Video | VideoNote
|
|
353
|
+
) -> VideoAttachPayload | VideoNoteAttachPayload:
|
|
318
354
|
logger.info("Uploading video")
|
|
319
355
|
logger.debug("Preparing video upload payload")
|
|
320
356
|
|
|
@@ -366,10 +402,10 @@ class UploadService:
|
|
|
366
402
|
file_size,
|
|
367
403
|
)
|
|
368
404
|
|
|
369
|
-
timeout = aiohttp.ClientTimeout(total=
|
|
405
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
370
406
|
|
|
371
407
|
headers = {
|
|
372
|
-
"Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}",
|
|
408
|
+
"Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}",
|
|
373
409
|
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
374
410
|
"Content-Length": str(file_size),
|
|
375
411
|
"Connection": "keep-alive",
|
|
@@ -400,6 +436,7 @@ class UploadService:
|
|
|
400
436
|
url=upload_info.url,
|
|
401
437
|
headers=headers,
|
|
402
438
|
data=uploadable_video.iter_chunks(1024 * 1024),
|
|
439
|
+
ssl=self.ssl_ctx,
|
|
403
440
|
) as response:
|
|
404
441
|
logger.debug(
|
|
405
442
|
"Video upload HTTP response status=%s video_id=%s",
|
|
@@ -427,10 +464,9 @@ class UploadService:
|
|
|
427
464
|
thumbhash += "=" * (-len(thumbhash) % 4)
|
|
428
465
|
thumbhash = base64.b64decode(thumbhash)
|
|
429
466
|
|
|
430
|
-
return
|
|
467
|
+
return VideoNoteAttachPayload(
|
|
431
468
|
video_id=video_id,
|
|
432
469
|
token=token,
|
|
433
|
-
video_type=1,
|
|
434
470
|
thumbhash=thumbhash,
|
|
435
471
|
duration=await uploadable_video.get_duration(),
|
|
436
472
|
)
|
|
@@ -474,6 +510,71 @@ class UploadService:
|
|
|
474
510
|
self.video_upload_waiters.pop(video_id, None)
|
|
475
511
|
logger.debug("Video upload waiter removed video_id=%s", video_id)
|
|
476
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
|
+
|
|
477
578
|
async def upload_file(self, file: File) -> AttachFilePayload:
|
|
478
579
|
logger.info("Uploading file")
|
|
479
580
|
|
|
@@ -532,51 +633,52 @@ class UploadService:
|
|
|
532
633
|
|
|
533
634
|
file_id = upload_info.file_id
|
|
534
635
|
|
|
636
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
637
|
+
|
|
535
638
|
self.file_upload_waiters[file_id] = future
|
|
536
639
|
logger.debug("File upload waiter registered file_id=%s", file_id)
|
|
537
640
|
|
|
538
641
|
try:
|
|
539
|
-
async with
|
|
540
|
-
proxy=self.app.config.proxy,
|
|
541
|
-
|
|
542
|
-
async with session.post(
|
|
642
|
+
async with (
|
|
643
|
+
aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session,
|
|
644
|
+
session.post(
|
|
543
645
|
url=upload_info.url,
|
|
544
646
|
headers=headers,
|
|
545
647
|
data=file.iter_chunks(1024 * 1024),
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
648
|
+
ssl=self.ssl_ctx,
|
|
649
|
+
) as response,
|
|
650
|
+
):
|
|
651
|
+
logger.debug(
|
|
652
|
+
"File upload HTTP response status=%s file_id=%s",
|
|
653
|
+
response.status,
|
|
654
|
+
file_id,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if response.status != HTTPStatus.OK:
|
|
658
|
+
logger.error(
|
|
659
|
+
"File upload failed with status %s file_id=%s",
|
|
549
660
|
response.status,
|
|
550
661
|
file_id,
|
|
551
662
|
)
|
|
663
|
+
raise UploadError(
|
|
664
|
+
f"File upload failed with status {response.status} file_id={file_id}"
|
|
665
|
+
)
|
|
552
666
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
)
|
|
667
|
+
try:
|
|
668
|
+
logger.debug(
|
|
669
|
+
"Waiting for file processing notification file_id=%s",
|
|
670
|
+
file_id,
|
|
671
|
+
)
|
|
672
|
+
await asyncio.wait_for(future, 60)
|
|
673
|
+
except asyncio.TimeoutError:
|
|
674
|
+
logger.warning(
|
|
675
|
+
"Timed out waiting for file processing notification file_id=%s",
|
|
676
|
+
file_id,
|
|
677
|
+
)
|
|
678
|
+
raise UploadError(f"Timed out waiting for file processing file_id={file_id}")
|
|
577
679
|
|
|
578
|
-
|
|
579
|
-
|
|
680
|
+
logger.debug("File upload complete file_id=%s", file_id)
|
|
681
|
+
return AttachFilePayload(file_id=file_id)
|
|
580
682
|
|
|
581
683
|
except UploadError:
|
|
582
684
|
raise
|