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.
- maxapi_python-2.0.0.dist-info/METADATA +217 -0
- maxapi_python-2.0.0.dist-info/RECORD +140 -0
- {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +50 -105
- pymax/api/__init__.py +17 -0
- pymax/api/auth/__init__.py +1 -0
- pymax/api/auth/enums.py +17 -0
- pymax/api/auth/payloads.py +129 -0
- pymax/api/auth/service.py +313 -0
- pymax/api/auth/types.py +13 -0
- pymax/api/chats/__init__.py +8 -0
- pymax/api/chats/enums.py +27 -0
- pymax/api/chats/payloads.py +103 -0
- pymax/api/chats/service.py +277 -0
- pymax/api/facade.py +32 -0
- pymax/api/messages/__init__.py +1 -0
- pymax/api/messages/enums.py +17 -0
- pymax/api/messages/payloads.py +92 -0
- pymax/api/messages/service.py +337 -0
- pymax/api/models.py +13 -0
- pymax/api/response.py +123 -0
- pymax/api/self/__init__.py +2 -0
- pymax/api/self/enums.py +11 -0
- pymax/api/self/payloads.py +41 -0
- pymax/api/self/service.py +142 -0
- pymax/api/session/__init__.py +1 -0
- pymax/api/session/enums.py +10 -0
- pymax/api/session/payloads.py +76 -0
- pymax/api/session/service.py +72 -0
- pymax/api/uploads/__init__.py +1 -0
- pymax/api/uploads/models.py +49 -0
- pymax/api/uploads/payloads.py +25 -0
- pymax/api/uploads/service.py +458 -0
- pymax/api/users/__init__.py +2 -0
- pymax/api/users/enums.py +12 -0
- pymax/api/users/payloads.py +16 -0
- pymax/api/users/service.py +124 -0
- pymax/app.py +273 -0
- pymax/auth/__init__.py +25 -0
- pymax/auth/base.py +37 -0
- pymax/auth/email.py +0 -0
- pymax/auth/models.py +5 -0
- pymax/auth/providers.py +127 -0
- pymax/auth/qr.py +135 -0
- pymax/auth/service.py +25 -0
- pymax/auth/sms.py +122 -0
- pymax/base.py +204 -0
- pymax/client.py +106 -0
- pymax/client_web.py +83 -0
- pymax/config.py +215 -0
- pymax/connection/__init__.py +1 -0
- pymax/connection/connection.py +205 -0
- pymax/connection/pending.py +46 -0
- pymax/connection/readers/__init__.py +2 -0
- pymax/connection/readers/base.py +6 -0
- pymax/connection/readers/tcp.py +29 -0
- pymax/connection/readers/ws.py +14 -0
- pymax/dispatch/__init__.py +10 -0
- pymax/dispatch/dispatcher.py +222 -0
- pymax/dispatch/enums.py +12 -0
- pymax/dispatch/mapping.py +73 -0
- pymax/dispatch/resolvers.py +52 -0
- pymax/dispatch/router.py +216 -0
- pymax/exceptions.py +22 -89
- pymax/files/__init__.py +9 -0
- pymax/files/base.py +82 -0
- pymax/files/file.py +76 -0
- pymax/files/photo.py +108 -0
- pymax/files/static.py +10 -0
- pymax/files/video.py +74 -0
- pymax/formatting/__init__.py +0 -0
- pymax/formatting/markdown.py +217 -0
- pymax/infra/__init__.py +1 -0
- pymax/infra/auth.py +55 -0
- pymax/infra/base.py +15 -0
- pymax/infra/chat.py +240 -0
- pymax/infra/message.py +252 -0
- pymax/infra/protocol.py +9 -0
- pymax/infra/self.py +139 -0
- pymax/infra/user.py +107 -0
- pymax/logging.py +129 -0
- pymax/protocol/__init__.py +11 -0
- pymax/protocol/base.py +13 -0
- pymax/protocol/enums.py +180 -0
- pymax/protocol/models.py +33 -0
- pymax/protocol/tcp/__init__.py +1 -0
- pymax/protocol/tcp/compression.py +97 -0
- pymax/protocol/tcp/framing.py +68 -0
- pymax/protocol/tcp/payload.py +127 -0
- pymax/protocol/tcp/protocol.py +68 -0
- pymax/protocol/ws/__init__.py +1 -0
- pymax/protocol/ws/protocol.py +27 -0
- pymax/py.typed +0 -0
- pymax/routers.py +8 -0
- pymax/session/__init__.py +3 -0
- pymax/session/models.py +11 -0
- pymax/session/protocol.py +14 -0
- pymax/session/store.py +232 -0
- pymax/telemetry/__init__.py +3 -0
- pymax/telemetry/navigation.py +181 -0
- pymax/telemetry/payloads.py +142 -0
- pymax/telemetry/service.py +225 -0
- pymax/transport/__init__.py +0 -0
- pymax/transport/base.py +14 -0
- pymax/transport/tcp.py +93 -0
- pymax/transport/websocket.py +50 -0
- pymax/types/__init__.py +2 -0
- pymax/types/domain/__init__.py +11 -0
- pymax/types/domain/attachments/__init__.py +11 -0
- pymax/types/domain/attachments/audio.py +35 -0
- pymax/types/domain/attachments/call.py +26 -0
- pymax/types/domain/attachments/contact.py +32 -0
- pymax/types/domain/attachments/control.py +20 -0
- pymax/types/domain/attachments/enums.py +27 -0
- pymax/types/domain/attachments/file.py +56 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -0
- pymax/types/domain/attachments/keyboards/inline.py +19 -0
- pymax/types/domain/attachments/photo.py +45 -0
- pymax/types/domain/attachments/share.py +29 -0
- pymax/types/domain/attachments/sticker.py +50 -0
- pymax/types/domain/attachments/video.py +90 -0
- pymax/types/domain/auth.py +161 -0
- pymax/types/domain/base.py +17 -0
- pymax/types/domain/chat.py +426 -0
- pymax/types/domain/element.py +24 -0
- pymax/types/domain/enums.py +24 -0
- pymax/types/domain/error.py +20 -0
- pymax/types/domain/folder.py +74 -0
- pymax/types/domain/login.py +35 -0
- pymax/types/domain/message.py +378 -0
- pymax/types/domain/name.py +20 -0
- pymax/types/domain/profile.py +15 -0
- pymax/types/domain/session.py +52 -0
- pymax/types/domain/sync.py +80 -0
- pymax/types/domain/user.py +117 -0
- pymax/types/events/__init__.py +3 -0
- pymax/types/events/file.py +5 -0
- pymax/types/events/message.py +37 -0
- pymax/types/events/video.py +5 -0
- maxapi_python-1.2.5.dist-info/METADATA +0 -202
- maxapi_python-1.2.5.dist-info/RECORD +0 -33
- pymax/core.py +0 -398
- pymax/crud.py +0 -96
- pymax/files.py +0 -138
- pymax/filters.py +0 -164
- pymax/formatter.py +0 -31
- pymax/formatting.py +0 -74
- pymax/interfaces.py +0 -558
- pymax/mixins/__init__.py +0 -40
- pymax/mixins/auth.py +0 -594
- pymax/mixins/channel.py +0 -130
- pymax/mixins/group.py +0 -458
- pymax/mixins/handler.py +0 -285
- pymax/mixins/message.py +0 -879
- pymax/mixins/scheduler.py +0 -28
- pymax/mixins/self.py +0 -259
- pymax/mixins/socket.py +0 -306
- pymax/mixins/telemetry.py +0 -118
- pymax/mixins/user.py +0 -219
- pymax/mixins/websocket.py +0 -151
- pymax/models.py +0 -8
- pymax/navigation.py +0 -187
- pymax/payloads.py +0 -403
- pymax/protocols.py +0 -123
- pymax/static/constant.py +0 -96
- pymax/static/enum.py +0 -231
- pymax/types.py +0 -1220
- pymax/utils.py +0 -90
- {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)
|