maxapi-python 1.2.5__py3-none-any.whl → 2.0.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 (169) hide show
  1. maxapi_python-2.0.0.dist-info/METADATA +217 -0
  2. maxapi_python-2.0.0.dist-info/RECORD +140 -0
  3. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +50 -105
  5. pymax/api/__init__.py +17 -0
  6. pymax/api/auth/__init__.py +1 -0
  7. pymax/api/auth/enums.py +17 -0
  8. pymax/api/auth/payloads.py +129 -0
  9. pymax/api/auth/service.py +313 -0
  10. pymax/api/auth/types.py +13 -0
  11. pymax/api/chats/__init__.py +8 -0
  12. pymax/api/chats/enums.py +27 -0
  13. pymax/api/chats/payloads.py +103 -0
  14. pymax/api/chats/service.py +277 -0
  15. pymax/api/facade.py +32 -0
  16. pymax/api/messages/__init__.py +1 -0
  17. pymax/api/messages/enums.py +17 -0
  18. pymax/api/messages/payloads.py +92 -0
  19. pymax/api/messages/service.py +337 -0
  20. pymax/api/models.py +13 -0
  21. pymax/api/response.py +123 -0
  22. pymax/api/self/__init__.py +2 -0
  23. pymax/api/self/enums.py +11 -0
  24. pymax/api/self/payloads.py +41 -0
  25. pymax/api/self/service.py +142 -0
  26. pymax/api/session/__init__.py +1 -0
  27. pymax/api/session/enums.py +10 -0
  28. pymax/api/session/payloads.py +76 -0
  29. pymax/api/session/service.py +72 -0
  30. pymax/api/uploads/__init__.py +1 -0
  31. pymax/api/uploads/models.py +49 -0
  32. pymax/api/uploads/payloads.py +25 -0
  33. pymax/api/uploads/service.py +458 -0
  34. pymax/api/users/__init__.py +2 -0
  35. pymax/api/users/enums.py +12 -0
  36. pymax/api/users/payloads.py +16 -0
  37. pymax/api/users/service.py +124 -0
  38. pymax/app.py +273 -0
  39. pymax/auth/__init__.py +25 -0
  40. pymax/auth/base.py +37 -0
  41. pymax/auth/email.py +0 -0
  42. pymax/auth/models.py +5 -0
  43. pymax/auth/providers.py +127 -0
  44. pymax/auth/qr.py +135 -0
  45. pymax/auth/service.py +25 -0
  46. pymax/auth/sms.py +122 -0
  47. pymax/base.py +204 -0
  48. pymax/client.py +106 -0
  49. pymax/client_web.py +83 -0
  50. pymax/config.py +215 -0
  51. pymax/connection/__init__.py +1 -0
  52. pymax/connection/connection.py +205 -0
  53. pymax/connection/pending.py +46 -0
  54. pymax/connection/readers/__init__.py +2 -0
  55. pymax/connection/readers/base.py +6 -0
  56. pymax/connection/readers/tcp.py +29 -0
  57. pymax/connection/readers/ws.py +14 -0
  58. pymax/dispatch/__init__.py +10 -0
  59. pymax/dispatch/dispatcher.py +222 -0
  60. pymax/dispatch/enums.py +12 -0
  61. pymax/dispatch/mapping.py +73 -0
  62. pymax/dispatch/resolvers.py +52 -0
  63. pymax/dispatch/router.py +216 -0
  64. pymax/exceptions.py +22 -89
  65. pymax/files/__init__.py +9 -0
  66. pymax/files/base.py +82 -0
  67. pymax/files/file.py +76 -0
  68. pymax/files/photo.py +108 -0
  69. pymax/files/static.py +10 -0
  70. pymax/files/video.py +74 -0
  71. pymax/formatting/__init__.py +0 -0
  72. pymax/formatting/markdown.py +217 -0
  73. pymax/infra/__init__.py +1 -0
  74. pymax/infra/auth.py +55 -0
  75. pymax/infra/base.py +15 -0
  76. pymax/infra/chat.py +240 -0
  77. pymax/infra/message.py +252 -0
  78. pymax/infra/protocol.py +9 -0
  79. pymax/infra/self.py +139 -0
  80. pymax/infra/user.py +107 -0
  81. pymax/logging.py +129 -0
  82. pymax/protocol/__init__.py +11 -0
  83. pymax/protocol/base.py +13 -0
  84. pymax/protocol/enums.py +180 -0
  85. pymax/protocol/models.py +33 -0
  86. pymax/protocol/tcp/__init__.py +1 -0
  87. pymax/protocol/tcp/compression.py +97 -0
  88. pymax/protocol/tcp/framing.py +68 -0
  89. pymax/protocol/tcp/payload.py +127 -0
  90. pymax/protocol/tcp/protocol.py +68 -0
  91. pymax/protocol/ws/__init__.py +1 -0
  92. pymax/protocol/ws/protocol.py +27 -0
  93. pymax/py.typed +0 -0
  94. pymax/routers.py +8 -0
  95. pymax/session/__init__.py +3 -0
  96. pymax/session/models.py +11 -0
  97. pymax/session/protocol.py +14 -0
  98. pymax/session/store.py +232 -0
  99. pymax/telemetry/__init__.py +3 -0
  100. pymax/telemetry/navigation.py +181 -0
  101. pymax/telemetry/payloads.py +142 -0
  102. pymax/telemetry/service.py +225 -0
  103. pymax/transport/__init__.py +0 -0
  104. pymax/transport/base.py +14 -0
  105. pymax/transport/tcp.py +93 -0
  106. pymax/transport/websocket.py +50 -0
  107. pymax/types/__init__.py +2 -0
  108. pymax/types/domain/__init__.py +11 -0
  109. pymax/types/domain/attachments/__init__.py +11 -0
  110. pymax/types/domain/attachments/audio.py +35 -0
  111. pymax/types/domain/attachments/call.py +26 -0
  112. pymax/types/domain/attachments/contact.py +32 -0
  113. pymax/types/domain/attachments/control.py +20 -0
  114. pymax/types/domain/attachments/enums.py +27 -0
  115. pymax/types/domain/attachments/file.py +56 -0
  116. pymax/types/domain/attachments/keyboards/__init__.py +1 -0
  117. pymax/types/domain/attachments/keyboards/inline.py +19 -0
  118. pymax/types/domain/attachments/photo.py +45 -0
  119. pymax/types/domain/attachments/share.py +29 -0
  120. pymax/types/domain/attachments/sticker.py +50 -0
  121. pymax/types/domain/attachments/video.py +90 -0
  122. pymax/types/domain/auth.py +161 -0
  123. pymax/types/domain/base.py +17 -0
  124. pymax/types/domain/chat.py +426 -0
  125. pymax/types/domain/element.py +24 -0
  126. pymax/types/domain/enums.py +24 -0
  127. pymax/types/domain/error.py +20 -0
  128. pymax/types/domain/folder.py +74 -0
  129. pymax/types/domain/login.py +35 -0
  130. pymax/types/domain/message.py +378 -0
  131. pymax/types/domain/name.py +20 -0
  132. pymax/types/domain/profile.py +15 -0
  133. pymax/types/domain/session.py +52 -0
  134. pymax/types/domain/sync.py +80 -0
  135. pymax/types/domain/user.py +117 -0
  136. pymax/types/events/__init__.py +3 -0
  137. pymax/types/events/file.py +5 -0
  138. pymax/types/events/message.py +37 -0
  139. pymax/types/events/video.py +5 -0
  140. maxapi_python-1.2.5.dist-info/METADATA +0 -202
  141. maxapi_python-1.2.5.dist-info/RECORD +0 -33
  142. pymax/core.py +0 -398
  143. pymax/crud.py +0 -96
  144. pymax/files.py +0 -138
  145. pymax/filters.py +0 -164
  146. pymax/formatter.py +0 -31
  147. pymax/formatting.py +0 -74
  148. pymax/interfaces.py +0 -558
  149. pymax/mixins/__init__.py +0 -40
  150. pymax/mixins/auth.py +0 -594
  151. pymax/mixins/channel.py +0 -130
  152. pymax/mixins/group.py +0 -458
  153. pymax/mixins/handler.py +0 -285
  154. pymax/mixins/message.py +0 -879
  155. pymax/mixins/scheduler.py +0 -28
  156. pymax/mixins/self.py +0 -259
  157. pymax/mixins/socket.py +0 -306
  158. pymax/mixins/telemetry.py +0 -118
  159. pymax/mixins/user.py +0 -219
  160. pymax/mixins/websocket.py +0 -151
  161. pymax/models.py +0 -8
  162. pymax/navigation.py +0 -187
  163. pymax/payloads.py +0 -403
  164. pymax/protocols.py +0 -123
  165. pymax/static/constant.py +0 -96
  166. pymax/static/enum.py +0 -231
  167. pymax/types.py +0 -1220
  168. pymax/utils.py +0 -90
  169. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,76 @@
