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.
Files changed (101) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
  2. maxapi_python-2.5.0.dist-info/RECORD +195 -0
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +8 -2
  5. pymax/_data/apk_fingerprints.json +374 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +15 -2
  8. pymax/api/auth/types.py +3 -0
  9. pymax/api/bots/payloads.py +8 -0
  10. pymax/api/bots/service.py +13 -1
  11. pymax/api/chats/enums.py +1 -0
  12. pymax/api/chats/payloads.py +21 -1
  13. pymax/api/chats/service.py +41 -0
  14. pymax/api/facade.py +2 -0
  15. pymax/api/messages/__init__.py +1 -1
  16. pymax/api/messages/enums.py +1 -0
  17. pymax/api/messages/payloads.py +56 -4
  18. pymax/api/messages/service.py +405 -27
  19. pymax/api/response.py +1 -1
  20. pymax/api/stories/__init__.py +2 -0
  21. pymax/api/stories/enums.py +19 -0
  22. pymax/api/stories/payloads.py +64 -0
  23. pymax/api/stories/service.py +198 -0
  24. pymax/api/uploads/payloads.py +42 -1
  25. pymax/api/uploads/service.py +171 -69
  26. pymax/app.py +39 -8
  27. pymax/auth/__init__.py +2 -0
  28. pymax/auth/exceptions.py +9 -0
  29. pymax/auth/providers.py +6 -2
  30. pymax/auth/qr.py +11 -1
  31. pymax/auth/sms.py +11 -1
  32. pymax/base.py +146 -27
  33. pymax/client.py +39 -11
  34. pymax/client_web.py +15 -12
  35. pymax/config.py +101 -53
  36. pymax/di/__init__.py +2 -0
  37. pymax/di/context.py +18 -0
  38. pymax/di/resolver.py +38 -0
  39. pymax/dispatch/__init__.py +7 -0
  40. pymax/dispatch/dispatcher.py +143 -40
  41. pymax/dispatch/enums.py +1 -0
  42. pymax/dispatch/mapping.py +23 -3
  43. pymax/dispatch/middleware.py +32 -0
  44. pymax/dispatch/resolvers.py +4 -0
  45. pymax/dispatch/router.py +86 -34
  46. pymax/files/base.py +58 -1
  47. pymax/files/video.py +32 -40
  48. pymax/files/voice.py +18 -4
  49. pymax/filters/__init__.py +15 -0
  50. pymax/filters/base.py +218 -0
  51. pymax/filters/containers/__init__.py +2 -0
  52. pymax/filters/containers/chat.py +240 -0
  53. pymax/filters/containers/message.py +147 -0
  54. pymax/filters/filters.py +211 -0
  55. pymax/filters/magic.py +13 -0
  56. pymax/filters/presets.py +88 -0
  57. pymax/filters/refs.py +56 -0
  58. pymax/fingerprint/fingerprint.py +5 -22
  59. pymax/fingerprint/models.py +1 -0
  60. pymax/formatting/markdown.py +55 -1
  61. pymax/fsm/__init__.py +3 -0
  62. pymax/fsm/context.py +30 -0
  63. pymax/fsm/protocol.py +14 -0
  64. pymax/fsm/state.py +70 -0
  65. pymax/fsm/storage.py +151 -0
  66. pymax/infra/base.py +2 -0
  67. pymax/infra/bots.py +20 -0
  68. pymax/infra/chat.py +37 -5
  69. pymax/infra/message.py +204 -9
  70. pymax/infra/protocol.py +5 -1
  71. pymax/infra/stories.py +205 -0
  72. pymax/integrations/__init__.py +0 -0
  73. pymax/integrations/dishka.py +109 -0
  74. pymax/protocol/enums.py +2 -0
  75. pymax/protocol/tcp/payload.py +0 -2
  76. pymax/session/__init__.py +1 -1
  77. pymax/session/models.py +30 -0
  78. pymax/session/protocol.py +33 -7
  79. pymax/session/store.py +73 -5
  80. pymax/transport/tcp.py +11 -3
  81. pymax/types/domain/__init__.py +23 -1
  82. pymax/types/domain/attachments/__init__.py +1 -1
  83. pymax/types/domain/attachments/call.py +7 -1
  84. pymax/types/domain/attachments/contact.py +3 -3
  85. pymax/types/domain/attachments/control.py +3 -0
  86. pymax/types/domain/attachments/enums.py +16 -0
  87. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  88. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  89. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  90. pymax/types/domain/attachments/video.py +3 -3
  91. pymax/types/domain/callback.py +19 -0
  92. pymax/types/domain/comment.py +24 -0
  93. pymax/types/domain/enums.py +7 -0
  94. pymax/types/domain/message.py +158 -9
  95. pymax/types/domain/story.py +126 -0
  96. pymax/types/events/reaction.py +28 -4
  97. pymax/versions/__init__.py +2 -0
  98. pymax/versions/catalog.py +104 -0
  99. pymax/versions/exceptions.py +10 -0
  100. maxapi_python-2.4.0.dist-info/RECORD +0 -161
  101. {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))
@@ -1,4 +1,6 @@
1
- from pydantic import Field
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")
@@ -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 AttachmentType, AudioUploadSignal
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
- 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:
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
- photo_id = str(parse_qs(parsed_url.query)["photoIds"][0])
84
- except (KeyError, IndexError) as e:
85
- logger.exception("Photo upload URL does not contain photoIds")
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) -> VideoAttachPayload:
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
- loop = asyncio.get_running_loop()
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
- 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
- )
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 VideoAttachPayload(
299
- type=AttachmentType.AUDIO, video_id=video_id, token=token
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(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload:
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=900, sock_read=60)
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 VideoAttachPayload(
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 aiohttp.ClientSession(
540
- proxy=self.app.config.proxy,
541
- ) as session:
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
- ) as response:
547
- logger.debug(
548
- "File upload HTTP response status=%s file_id=%s",
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
- 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
- )
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
- logger.debug("File upload complete file_id=%s", file_id)
579
- return AttachFilePayload(file_id=file_id)
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