maxapi-python 2.3.0__py3-none-any.whl → 2.4.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.3.0.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +4 -1
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +61 -53
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +7 -2
- pymax/_data/apk_fingerprints.json +682 -0
- pymax/api/auth/enums.py +1 -0
- pymax/api/auth/payloads.py +28 -2
- pymax/api/auth/service.py +88 -3
- pymax/api/chats/__init__.py +1 -0
- pymax/api/chats/enums.py +15 -0
- pymax/api/chats/payloads.py +16 -1
- pymax/api/chats/service.py +36 -1
- pymax/api/messages/payloads.py +33 -5
- pymax/api/messages/service.py +95 -13
- pymax/api/self/__init__.py +2 -1
- pymax/api/self/enums.py +9 -0
- pymax/api/self/payloads.py +40 -1
- pymax/api/self/service.py +37 -0
- pymax/api/session/service.py +21 -8
- pymax/api/uploads/payloads.py +6 -0
- pymax/api/uploads/service.py +198 -21
- pymax/app.py +69 -15
- pymax/base.py +8 -0
- pymax/client_web.py +2 -2
- pymax/config.py +44 -7
- pymax/connection/connection.py +29 -18
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +3 -1
- pymax/dispatch/resolvers.py +7 -1
- pymax/files/__init__.py +4 -1
- pymax/files/video.py +58 -0
- pymax/files/voice.py +39 -0
- pymax/fingerprint/__init__.py +1 -0
- pymax/fingerprint/fingerprint.py +47 -0
- pymax/fingerprint/models.py +11 -0
- pymax/infra/auth.py +4 -0
- pymax/infra/bots.py +3 -3
- pymax/infra/chat.py +38 -0
- pymax/infra/message.py +65 -9
- pymax/infra/self.py +25 -1
- pymax/protocol/enums.py +13 -0
- pymax/protocol/tcp/compression.py +18 -0
- pymax/protocol/tcp/payload.py +41 -5
- pymax/protocol/tcp/protocol.py +5 -1
- pymax/transport/tcp.py +6 -1
- pymax/transport/websocket.py +6 -3
- pymax/types/domain/__init__.py +2 -1
- pymax/types/domain/attachments/__init__.py +2 -1
- pymax/types/domain/attachments/enums.py +17 -1
- pymax/types/domain/attachments/poll.py +111 -0
- pymax/types/domain/attachments/sticker.py +1 -1
- pymax/types/domain/attachments/video.py +29 -8
- pymax/types/domain/chat.py +9 -9
- pymax/types/domain/handshake.py +16 -0
- pymax/types/domain/login.py +30 -1
- pymax/types/domain/message.py +47 -19
- pymax/types/domain/user.py +5 -5
- pymax/types/events/__init__.py +1 -0
- pymax/types/events/reaction.py +2 -2
- pymax/types/events/voice.py +5 -0
- {maxapi_python-2.3.0.dist-info → maxapi_python-2.4.0.dist-info}/licenses/LICENSE +0 -0
pymax/api/self/payloads.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
3
5
|
from pymax.api.models import CamelModel
|
|
4
6
|
|
|
5
|
-
from .enums import AvatarType
|
|
7
|
+
from .enums import AvatarType, PrivacyAccess
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
class UploadPayload(CamelModel):
|
|
@@ -39,3 +41,40 @@ class UpdateFolderPayload(CamelModel):
|
|
|
39
41
|
|
|
40
42
|
class DeleteFolderPayload(CamelModel):
|
|
41
43
|
folder_ids: list[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PrivacySettingsUpdate(BaseModel):
|
|
47
|
+
"""Изменяемые настройки приватности аккаунта.
|
|
48
|
+
|
|
49
|
+
Все поля необязательны: в запрос попадают только переданные настройки.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
search_by_phone: Кто может найти аккаунт по номеру телефона.
|
|
53
|
+
incoming_calls: Кто может звонить аккаунту.
|
|
54
|
+
chat_invites: Кто может добавлять аккаунт в чаты.
|
|
55
|
+
phone_number_visibility: Кто может видеть номер телефона.
|
|
56
|
+
hide_online_status: Скрывать ли статус присутствия.
|
|
57
|
+
safe_content_only: Показывать только безопасный контент.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
search_by_phone: PrivacyAccess | None = Field(
|
|
61
|
+
default=None, serialization_alias="SEARCH_BY_PHONE"
|
|
62
|
+
)
|
|
63
|
+
incoming_calls: PrivacyAccess | None = Field(default=None, serialization_alias="INCOMING_CALL")
|
|
64
|
+
chat_invites: PrivacyAccess | None = Field(default=None, serialization_alias="CHATS_INVITE")
|
|
65
|
+
phone_number_visibility: PrivacyAccess | None = Field(
|
|
66
|
+
default=None, serialization_alias="PHONE_NUMBER_PRIVACY"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
hide_online_status: bool | None = Field(default=None, serialization_alias="HIDDEN")
|
|
70
|
+
safe_content_only: bool | None = Field(
|
|
71
|
+
default=None, serialization_alias="CONTENT_LEVEL_ACCESS"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ChangeProfileSettings(CamelModel):
|
|
76
|
+
user: PrivacySettingsUpdate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ChangeProfileSettingsPayload(CamelModel):
|
|
80
|
+
settings: ChangeProfileSettings
|
pymax/api/self/service.py
CHANGED
|
@@ -18,9 +18,12 @@ from pymax.types.domain import FolderList, FolderUpdate, Profile
|
|
|
18
18
|
from .enums import SelfPayloadKey
|
|
19
19
|
from .payloads import (
|
|
20
20
|
ChangeProfilePayload,
|
|
21
|
+
ChangeProfileSettings,
|
|
22
|
+
ChangeProfileSettingsPayload,
|
|
21
23
|
CreateFolderPayload,
|
|
22
24
|
DeleteFolderPayload,
|
|
23
25
|
GetFolderPayload,
|
|
26
|
+
PrivacySettingsUpdate,
|
|
24
27
|
UpdateFolderPayload,
|
|
25
28
|
UploadPayload,
|
|
26
29
|
)
|
|
@@ -150,3 +153,37 @@ class SelfService:
|
|
|
150
153
|
logger.info("logging out")
|
|
151
154
|
await self.app.invoke(Opcode.LOGOUT, {})
|
|
152
155
|
return True
|
|
156
|
+
|
|
157
|
+
def set_presence(self, online: bool) -> None:
|
|
158
|
+
logger.info("setting presence to %s", "online" if online else "offline")
|
|
159
|
+
self.app.config.interactive = online
|
|
160
|
+
|
|
161
|
+
async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool:
|
|
162
|
+
logger.info("changing profile settings")
|
|
163
|
+
|
|
164
|
+
frame = ChangeProfileSettingsPayload(settings=ChangeProfileSettings(user=settings))
|
|
165
|
+
|
|
166
|
+
response = await self.app.invoke(
|
|
167
|
+
Opcode.CONFIG,
|
|
168
|
+
frame.to_payload(),
|
|
169
|
+
)
|
|
170
|
+
session = self.app.session
|
|
171
|
+
|
|
172
|
+
if not session:
|
|
173
|
+
logger.warning("no session found, skipping sync update")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
sync = session.sync.model_copy(
|
|
177
|
+
update={"config_hash": require_payload_item(response, SelfPayloadKey.HASH)}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
updated = session.model_copy(
|
|
181
|
+
update={
|
|
182
|
+
"mt_instance_id": self.app.config.device.mt_instance_id,
|
|
183
|
+
"sync": sync,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
self.app.session = updated
|
|
187
|
+
await self.app.store.save_session(updated)
|
|
188
|
+
|
|
189
|
+
return True
|
pymax/api/session/service.py
CHANGED
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from pymax.api.response import require_payload_model
|
|
5
6
|
from pymax.logging import get_logger
|
|
6
7
|
from pymax.protocol import Opcode
|
|
8
|
+
from pymax.types.domain import HandshakeResponse
|
|
7
9
|
|
|
8
10
|
from .enums import DeviceType
|
|
9
11
|
from .payloads import (
|
|
@@ -28,19 +30,18 @@ class SessionService:
|
|
|
28
30
|
mt_instance_id: str,
|
|
29
31
|
user_agent: MobileUserAgentPayload,
|
|
30
32
|
device_id: str,
|
|
31
|
-
) ->
|
|
33
|
+
) -> HandshakeResponse:
|
|
32
34
|
if user_agent.device_type == DeviceType.WEB:
|
|
33
|
-
await self.web_handshake(user_agent, device_id)
|
|
34
|
-
return
|
|
35
|
+
return await self.web_handshake(user_agent, device_id)
|
|
35
36
|
|
|
36
|
-
await self.mobile_handshake(mt_instance_id, user_agent, device_id)
|
|
37
|
+
return await self.mobile_handshake(mt_instance_id, user_agent, device_id)
|
|
37
38
|
|
|
38
39
|
async def mobile_handshake(
|
|
39
40
|
self,
|
|
40
41
|
mt_instance_id: str,
|
|
41
42
|
user_agent: MobileUserAgentPayload,
|
|
42
43
|
device_id: str,
|
|
43
|
-
) ->
|
|
44
|
+
) -> HandshakeResponse:
|
|
44
45
|
logger.debug(
|
|
45
46
|
"mobile handshake mt_instance_id_set=%s device_id=%s app_version=%s",
|
|
46
47
|
bool(mt_instance_id),
|
|
@@ -52,10 +53,17 @@ class SessionService:
|
|
|
52
53
|
user_agent=user_agent,
|
|
53
54
|
device_id=device_id,
|
|
54
55
|
)
|
|
55
|
-
await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
|
|
56
|
+
response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
|
|
56
57
|
logger.info("mobile handshake completed")
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
return require_payload_model(
|
|
60
|
+
response,
|
|
61
|
+
HandshakeResponse,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def web_handshake(
|
|
65
|
+
self, user_agent: MobileUserAgentPayload, device_id: str
|
|
66
|
+
) -> HandshakeResponse:
|
|
59
67
|
logger.debug(
|
|
60
68
|
"web handshake device_id=%s app_version=%s browser=%s",
|
|
61
69
|
device_id,
|
|
@@ -66,5 +74,10 @@ class SessionService:
|
|
|
66
74
|
user_agent=user_agent,
|
|
67
75
|
device_id=device_id,
|
|
68
76
|
)
|
|
69
|
-
await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
|
|
77
|
+
response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
|
|
70
78
|
logger.info("web handshake completed")
|
|
79
|
+
|
|
80
|
+
return require_payload_model(
|
|
81
|
+
response,
|
|
82
|
+
HandshakeResponse,
|
|
83
|
+
)
|
pymax/api/uploads/payloads.py
CHANGED
|
@@ -13,6 +13,10 @@ class VideoAttachPayload(CamelModel):
|
|
|
13
13
|
type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
|
|
14
14
|
video_id: int
|
|
15
15
|
token: str
|
|
16
|
+
video_type: int = 0
|
|
17
|
+
thumbhash: bytes | None = None
|
|
18
|
+
duration: int | None = None
|
|
19
|
+
wave: bytes | None = None
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
class AttachFilePayload(CamelModel):
|
|
@@ -22,4 +26,6 @@ class AttachFilePayload(CamelModel):
|
|
|
22
26
|
|
|
23
27
|
class UploadPayload(CamelModel):
|
|
24
28
|
count: int = 1
|
|
29
|
+
type: int = 0
|
|
30
|
+
uploader_type: int = 0
|
|
25
31
|
profile: bool = False
|
pymax/api/uploads/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import base64
|
|
4
5
|
from http import HTTPStatus
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
6
7
|
from urllib.parse import parse_qs, quote, urlparse
|
|
@@ -11,9 +12,10 @@ from pydantic import ValidationError
|
|
|
11
12
|
from pymax.api.response import payload_item
|
|
12
13
|
from pymax.dispatch.enums import EventType
|
|
13
14
|
from pymax.exceptions import UploadError
|
|
14
|
-
from pymax.files import File, Photo, Video
|
|
15
|
+
from pymax.files import File, Photo, Video, VideoNote, Voice
|
|
15
16
|
from pymax.logging import get_logger
|
|
16
17
|
from pymax.protocol import Opcode
|
|
18
|
+
from pymax.types import AttachmentType, AudioUploadSignal
|
|
17
19
|
|
|
18
20
|
from .models import (
|
|
19
21
|
FileUploadResponse,
|
|
@@ -40,14 +42,16 @@ class UploadService:
|
|
|
40
42
|
self.app = app
|
|
41
43
|
self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
|
|
42
44
|
self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
|
|
45
|
+
self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {}
|
|
43
46
|
self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
|
|
44
47
|
self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
|
|
48
|
+
self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
|
|
45
49
|
|
|
46
50
|
async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
|
|
47
51
|
logger.info("Uploading photo")
|
|
48
52
|
logger.debug("Preparing photo upload payload")
|
|
49
53
|
|
|
50
|
-
payload = UploadPayload(profile=profile).
|
|
54
|
+
payload = UploadPayload(profile=profile).to_payload()
|
|
51
55
|
|
|
52
56
|
try:
|
|
53
57
|
data = await self.app.invoke(
|
|
@@ -178,11 +182,146 @@ class UploadService:
|
|
|
178
182
|
logger.debug("Photo upload complete photo_id=%s", photo_id)
|
|
179
183
|
return AttachPhotoPayload(photo_token=token)
|
|
180
184
|
|
|
181
|
-
async def
|
|
185
|
+
async def upload_voice(self, voice: Voice) -> VideoAttachPayload:
|
|
186
|
+
logger.info("Uploading voice")
|
|
187
|
+
|
|
188
|
+
payload = UploadPayload(
|
|
189
|
+
type=2,
|
|
190
|
+
uploader_type=1,
|
|
191
|
+
).to_payload()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
data = await self.app.invoke(
|
|
195
|
+
Opcode.VIDEO_UPLOAD,
|
|
196
|
+
payload=payload,
|
|
197
|
+
)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.exception("Failed to request voice upload URL")
|
|
200
|
+
raise UploadError("Failed to request voice upload URL") from e
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
response = VideoUploadResponse.model_validate(data.payload)
|
|
204
|
+
except ValidationError as e:
|
|
205
|
+
logger.exception("Invalid voice upload response model")
|
|
206
|
+
logger.debug("Invalid voice upload payload=%r", data.payload)
|
|
207
|
+
raise UploadError("Invalid voice upload response model") from e
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.exception("Failed to parse voice upload response")
|
|
210
|
+
logger.debug("Invalid voice upload payload=%r", data.payload)
|
|
211
|
+
raise UploadError("Failed to parse voice upload response") from e
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
upload_info = response.info[0]
|
|
215
|
+
except IndexError as e:
|
|
216
|
+
logger.error("voice upload response info is empty")
|
|
217
|
+
logger.debug("voice upload response=%r", response)
|
|
218
|
+
raise UploadError("voice upload response info is empty") from e
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.exception("Failed to get voice upload info")
|
|
221
|
+
logger.debug("voice upload response=%r", response)
|
|
222
|
+
raise UploadError("Failed to get voice upload info") from e
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
file_size = await voice.size()
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.exception("Failed to get voice size")
|
|
228
|
+
raise UploadError("Failed to get voice size") from e
|
|
229
|
+
|
|
230
|
+
headers = {
|
|
231
|
+
"Content-Disposition": f"attachment; filename={quote(voice.name)}",
|
|
232
|
+
"Content-Range": f"0-{file_size - 1}/{file_size}",
|
|
233
|
+
"Content-Length": str(file_size),
|
|
234
|
+
"Connection": "keep-alive",
|
|
235
|
+
"Content-Type": "application/octet-stream",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
logger.debug(
|
|
239
|
+
"Voice upload headers prepared content_range=%s",
|
|
240
|
+
headers["Content-Range"],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
future: asyncio.Future[AudioUploadSignal] = loop.create_future()
|
|
245
|
+
|
|
246
|
+
timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
|
|
247
|
+
|
|
248
|
+
video_id = upload_info.video_id
|
|
249
|
+
token = upload_info.token
|
|
250
|
+
|
|
251
|
+
self.voice_upload_waiters[video_id] = future
|
|
252
|
+
logger.debug("Voice upload waiter registered voice_id=%s", video_id)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
async with aiohttp.ClientSession(
|
|
256
|
+
timeout=timeout, proxy=self.app.config.proxy
|
|
257
|
+
) as session:
|
|
258
|
+
logger.debug("Starting voice upload HTTP request voice_id=%s", video_id)
|
|
259
|
+
|
|
260
|
+
async with session.post(
|
|
261
|
+
url=upload_info.url,
|
|
262
|
+
headers=headers,
|
|
263
|
+
data=voice.iter_chunks(1024 * 1024),
|
|
264
|
+
) as response:
|
|
265
|
+
logger.debug(
|
|
266
|
+
"Voice upload HTTP response status=%s voice_id=%s",
|
|
267
|
+
response.status,
|
|
268
|
+
video_id,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if response.status != HTTPStatus.OK:
|
|
272
|
+
logger.error(
|
|
273
|
+
"Voice upload failed with status %s video_id=%s",
|
|
274
|
+
response.status,
|
|
275
|
+
video_id,
|
|
276
|
+
)
|
|
277
|
+
raise UploadError(
|
|
278
|
+
"Voice upload failed with status "
|
|
279
|
+
f"{response.status} voice_id={video_id}"
|
|
280
|
+
)
|
|
281
|
+
|
|
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
|
+
)
|
|
296
|
+
|
|
297
|
+
logger.debug("Voice upload complete voice_id=%s", video_id)
|
|
298
|
+
return VideoAttachPayload(
|
|
299
|
+
type=AttachmentType.AUDIO, video_id=video_id, token=token
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
except UploadError:
|
|
303
|
+
raise
|
|
304
|
+
except aiohttp.ClientError as e:
|
|
305
|
+
logger.exception("HTTP error during voice upload voice_id=%s", video_id)
|
|
306
|
+
raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e
|
|
307
|
+
except asyncio.TimeoutError as e:
|
|
308
|
+
logger.exception("Timed out during voice upload voice_id=%s", video_id)
|
|
309
|
+
raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.exception("Unexpected error during voice upload voice_id=%s", video_id)
|
|
312
|
+
raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e
|
|
313
|
+
finally:
|
|
314
|
+
self.voice_upload_waiters.pop(video_id, None)
|
|
315
|
+
logger.debug("Voice upload waiter removed voice_id=%s", video_id)
|
|
316
|
+
|
|
317
|
+
async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload:
|
|
182
318
|
logger.info("Uploading video")
|
|
183
319
|
logger.debug("Preparing video upload payload")
|
|
184
320
|
|
|
185
|
-
|
|
321
|
+
if isinstance(uploadable_video, VideoNote):
|
|
322
|
+
payload = UploadPayload(type=1, uploader_type=1).to_payload()
|
|
323
|
+
else:
|
|
324
|
+
payload = UploadPayload().to_payload()
|
|
186
325
|
|
|
187
326
|
try:
|
|
188
327
|
data = await self.app.invoke(
|
|
@@ -216,7 +355,7 @@ class UploadService:
|
|
|
216
355
|
raise UploadError("Failed to get video upload info") from e
|
|
217
356
|
|
|
218
357
|
try:
|
|
219
|
-
file_size = await
|
|
358
|
+
file_size = await uploadable_video.size()
|
|
220
359
|
except Exception as e:
|
|
221
360
|
logger.exception("Failed to get video size")
|
|
222
361
|
raise UploadError("Failed to get video size") from e
|
|
@@ -230,8 +369,8 @@ class UploadService:
|
|
|
230
369
|
timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
|
|
231
370
|
|
|
232
371
|
headers = {
|
|
233
|
-
"Content-Disposition": f"attachment; filename={quote(
|
|
234
|
-
"Content-Range": f"0-{file_size - 1}/{file_size}",
|
|
372
|
+
"Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}",
|
|
373
|
+
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
235
374
|
"Content-Length": str(file_size),
|
|
236
375
|
"Connection": "keep-alive",
|
|
237
376
|
}
|
|
@@ -240,15 +379,16 @@ class UploadService:
|
|
|
240
379
|
"Video upload headers prepared content_range=%s",
|
|
241
380
|
headers["Content-Range"],
|
|
242
381
|
)
|
|
243
|
-
|
|
244
|
-
loop = asyncio.get_running_loop()
|
|
245
|
-
future: asyncio.Future[VideoUploadSignal] = loop.create_future()
|
|
246
|
-
|
|
247
|
-
video_id = upload_info.video_id
|
|
248
382
|
token = upload_info.token
|
|
383
|
+
video_id = upload_info.video_id
|
|
384
|
+
|
|
385
|
+
future = None
|
|
386
|
+
if not isinstance(uploadable_video, VideoNote):
|
|
387
|
+
loop = asyncio.get_running_loop()
|
|
388
|
+
future = loop.create_future()
|
|
389
|
+
self.video_upload_waiters[video_id] = future
|
|
249
390
|
|
|
250
|
-
|
|
251
|
-
logger.debug("Video upload waiter registered video_id=%s", video_id)
|
|
391
|
+
logger.debug("Video upload waiter registered video_id=%s", video_id)
|
|
252
392
|
|
|
253
393
|
try:
|
|
254
394
|
async with aiohttp.ClientSession(
|
|
@@ -259,7 +399,7 @@ class UploadService:
|
|
|
259
399
|
async with session.post(
|
|
260
400
|
url=upload_info.url,
|
|
261
401
|
headers=headers,
|
|
262
|
-
data=
|
|
402
|
+
data=uploadable_video.iter_chunks(1024 * 1024),
|
|
263
403
|
) as response:
|
|
264
404
|
logger.debug(
|
|
265
405
|
"Video upload HTTP response status=%s video_id=%s",
|
|
@@ -279,11 +419,33 @@ class UploadService:
|
|
|
279
419
|
)
|
|
280
420
|
|
|
281
421
|
try:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
422
|
+
if isinstance(uploadable_video, VideoNote):
|
|
423
|
+
data = await response.json(content_type=None)
|
|
424
|
+
|
|
425
|
+
thumbhash = data.get("thumbhash")
|
|
426
|
+
if thumbhash:
|
|
427
|
+
thumbhash += "=" * (-len(thumbhash) % 4)
|
|
428
|
+
thumbhash = base64.b64decode(thumbhash)
|
|
429
|
+
|
|
430
|
+
return VideoAttachPayload(
|
|
431
|
+
video_id=video_id,
|
|
432
|
+
token=token,
|
|
433
|
+
video_type=1,
|
|
434
|
+
thumbhash=thumbhash,
|
|
435
|
+
duration=await uploadable_video.get_duration(),
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
if future is None:
|
|
439
|
+
raise ValueError(
|
|
440
|
+
"Unexpected internal state: "
|
|
441
|
+
+ "future is missing in UploadService.upload_video."
|
|
442
|
+
+ f" video type = {type(uploadable_video)}"
|
|
443
|
+
)
|
|
444
|
+
logger.debug(
|
|
445
|
+
"Waiting for video processing notification video_id=%s",
|
|
446
|
+
video_id,
|
|
447
|
+
)
|
|
448
|
+
await asyncio.wait_for(future, 60)
|
|
287
449
|
except asyncio.TimeoutError:
|
|
288
450
|
logger.warning(
|
|
289
451
|
"Timed out waiting for video processing notification video_id=%s",
|
|
@@ -294,6 +456,7 @@ class UploadService:
|
|
|
294
456
|
)
|
|
295
457
|
|
|
296
458
|
logger.debug("Video upload complete video_id=%s", video_id)
|
|
459
|
+
|
|
297
460
|
return VideoAttachPayload(video_id=video_id, token=token)
|
|
298
461
|
|
|
299
462
|
except UploadError:
|
|
@@ -314,7 +477,7 @@ class UploadService:
|
|
|
314
477
|
async def upload_file(self, file: File) -> AttachFilePayload:
|
|
315
478
|
logger.info("Uploading file")
|
|
316
479
|
|
|
317
|
-
payload = UploadPayload().
|
|
480
|
+
payload = UploadPayload().to_payload()
|
|
318
481
|
|
|
319
482
|
try:
|
|
320
483
|
data = await self.app.invoke(
|
|
@@ -430,6 +593,20 @@ class UploadService:
|
|
|
430
593
|
self.file_upload_waiters.pop(file_id, None)
|
|
431
594
|
logger.debug("File upload waiter removed file=%s", file_id)
|
|
432
595
|
|
|
596
|
+
async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None:
|
|
597
|
+
future = self.voice_upload_waiters.pop(attach.audio_id, None)
|
|
598
|
+
|
|
599
|
+
if not future:
|
|
600
|
+
logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id)
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
if future.done():
|
|
604
|
+
logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id)
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
future.set_result(attach)
|
|
608
|
+
logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id)
|
|
609
|
+
|
|
433
610
|
async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
|
|
434
611
|
logger.debug("Received attach event video_id=%s", attach.video_id)
|
|
435
612
|
|
pymax/app.py
CHANGED
|
@@ -8,6 +8,7 @@ from pymax.connection import ConnectionManager
|
|
|
8
8
|
from pymax.dispatch import Dispatcher
|
|
9
9
|
from pymax.dispatch.router import EventType, Router
|
|
10
10
|
from pymax.exceptions import ApiError
|
|
11
|
+
from pymax.fingerprint import FingerprintGenerator
|
|
11
12
|
from pymax.logging import get_logger
|
|
12
13
|
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
13
14
|
from pymax.protocol.enums import Opcode
|
|
@@ -15,7 +16,8 @@ from pymax.session import SessionStore
|
|
|
15
16
|
from pymax.session.models import SessionInfo
|
|
16
17
|
from pymax.telemetry import TelemetryService
|
|
17
18
|
from pymax.types import MaxApiError, Message
|
|
18
|
-
from pymax.types.domain import Chat, Profile, User
|
|
19
|
+
from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
|
|
20
|
+
from pymax.types.domain.login import LoginResponse
|
|
19
21
|
|
|
20
22
|
if TYPE_CHECKING:
|
|
21
23
|
from pymax.base import BaseClient
|
|
@@ -38,6 +40,7 @@ class App(Generic[ClientT]):
|
|
|
38
40
|
self.config = config
|
|
39
41
|
self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
|
|
40
42
|
self.auth_flow = auth_flow
|
|
43
|
+
self.fingerprint_generator = FingerprintGenerator()
|
|
41
44
|
|
|
42
45
|
self.me: Profile | None = None
|
|
43
46
|
self.chats: list[Chat] | None = None
|
|
@@ -46,6 +49,7 @@ class App(Generic[ClientT]):
|
|
|
46
49
|
self.messages: dict[int, list[Message]] = {}
|
|
47
50
|
|
|
48
51
|
self.session: SessionInfo | None = None
|
|
52
|
+
self.handshake_response: HandshakeResponse | None = None
|
|
49
53
|
|
|
50
54
|
self.started = False
|
|
51
55
|
self._ping_task: asyncio.Task[None] | None = None
|
|
@@ -81,12 +85,14 @@ class App(Generic[ClientT]):
|
|
|
81
85
|
session_data.device_id if session_data else self.config.device.device_id
|
|
82
86
|
)
|
|
83
87
|
logger.debug("running handshake")
|
|
84
|
-
await self.handshake(handshake_device_id)
|
|
88
|
+
handshake_response = await self.handshake(handshake_device_id)
|
|
85
89
|
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
|
|
86
90
|
logger.exception("failed to connect or handshake")
|
|
87
91
|
await self.connection.close()
|
|
88
92
|
raise ConnectionError(f"Failed to connect and handshake: {e}") from e
|
|
89
93
|
|
|
94
|
+
self.handshake_response = handshake_response
|
|
95
|
+
|
|
90
96
|
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
91
97
|
|
|
92
98
|
if not session_data:
|
|
@@ -129,10 +135,26 @@ class App(Generic[ClientT]):
|
|
|
129
135
|
logger.debug("logging in")
|
|
130
136
|
|
|
131
137
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
login_response, login2_response = await self.login()
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
login2_response
|
|
142
|
+
and login_response.login2_flags
|
|
143
|
+
and login_response.login2_flags.profile_enabled
|
|
144
|
+
and login2_response.profile
|
|
145
|
+
):
|
|
146
|
+
self.me = login2_response.profile
|
|
147
|
+
elif login_response.profile:
|
|
148
|
+
self.me = login_response.profile
|
|
149
|
+
elif login2_response and login2_response.profile:
|
|
150
|
+
self.me = login2_response.profile
|
|
151
|
+
else:
|
|
152
|
+
logger.error("Unexpected internal state: login response does not contain profile")
|
|
153
|
+
raise RuntimeError("Login response does not contain profile")
|
|
135
154
|
except Exception as e:
|
|
155
|
+
if self._is_invalid_login_token_error(e):
|
|
156
|
+
raise
|
|
157
|
+
|
|
136
158
|
handled = False
|
|
137
159
|
if self.dispatcher.client is not None:
|
|
138
160
|
handled = await self.dispatcher.emit_error(
|
|
@@ -148,15 +170,23 @@ class App(Generic[ClientT]):
|
|
|
148
170
|
await self.close()
|
|
149
171
|
return
|
|
150
172
|
|
|
151
|
-
if
|
|
152
|
-
await self.store.update_token(self.session.token,
|
|
153
|
-
self.session.token =
|
|
173
|
+
if login_response.token is not None and login_response.token != self.session.token:
|
|
174
|
+
await self.store.update_token(self.session.token, login_response.token)
|
|
175
|
+
self.session.token = login_response.token
|
|
154
176
|
|
|
155
|
-
self.
|
|
156
|
-
self.chats = response.chats
|
|
177
|
+
self.chats = login_response.chats
|
|
157
178
|
self.users[self.me.contact.id] = self.me.contact
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
login2_response
|
|
182
|
+
and login_response.login2_flags
|
|
183
|
+
and login_response.login2_flags.contact_enabled
|
|
184
|
+
):
|
|
185
|
+
self.contacts = login2_response.contacts
|
|
186
|
+
else:
|
|
187
|
+
self.contacts = login_response.contacts
|
|
188
|
+
|
|
189
|
+
self.messages = login_response.messages
|
|
160
190
|
|
|
161
191
|
self.started = True
|
|
162
192
|
logger.info(
|
|
@@ -168,13 +198,37 @@ class App(Generic[ClientT]):
|
|
|
168
198
|
if self._telemetry:
|
|
169
199
|
self._telemetry.start()
|
|
170
200
|
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _is_invalid_login_token_error(exc: Exception) -> bool:
|
|
203
|
+
return (
|
|
204
|
+
isinstance(exc, ApiError)
|
|
205
|
+
and exc.opcode == Opcode.LOGIN
|
|
206
|
+
and any(
|
|
207
|
+
err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL")
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def login(self) -> tuple[LoginResponse, Login2Response | None]:
|
|
212
|
+
login_response = await self.api.auth.login(
|
|
213
|
+
self.config.device.user_agent,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if login_response.login2_flags and login_response.login2_flags.enabled:
|
|
217
|
+
logger.debug("login2 required; proceeding with login2")
|
|
218
|
+
login2_response = await self.api.auth.mobile_login2(login_response.login2_flags)
|
|
219
|
+
|
|
220
|
+
return login_response, login2_response
|
|
221
|
+
|
|
222
|
+
return login_response, None
|
|
223
|
+
|
|
224
|
+
async def handshake(self, device_id: str) -> HandshakeResponse:
|
|
225
|
+
response = await self.api.session.handshake(
|
|
173
226
|
self.config.device.mt_instance_id,
|
|
174
227
|
self.config.device.user_agent,
|
|
175
228
|
device_id,
|
|
176
229
|
)
|
|
177
230
|
logger.debug("handshake completed device_id=%s", device_id)
|
|
231
|
+
return response
|
|
178
232
|
|
|
179
233
|
async def close(self) -> None:
|
|
180
234
|
if self._telemetry:
|
|
@@ -246,7 +300,7 @@ class App(Generic[ClientT]):
|
|
|
246
300
|
while True:
|
|
247
301
|
await self.invoke(
|
|
248
302
|
opcode=Opcode.PING,
|
|
249
|
-
payload={"interactive":
|
|
303
|
+
payload={"interactive": self.config.interactive},
|
|
250
304
|
timeout=self.config.request_timeout,
|
|
251
305
|
)
|
|
252
306
|
await asyncio.sleep(30)
|
pymax/base.py
CHANGED
|
@@ -7,6 +7,7 @@ from uuid import uuid4
|
|
|
7
7
|
|
|
8
8
|
from pymax.dispatch import ErrorScope, Router
|
|
9
9
|
from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator
|
|
10
|
+
from pymax.exceptions import ApiError
|
|
10
11
|
from pymax.infra import BaseMixin
|
|
11
12
|
from pymax.logging import get_logger
|
|
12
13
|
|
|
@@ -138,6 +139,13 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
138
139
|
except asyncio.CancelledError:
|
|
139
140
|
await self.close()
|
|
140
141
|
raise
|
|
142
|
+
except ApiError as e:
|
|
143
|
+
if not self._app._is_invalid_login_token_error(e):
|
|
144
|
+
await self.close()
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
logger.warning("login token was revoked; starting authentication again")
|
|
148
|
+
await self.relogin(start=False)
|
|
141
149
|
except ( # noqa: PERF203
|
|
142
150
|
ConnectionError,
|
|
143
151
|
EOFError,
|