1
+ from random import randint
2
+
3
+ from pydantic import Field
4
+
5
+ from pymax.api.models import CamelModel
6
+
7
+ from .enums import DeviceType
8
+
9
+ DEFAULT_WEB_HEADER_USER_AGENT = (
10
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
11
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
12
+ )
13
+ WEB_USER_AGENT_ALIASES = (
14
+ "deviceType",
15
+ "locale",
16
+ "deviceLocale",
17
+ "osVersion",
18
+ "deviceName",
19
+ "headerUserAgent",
20
+ "appVersion",
21
+ "screen",
22
+ "timezone",
23
+ )
24
+
25
+
26
+ class MobileUserAgentPayload(CamelModel):
27
+ """User-agent payload, который PyMax отправляет в Max.
28
+
29
+ Обычно его создает ``ExtraConfig.generate_user_agent()`` или
30
+ ``ExtraConfig.generate_web_user_agent()``. Передавайте собственный объект
31
+ в ``ExtraConfig(user_agent=...)`` только если нужно явно управлять
32
+ device/app параметрами.
33
+ """
34
+
35
+ device_type: DeviceType
36
+ app_version: str
37
+ os_version: str
38
+ timezone: str
39
+ screen: str
40
+ push_device_type: str | None = None
41
+ arch: str | None = None
42
+ locale: str
43
+ build_number: int | None = None
44
+ device_name: str
45
+ device_locale: str
46
+ release: int | None = None
47
+ header_user_agent: str | None = None
48
+
49
+ def to_web_payload(self) -> dict:
50
+ """Возвращает payload в формате, который ожидает web-login."""
51
+ payload = self.model_dump(
52
+ by_alias=True,
53
+ exclude_none=True,
54
+ )
55
+ if self.device_type == DeviceType.WEB and "headerUserAgent" not in payload:
56
+ payload["headerUserAgent"] = DEFAULT_WEB_HEADER_USER_AGENT
57
+
58
+ return {alias: payload[alias] for alias in WEB_USER_AGENT_ALIASES if alias in payload}
59
+
60
+
61
+ class MobileHandshakePayload(CamelModel):
62
+ mt_instance_id: str = Field(..., alias="mt_instanceid")
63
+ user_agent: MobileUserAgentPayload
64
+ client_session_id: int = Field(default_factory=lambda: randint(1, 70))
65
+ device_id: str
66
+
67
+
68
+ class WebHandshakePayload(CamelModel):
69
+ user_agent: MobileUserAgentPayload
70
+ device_id: str
71
+
72
+ def to_payload(self) -> dict:
73
+ return {
74
+ "userAgent": self.user_agent.to_web_payload(),
75
+ "deviceId": self.device_id,
76
+ }
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pymax.logging import get_logger
6
+ from pymax.protocol import Opcode
7
+
8
+ from .enums import DeviceType
9
+ from .payloads import (
10
+ MobileHandshakePayload,
11
+ MobileUserAgentPayload,
12
+ WebHandshakePayload,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from pymax.app import App
17
+
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class SessionService:
23
+ def __init__(self, app: App) -> None:
24
+ self.app = app
25
+
26
+ async def handshake(
27
+ self,
28
+ mt_instance_id: str,
29
+ user_agent: MobileUserAgentPayload,
30
+ device_id: str,
31
+ ) -> None:
32
+ if user_agent.device_type == DeviceType.WEB:
33
+ await self.web_handshake(user_agent, device_id)
34
+ return
35
+
36
+ await self.mobile_handshake(mt_instance_id, user_agent, device_id)
37
+
38
+ async def mobile_handshake(
39
+ self,
40
+ mt_instance_id: str,
41
+ user_agent: MobileUserAgentPayload,
42
+ device_id: str,
43
+ ) -> None:
44
+ logger.debug(
45
+ "mobile handshake mt_instance_id_set=%s device_id=%s app_version=%s",
46
+ bool(mt_instance_id),
47
+ device_id,
48
+ user_agent.app_version,
49
+ )
50
+ frame = MobileHandshakePayload(
51
+ mt_instanceid=mt_instance_id,
52
+ user_agent=user_agent,
53
+ device_id=device_id,
54
+ )
55
+ await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
56
+ logger.info("mobile handshake completed")
57
+
58
+ async def web_handshake(
59
+ self, user_agent: MobileUserAgentPayload, device_id: str
60
+ ) -> None:
61
+ logger.debug(
62
+ "web handshake device_id=%s app_version=%s browser=%s",
63
+ device_id,
64
+ user_agent.app_version,
65
+ user_agent.device_name,
66
+ )
67
+ frame = WebHandshakePayload(
68
+ user_agent=user_agent,
69
+ device_id=device_id,
70
+ )
71
+ await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
72
+ logger.info("web handshake completed")
@@ -0,0 +1 @@
1
+ from .service import UploadService
@@ -0,0 +1,49 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from pymax.api.models import CamelModel
6
+ from pymax.types import AttachmentType
7
+
8
+
9
+ class PhotoPayloadResponse(BaseModel):
10
+ token: str
11
+
12
+
13
+ class PhotoUploadResponse(BaseModel):
14
+ photos: dict[str, PhotoPayloadResponse]
15
+
16
+
17
+ class VideoPayloadResponse(CamelModel):
18
+ url: str
19
+ video_id: int
20
+ token: str
21
+
22
+
23
+ class VideoUploadResponse(BaseModel):
24
+ info: list[VideoPayloadResponse]
25
+
26
+
27
+ class FilePayloadResponse(CamelModel):
28
+ url: str
29
+ file_id: int
30
+ token: str
31
+
32
+
33
+ class FileUploadResponse(BaseModel):
34
+ info: list[FilePayloadResponse]
35
+
36
+
37
+ # class PhotoUploadResult(CamelModel):
38
+ # type: Literal[AttachmentType.PHOTO] = Field(
39
+ # serialization_alias="_type", default=AttachmentType.PHOTO
40
+ # )
41
+ # photo_token: str
42
+
43
+
44
+ # class VideoUploadResult(CamelModel):
45
+ # type: Literal[AttachmentType.VIDEO] = Field(
46
+ # serialization_alias="_type", default=AttachmentType.VIDEO
47
+ # )
48
+ # video_id: int
49
+ # token: str
@@ -0,0 +1,25 @@
1
+ from pydantic import Field
2
+
3
+ from pymax.api.models import CamelModel
4
+ from pymax.types import AttachmentType
5
+
6
+
7
+ class AttachPhotoPayload(CamelModel):
8
+ type: AttachmentType = Field(default=AttachmentType.PHOTO, serialization_alias="_type")
9
+ photo_token: str
10
+
11
+
12
+ class VideoAttachPayload(CamelModel):
13
+ type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
14
+ video_id: int
15
+ token: str
16
+
17
+
18
+ class AttachFilePayload(CamelModel):
19
+ type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type")
20
+ file_id: int
21
+
22
+
23
+ class UploadPayload(CamelModel):
24
+ count: int = 1
25
+ profile: bool = False
@@ -0,0 +1,458 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from http import HTTPStatus
5
+ from typing import TYPE_CHECKING
6
+ from urllib.parse import parse_qs, quote, urlparse
7
+
8
+ import aiohttp
9
+ from pydantic import ValidationError
10
+
11
+ from pymax.api.response import payload_item
12
+ from pymax.dispatch.enums import EventType
13
+ from pymax.exceptions import UploadError
14
+ from pymax.files import File, Photo, Video
15
+ from pymax.logging import get_logger
16
+ from pymax.protocol import Opcode
17
+
18
+ from .models import (
19
+ FileUploadResponse,
20
+ PhotoUploadResponse,
21
+ VideoUploadResponse,
22
+ )
23
+ from .payloads import (
24
+ AttachFilePayload,
25
+ AttachPhotoPayload,
26
+ UploadPayload,
27
+ VideoAttachPayload,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from pymax import Client
32
+ from pymax.app import App
33
+ from pymax.types.events import FileUploadSignal, VideoUploadSignal
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class UploadService:
39
+ def __init__(self, app: App) -> None:
40
+ self.app = app
41
+ self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
42
+ self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
43
+ self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
44
+ self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
45
+
46
+ async def upload_photo(self, photo: Photo) -> AttachPhotoPayload:
47
+ logger.info("Uploading photo")
48
+ logger.debug("Preparing photo upload payload")
49
+
50
+ payload = UploadPayload().model_dump()
51
+
52
+ try:
53
+ data = await self.app.invoke(
54
+ Opcode.PHOTO_UPLOAD,
55
+ payload=payload,
56
+ )
57
+ except Exception as e:
58
+ logger.exception("Failed to request photo upload URL")
59
+ raise UploadError("Failed to request photo upload URL") from e
60
+
61
+ try:
62
+ url = payload_item(data, "url", str) # TODO: ENUM!!!!
63
+ except Exception as e:
64
+ logger.exception("Failed to parse photo upload URL from response")
65
+ raise UploadError("Failed to parse photo upload URL from response") from e
66
+
67
+ if not url:
68
+ logger.error("No upload URL received")
69
+ logger.debug(
70
+ "Photo upload URL response payload=%r",
71
+ getattr(data, "payload", None),
72
+ )
73
+ raise UploadError("No upload URL received")
74
+
75
+ logger.debug("Photo upload URL received")
76
+
77
+ try:
78
+ parsed_url = urlparse(url)
79
+ photo_id = str(parse_qs(parsed_url.query)["photoIds"][0])
80
+ except (KeyError, IndexError) as e:
81
+ logger.exception("Photo upload URL does not contain photoIds")
82
+ logger.debug("Invalid photo upload URL=%s", url)
83
+ raise UploadError("Photo upload URL does not contain photoIds") from e
84
+ except Exception as e:
85
+ logger.exception("Failed to parse photo id from upload URL")
86
+ logger.debug("Invalid photo upload URL=%s", url)
87
+ raise UploadError("Failed to parse photo id from upload URL") from e
88
+
89
+ logger.debug("Photo upload id parsed photo_id=%s", photo_id)
90
+
91
+ try:
92
+ photo_data = photo.validate_photo()
93
+ except Exception as e:
94
+ logger.exception("Photo validation crashed")
95
+ raise UploadError("Photo validation crashed") from e
96
+
97
+ if not photo_data:
98
+ logger.error("Photo validation failed")
99
+ raise UploadError("Photo validation failed")
100
+
101
+ logger.debug(
102
+ "Photo validated extension=%s content_type=%s",
103
+ photo_data[0],
104
+ photo_data[1],
105
+ )
106
+
107
+ try:
108
+ photo_bytes = await photo.read()
109
+ except Exception as e:
110
+ logger.exception("Failed to read photo bytes")
111
+ raise UploadError("Failed to read photo bytes") from e
112
+
113
+ logger.debug("Photo read complete size=%s", len(photo_bytes))
114
+
115
+ form = aiohttp.FormData()
116
+ form.add_field(
117
+ name="file",
118
+ value=photo_bytes,
119
+ filename=f"image.{quote(photo_data[0])}",
120
+ content_type=photo_data[1],
121
+ )
122
+
123
+ try:
124
+ async with (
125
+ aiohttp.ClientSession() as session,
126
+ session.post(
127
+ url=url,
128
+ data=form,
129
+ ) as response,
130
+ ):
131
+ logger.debug("Photo upload HTTP response status=%s", response.status)
132
+
133
+ if response.status != HTTPStatus.OK:
134
+ logger.error("Photo upload failed with status %s", response.status)
135
+ raise UploadError(f"Photo upload failed with status {response.status}")
136
+
137
+ try:
138
+ result = await response.json()
139
+ except Exception as e:
140
+ logger.exception("Failed to decode photo upload response JSON")
141
+ raise UploadError("Failed to decode photo upload response JSON") from e
142
+
143
+ except UploadError:
144
+ raise
145
+ except aiohttp.ClientError as e:
146
+ logger.exception("HTTP error during photo upload")
147
+ raise UploadError("HTTP error during photo upload") from e
148
+ except asyncio.TimeoutError as e:
149
+ logger.exception("Timed out during photo upload")
150
+ raise UploadError("Timed out during photo upload") from e
151
+ except Exception as e:
152
+ logger.exception("Unexpected error during photo upload")
153
+ raise UploadError("Unexpected error during photo upload") from e
154
+
155
+ try:
156
+ model = PhotoUploadResponse.model_validate(result)
157
+ except ValidationError as e:
158
+ logger.exception("Invalid photo upload response model")
159
+ logger.debug("Invalid photo upload response=%r", result)
160
+ raise UploadError("Invalid photo upload response model") from e
161
+
162
+ try:
163
+ token = model.photos[photo_id].token
164
+ except KeyError as e:
165
+ logger.exception(
166
+ "Photo upload response does not contain token for photo_id=%s",
167
+ photo_id,
168
+ )
169
+ logger.debug("Photo upload model=%r", model)
170
+ raise UploadError(
171
+ f"Photo upload response does not contain token for photo_id={photo_id}"
172
+ ) from e
173
+ except Exception as e:
174
+ logger.exception("Failed to extract photo token")
175
+ logger.debug("Photo upload model=%r", model)
176
+ raise UploadError("Failed to extract photo token") from e
177
+
178
+ logger.debug("Photo upload complete photo_id=%s", photo_id)
179
+ return AttachPhotoPayload(photo_token=token)
180
+
181
+ async def upload_video(self, video: Video) -> VideoAttachPayload:
182
+ logger.info("Uploading video")
183
+ logger.debug("Preparing video upload payload")
184
+
185
+ payload = UploadPayload().model_dump()
186
+
187
+ try:
188
+ data = await self.app.invoke(
189
+ Opcode.VIDEO_UPLOAD,
190
+ payload=payload,
191
+ )
192
+ except Exception as e:
193
+ logger.exception("Failed to request video upload URL")
194
+ raise UploadError("Failed to request video upload URL") from e
195
+
196
+ try:
197
+ response = VideoUploadResponse.model_validate(data.payload)
198
+ except ValidationError as e:
199
+ logger.exception("Invalid video upload response model")
200
+ logger.debug("Invalid video upload payload=%r", data.payload)
201
+ raise UploadError("Invalid video upload response model") from e
202
+ except Exception as e:
203
+ logger.exception("Failed to parse video upload response")
204
+ logger.debug("Invalid video upload payload=%r", data.payload)
205
+ raise UploadError("Failed to parse video upload response") from e
206
+
207
+ try:
208
+ upload_info = response.info[0]
209
+ except IndexError as e:
210
+ logger.error("Video upload response info is empty")
211
+ logger.debug("Video upload response=%r", response)
212
+ raise UploadError("Video upload response info is empty") from e
213
+ except Exception as e:
214
+ logger.exception("Failed to get video upload info")
215
+ logger.debug("Video upload response=%r", response)
216
+ raise UploadError("Failed to get video upload info") from e
217
+
218
+ try:
219
+ file_size = await video.size()
220
+ except Exception as e:
221
+ logger.exception("Failed to get video size")
222
+ raise UploadError("Failed to get video size") from e
223
+
224
+ logger.debug(
225
+ "Video upload info received video_id=%s file_size=%s",
226
+ upload_info.video_id,
227
+ file_size,
228
+ )
229
+
230
+ timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
231
+
232
+ headers = {
233
+ "Content-Disposition": f"attachment; filename={quote(video.name)}",
234
+ "Content-Range": f"0-{file_size - 1}/{file_size}",
235
+ "Content-Length": str(file_size),
236
+ "Connection": "keep-alive",
237
+ }
238
+
239
+ logger.debug(
240
+ "Video upload headers prepared content_range=%s",
241
+ headers["Content-Range"],
242
+ )
243
+
244
+ loop = asyncio.get_running_loop()
245
+ future: asyncio.Future[VideoUploadSignal] = loop.create_future()
246
+
247
+ video_id = upload_info.video_id
248
+ token = upload_info.token
249
+
250
+ self.video_upload_waiters[video_id] = future
251
+ logger.debug("Video upload waiter registered video_id=%s", video_id)
252
+
253
+ try:
254
+ async with aiohttp.ClientSession(timeout=timeout) as session:
255
+ logger.debug("Starting video upload HTTP request video_id=%s", video_id)
256
+
257
+ async with session.post(
258
+ url=upload_info.url,
259
+ headers=headers,
260
+ data=video.iter_chunks(1024 * 1024),
261
+ ) as response:
262
+ logger.debug(
263
+ "Video upload HTTP response status=%s video_id=%s",
264
+ response.status,
265
+ video_id,
266
+ )
267
+
268
+ if response.status != HTTPStatus.OK:
269
+ logger.error(
270
+ "Video upload failed with status %s video_id=%s",
271
+ response.status,
272
+ video_id,
273
+ )
274
+ raise UploadError(
275
+ "Video upload failed with status "
276
+ f"{response.status} video_id={video_id}"
277
+ )
278
+
279
+ try:
280
+ logger.debug(
281
+ "Waiting for video processing notification video_id=%s",
282
+ video_id,
283
+ )
284
+ await asyncio.wait_for(future, 60)
285
+ except asyncio.TimeoutError:
286
+ logger.warning(
287
+ "Timed out waiting for video processing notification video_id=%s",
288
+ video_id,
289
+ )
290
+ raise UploadError(
291
+ f"Timed out waiting for video processing video_id={video_id}"
292
+ )
293
+
294
+ logger.debug("Video upload complete video_id=%s", video_id)
295
+ return VideoAttachPayload(video_id=video_id, token=token)
296
+
297
+ except UploadError:
298
+ raise
299
+ except aiohttp.ClientError as e:
300
+ logger.exception("HTTP error during video upload video_id=%s", video_id)
301
+ raise UploadError(f"HTTP error during video upload video_id={video_id}") from e
302
+ except asyncio.TimeoutError as e:
303
+ logger.exception("Timed out during video upload video_id=%s", video_id)
304
+ raise UploadError(f"Timed out during video upload video_id={video_id}") from e
305
+ except Exception as e:
306
+ logger.exception("Unexpected error during video upload video_id=%s", video_id)
307
+ raise UploadError(f"Unexpected error during video upload video_id={video_id}") from e
308
+ finally:
309
+ self.video_upload_waiters.pop(video_id, None)
310
+ logger.debug("Video upload waiter removed video_id=%s", video_id)
311
+
312
+ async def upload_file(self, file: File) -> AttachFilePayload:
313
+ logger.info("Uploading file")
314
+
315
+ payload = UploadPayload().model_dump()
316
+
317
+ try:
318
+ data = await self.app.invoke(
319
+ Opcode.FILE_UPLOAD,
320
+ payload=payload,
321
+ )
322
+ except Exception as e:
323
+ logger.exception("Failed to request file upload URL")
324
+ raise UploadError("Failed to request file upload URL") from e
325
+
326
+ try:
327
+ response = FileUploadResponse.model_validate(data.payload)
328
+ except ValidationError as e:
329
+ logger.exception("Invalid file upload response model")
330
+ logger.debug("Invalid file upload payload=%r", data.payload)
331
+ raise UploadError("Invalid file upload response model") from e
332
+ except Exception as e:
333
+ logger.exception("Failed to parse file upload response")
334
+ logger.debug("Invalid File upload payload=%r", data.payload)
335
+ raise UploadError("Failed to parse file upload response") from e
336
+
337
+ try:
338
+ upload_info = response.info[0]
339
+ except IndexError as e:
340
+ logger.error("File upload response info is empty")
341
+ logger.debug("File upload response=%r", response)
342
+ raise UploadError("File upload response info is empty") from e
343
+ except Exception as e:
344
+ logger.exception("Failed to get file upload info")
345
+ logger.debug("File upload response=%r", response)
346
+ raise UploadError("Failed to get file upload info") from e
347
+
348
+ try:
349
+ file_size = await file.size()
350
+ except Exception as e:
351
+ logger.exception("Failed to get file size")
352
+ raise UploadError("Failed to get file size") from e
353
+
354
+ headers = {
355
+ "Content-Disposition": f"attachment; filename={quote(file.name)}",
356
+ "Content-Length": str(file_size),
357
+ "Content-Range": f"0-{file_size - 1}/{file_size}",
358
+ }
359
+
360
+ logger.debug(
361
+ "File upload headers prepared content_range=%s",
362
+ headers["Content-Range"],
363
+ )
364
+
365
+ loop = asyncio.get_running_loop()
366
+ future: asyncio.Future[FileUploadSignal] = loop.create_future()
367
+
368
+ file_id = upload_info.file_id
369
+
370
+ self.file_upload_waiters[file_id] = future
371
+ logger.debug("File upload waiter registered file_id=%s", file_id)
372
+
373
+ try:
374
+ async with aiohttp.ClientSession() as session:
375
+ async with session.post(
376
+ url=upload_info.url,
377
+ headers=headers,
378
+ data=file.iter_chunks(1024 * 1024),
379
+ ) as response:
380
+ logger.debug(
381
+ "File upload HTTP response status=%s file_id=%s",
382
+ response.status,
383
+ file_id,
384
+ )
385
+
386
+ if response.status != HTTPStatus.OK:
387
+ logger.error(
388
+ "File upload failed with status %s file_id=%s",
389
+ response.status,
390
+ file_id,
391
+ )
392
+ raise UploadError(
393
+ f"File upload failed with status {response.status} file_id={file_id}"
394
+ )
395
+
396
+ try:
397
+ logger.debug(
398
+ "Waiting for file processing notification file_id=%s",
399
+ file_id,
400
+ )
401
+ await asyncio.wait_for(future, 60)
402
+ except asyncio.TimeoutError:
403
+ logger.warning(
404
+ "Timed out waiting for file processing notification file_id=%s",
405
+ file_id,
406
+ )
407
+ raise UploadError(
408
+ f"Timed out waiting for file processing file_id={file_id}"
409
+ )
410
+
411
+ logger.debug("File upload complete file_id=%s", file_id)
412
+ return AttachFilePayload(file_id=file_id)
413
+
414
+ except UploadError:
415
+ raise
416
+ except aiohttp.ClientError as e:
417
+ logger.exception("HTTP error during file upload file_id=%s", file_id)
418
+ raise UploadError(f"HTTP error during file upload file_id={file_id}") from e
419
+ except asyncio.TimeoutError as e:
420
+ logger.exception("Timed out during file upload file_id=%s", file_id)
421
+ raise UploadError(f"Timed out during file upload file_id={file_id}") from e
422
+ except Exception as e:
423
+ logger.exception("Unexpected error during file upload file_id=%s", file_id)
424
+ raise UploadError(f"Unexpected error during file upload file_id={file_id}") from e
425
+ finally:
426
+ self.file_upload_waiters.pop(file_id, None)
427
+ logger.debug("File upload waiter removed file=%s", file_id)
428
+
429
+ async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
430
+ logger.debug("Received attach event video_id=%s", attach.video_id)
431
+
432
+ future = self.video_upload_waiters.pop(attach.video_id, None)
433
+
434
+ if not future:
435
+ logger.debug("No video upload waiter found video_id=%s", attach.video_id)
436
+ return
437
+
438
+ if future.done():
439
+ logger.debug("Video upload waiter already done video_id=%s", attach.video_id)
440
+ return
441
+
442
+ future.set_result(attach)
443
+ logger.debug("Video upload waiter resolved video_id=%s", attach.video_id)
444
+
445
+ async def on_file_attach(self, attach: FileUploadSignal, _: Client) -> None:
446
+ logger.debug("Received attach event file_id=%s", attach.file_id)
447
+ future = self.file_upload_waiters.pop(attach.file_id, None)
448
+
449
+ if not future:
450
+ logger.debug("No file upload waiter found file_id=%s", attach.file_id)
451
+ return
452
+
453
+ if future.done():
454
+ logger.debug("File upload waiter already done file_id=%s", attach.file_id)
455
+ return
456
+
457
+ future.set_result(attach)
458
+ logger.debug("File upload waiter resolved file_id=%s", attach.file_id)
@@ -0,0 +1,2 @@
1
+ from .enums import ContactAction, UserPayloadKey
2
+ from .service import UserService