maxapi-python 2.3.1__py3-none-any.whl → 2.4.1__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.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
- pymax/__init__.py +9 -2
- pymax/_data/apk_fingerprints.json +767 -0
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/enums.py +1 -0
- pymax/api/auth/payloads.py +28 -2
- pymax/api/auth/service.py +100 -3
- pymax/api/chats/__init__.py +1 -0
- pymax/api/chats/enums.py +15 -0
- pymax/api/chats/payloads.py +18 -2
- pymax/api/chats/service.py +43 -1
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/payloads.py +21 -7
- pymax/api/messages/service.py +161 -24
- 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 +39 -1
- pymax/api/uploads/service.py +247 -55
- pymax/app.py +91 -18
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/sms.py +11 -1
- pymax/base.py +90 -8
- pymax/client.py +39 -10
- pymax/client_web.py +17 -14
- pymax/config.py +98 -19
- 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/base.py +58 -1
- pymax/files/video.py +51 -1
- pymax/files/voice.py +53 -0
- pymax/fingerprint/__init__.py +1 -0
- pymax/fingerprint/fingerprint.py +30 -0
- pymax/fingerprint/models.py +12 -0
- pymax/infra/auth.py +4 -0
- pymax/infra/bots.py +3 -3
- pymax/infra/chat.py +47 -5
- pymax/infra/message.py +49 -14
- pymax/infra/protocol.py +5 -1
- pymax/infra/self.py +25 -1
- pymax/protocol/enums.py +13 -0
- pymax/protocol/tcp/payload.py +21 -1
- pymax/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +17 -4
- 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/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +33 -1
- pymax/types/domain/attachments/poll.py +111 -0
- pymax/types/domain/attachments/sticker.py +1 -1
- pymax/types/domain/attachments/video.py +32 -11
- pymax/types/domain/chat.py +9 -9
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/handshake.py +16 -0
- pymax/types/domain/login.py +30 -1
- pymax/types/domain/message.py +108 -27
- pymax/types/domain/user.py +1 -3
- pymax/types/events/__init__.py +1 -0
- pymax/types/events/reaction.py +2 -2
- pymax/types/events/voice.py +5 -0
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
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 AudioUploadSignal
|
|
17
19
|
|
|
18
20
|
from .models import (
|
|
19
21
|
FileUploadResponse,
|
|
@@ -25,6 +27,8 @@ from .payloads import (
|
|
|
25
27
|
AttachPhotoPayload,
|
|
26
28
|
UploadPayload,
|
|
27
29
|
VideoAttachPayload,
|
|
30
|
+
VideoNoteAttachPayload,
|
|
31
|
+
VoiceAttachPayload,
|
|
28
32
|
)
|
|
29
33
|
|
|
30
34
|
if TYPE_CHECKING:
|
|
@@ -40,14 +44,16 @@ class UploadService:
|
|
|
40
44
|
self.app = app
|
|
41
45
|
self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
|
|
42
46
|
self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
|
|
47
|
+
self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {}
|
|
43
48
|
self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
|
|
44
49
|
self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
|
|
50
|
+
self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
|
|
45
51
|
|
|
46
52
|
async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
|
|
47
53
|
logger.info("Uploading photo")
|
|
48
54
|
logger.debug("Preparing photo upload payload")
|
|
49
55
|
|
|
50
|
-
payload = UploadPayload(profile=profile).
|
|
56
|
+
payload = UploadPayload(profile=profile).to_payload()
|
|
51
57
|
|
|
52
58
|
try:
|
|
53
59
|
data = await self.app.invoke(
|
|
@@ -178,11 +184,160 @@ class UploadService:
|
|
|
178
184
|
logger.debug("Photo upload complete photo_id=%s", photo_id)
|
|
179
185
|
return AttachPhotoPayload(photo_token=token)
|
|
180
186
|
|
|
181
|
-
async def
|
|
187
|
+
async def upload_voice(self, voice: Voice) -> VoiceAttachPayload:
|
|
188
|
+
logger.info("Uploading voice")
|
|
189
|
+
|
|
190
|
+
payload = UploadPayload(
|
|
191
|
+
type=2,
|
|
192
|
+
uploader_type=1,
|
|
193
|
+
).to_payload()
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
data = await self.app.invoke(
|
|
197
|
+
Opcode.VIDEO_UPLOAD,
|
|
198
|
+
payload=payload,
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.exception("Failed to request voice upload URL")
|
|
202
|
+
raise UploadError("Failed to request voice upload URL") from e
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
response = VideoUploadResponse.model_validate(data.payload)
|
|
206
|
+
except ValidationError as e:
|
|
207
|
+
logger.exception("Invalid voice upload response model")
|
|
208
|
+
logger.debug("Invalid voice upload payload=%r", data.payload)
|
|
209
|
+
raise UploadError("Invalid voice upload response model") from e
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.exception("Failed to parse voice upload response")
|
|
212
|
+
logger.debug("Invalid voice upload payload=%r", data.payload)
|
|
213
|
+
raise UploadError("Failed to parse voice upload response") from e
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
upload_info = response.info[0]
|
|
217
|
+
except IndexError as e:
|
|
218
|
+
logger.error("voice upload response info is empty")
|
|
219
|
+
logger.debug("voice upload response=%r", response)
|
|
220
|
+
raise UploadError("voice upload response info is empty") from e
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.exception("Failed to get voice upload info")
|
|
223
|
+
logger.debug("voice upload response=%r", response)
|
|
224
|
+
raise UploadError("Failed to get voice upload info") from e
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
file_size = await voice.size()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.exception("Failed to get voice size")
|
|
230
|
+
raise UploadError("Failed to get voice size") from e
|
|
231
|
+
|
|
232
|
+
# ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"),
|
|
233
|
+
# OKMessages/{app_version} ({os_version}; {device_name}; {screen})
|
|
234
|
+
|
|
235
|
+
user_agent = (
|
|
236
|
+
f"OKMessages/{self.app.config.app_version}"
|
|
237
|
+
+ f" ({self.app.config.device.user_agent.os_version};"
|
|
238
|
+
+ f" {self.app.config.device.user_agent.device_name};"
|
|
239
|
+
+ f" {self.app.config.device.user_agent.screen})"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
headers = {
|
|
243
|
+
"Content-Disposition": f"attachment; filename={quote(voice.name)}",
|
|
244
|
+
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
245
|
+
"Content-Length": str(file_size),
|
|
246
|
+
"Connection": "keep-alive",
|
|
247
|
+
"Content-Type": "application/octet-stream",
|
|
248
|
+
"User-Agent": quote(user_agent),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logger.debug(
|
|
252
|
+
"Voice upload headers prepared content_range=%s",
|
|
253
|
+
headers["Content-Range"],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
257
|
+
|
|
258
|
+
video_id = upload_info.video_id
|
|
259
|
+
token = upload_info.token
|
|
260
|
+
|
|
261
|
+
logger.debug("Voice upload waiter registered voice_id=%s", video_id)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
async with aiohttp.ClientSession(
|
|
265
|
+
timeout=timeout, proxy=self.app.config.proxy
|
|
266
|
+
) as session:
|
|
267
|
+
logger.debug("Starting voice upload HTTP request voice_id=%s", video_id)
|
|
268
|
+
|
|
269
|
+
async with session.post(
|
|
270
|
+
url=upload_info.url,
|
|
271
|
+
headers=headers,
|
|
272
|
+
data=voice.iter_chunks(1024 * 1024),
|
|
273
|
+
) as response:
|
|
274
|
+
logger.debug(
|
|
275
|
+
"Voice upload HTTP response status=%s voice_id=%s",
|
|
276
|
+
response.status,
|
|
277
|
+
video_id,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if response.status != HTTPStatus.OK:
|
|
281
|
+
logger.error(
|
|
282
|
+
"Voice upload failed with status %s video_id=%s",
|
|
283
|
+
response.status,
|
|
284
|
+
video_id,
|
|
285
|
+
)
|
|
286
|
+
raise UploadError(
|
|
287
|
+
"Voice upload failed with status "
|
|
288
|
+
f"{response.status} voice_id={video_id}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# print(await response.read()) # TODO: Error handing (yes, with 200 status 💔)
|
|
292
|
+
|
|
293
|
+
# try:
|
|
294
|
+
# logger.debug(
|
|
295
|
+
# "Waiting for voice processing notification voice_id=%s",
|
|
296
|
+
# video_id,
|
|
297
|
+
# )
|
|
298
|
+
# await asyncio.wait_for(future, 60)
|
|
299
|
+
# except asyncio.TimeoutError:
|
|
300
|
+
# logger.warning(
|
|
301
|
+
# "Timed out waiting for voice processing notification voice_id=%s",
|
|
302
|
+
# video_id,
|
|
303
|
+
# )
|
|
304
|
+
# raise UploadError(
|
|
305
|
+
# f"Timed out waiting for voice processing voice_id={video_id}"
|
|
306
|
+
# )
|
|
307
|
+
|
|
308
|
+
logger.debug("Voice upload complete voice_id=%s", video_id)
|
|
309
|
+
return VoiceAttachPayload(
|
|
310
|
+
video_id=video_id,
|
|
311
|
+
token=token,
|
|
312
|
+
duration=await voice.get_duration(),
|
|
313
|
+
wave=b"\x00" * 80,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
except UploadError:
|
|
317
|
+
raise
|
|
318
|
+
except aiohttp.ClientError as e:
|
|
319
|
+
logger.exception("HTTP error during voice upload voice_id=%s", video_id)
|
|
320
|
+
raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e
|
|
321
|
+
except asyncio.TimeoutError as e:
|
|
322
|
+
logger.exception("Timed out during voice upload voice_id=%s", video_id)
|
|
323
|
+
raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.exception("Unexpected error during voice upload voice_id=%s", video_id)
|
|
326
|
+
raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e
|
|
327
|
+
finally:
|
|
328
|
+
self.voice_upload_waiters.pop(video_id, None)
|
|
329
|
+
logger.debug("Voice upload waiter removed voice_id=%s", video_id)
|
|
330
|
+
|
|
331
|
+
async def upload_video(
|
|
332
|
+
self, uploadable_video: Video | VideoNote
|
|
333
|
+
) -> VideoAttachPayload | VideoNoteAttachPayload:
|
|
182
334
|
logger.info("Uploading video")
|
|
183
335
|
logger.debug("Preparing video upload payload")
|
|
184
336
|
|
|
185
|
-
|
|
337
|
+
if isinstance(uploadable_video, VideoNote):
|
|
338
|
+
payload = UploadPayload(type=1, uploader_type=1).to_payload()
|
|
339
|
+
else:
|
|
340
|
+
payload = UploadPayload().to_payload()
|
|
186
341
|
|
|
187
342
|
try:
|
|
188
343
|
data = await self.app.invoke(
|
|
@@ -216,7 +371,7 @@ class UploadService:
|
|
|
216
371
|
raise UploadError("Failed to get video upload info") from e
|
|
217
372
|
|
|
218
373
|
try:
|
|
219
|
-
file_size = await
|
|
374
|
+
file_size = await uploadable_video.size()
|
|
220
375
|
except Exception as e:
|
|
221
376
|
logger.exception("Failed to get video size")
|
|
222
377
|
raise UploadError("Failed to get video size") from e
|
|
@@ -227,11 +382,11 @@ class UploadService:
|
|
|
227
382
|
file_size,
|
|
228
383
|
)
|
|
229
384
|
|
|
230
|
-
timeout = aiohttp.ClientTimeout(total=
|
|
385
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
231
386
|
|
|
232
387
|
headers = {
|
|
233
|
-
"Content-Disposition": f"attachment; filename={quote(
|
|
234
|
-
"Content-Range": f"0-{file_size - 1}/{file_size}",
|
|
388
|
+
"Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}",
|
|
389
|
+
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
235
390
|
"Content-Length": str(file_size),
|
|
236
391
|
"Connection": "keep-alive",
|
|
237
392
|
}
|
|
@@ -240,15 +395,16 @@ class UploadService:
|
|
|
240
395
|
"Video upload headers prepared content_range=%s",
|
|
241
396
|
headers["Content-Range"],
|
|
242
397
|
)
|
|
243
|
-
|
|
244
|
-
loop = asyncio.get_running_loop()
|
|
245
|
-
future: asyncio.Future[VideoUploadSignal] = loop.create_future()
|
|
246
|
-
|
|
247
|
-
video_id = upload_info.video_id
|
|
248
398
|
token = upload_info.token
|
|
399
|
+
video_id = upload_info.video_id
|
|
400
|
+
|
|
401
|
+
future = None
|
|
402
|
+
if not isinstance(uploadable_video, VideoNote):
|
|
403
|
+
loop = asyncio.get_running_loop()
|
|
404
|
+
future = loop.create_future()
|
|
405
|
+
self.video_upload_waiters[video_id] = future
|
|
249
406
|
|
|
250
|
-
|
|
251
|
-
logger.debug("Video upload waiter registered video_id=%s", video_id)
|
|
407
|
+
logger.debug("Video upload waiter registered video_id=%s", video_id)
|
|
252
408
|
|
|
253
409
|
try:
|
|
254
410
|
async with aiohttp.ClientSession(
|
|
@@ -259,7 +415,7 @@ class UploadService:
|
|
|
259
415
|
async with session.post(
|
|
260
416
|
url=upload_info.url,
|
|
261
417
|
headers=headers,
|
|
262
|
-
data=
|
|
418
|
+
data=uploadable_video.iter_chunks(1024 * 1024),
|
|
263
419
|
) as response:
|
|
264
420
|
logger.debug(
|
|
265
421
|
"Video upload HTTP response status=%s video_id=%s",
|
|
@@ -279,11 +435,32 @@ class UploadService:
|
|
|
279
435
|
)
|
|
280
436
|
|
|
281
437
|
try:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
438
|
+
if isinstance(uploadable_video, VideoNote):
|
|
439
|
+
data = await response.json(content_type=None)
|
|
440
|
+
|
|
441
|
+
thumbhash = data.get("thumbhash")
|
|
442
|
+
if thumbhash:
|
|
443
|
+
thumbhash += "=" * (-len(thumbhash) % 4)
|
|
444
|
+
thumbhash = base64.b64decode(thumbhash)
|
|
445
|
+
|
|
446
|
+
return VideoNoteAttachPayload(
|
|
447
|
+
video_id=video_id,
|
|
448
|
+
token=token,
|
|
449
|
+
thumbhash=thumbhash,
|
|
450
|
+
duration=await uploadable_video.get_duration(),
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
if future is None:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"Unexpected internal state: "
|
|
456
|
+
+ "future is missing in UploadService.upload_video."
|
|
457
|
+
+ f" video type = {type(uploadable_video)}"
|
|
458
|
+
)
|
|
459
|
+
logger.debug(
|
|
460
|
+
"Waiting for video processing notification video_id=%s",
|
|
461
|
+
video_id,
|
|
462
|
+
)
|
|
463
|
+
await asyncio.wait_for(future, 60)
|
|
287
464
|
except asyncio.TimeoutError:
|
|
288
465
|
logger.warning(
|
|
289
466
|
"Timed out waiting for video processing notification video_id=%s",
|
|
@@ -294,6 +471,7 @@ class UploadService:
|
|
|
294
471
|
)
|
|
295
472
|
|
|
296
473
|
logger.debug("Video upload complete video_id=%s", video_id)
|
|
474
|
+
|
|
297
475
|
return VideoAttachPayload(video_id=video_id, token=token)
|
|
298
476
|
|
|
299
477
|
except UploadError:
|
|
@@ -314,7 +492,7 @@ class UploadService:
|
|
|
314
492
|
async def upload_file(self, file: File) -> AttachFilePayload:
|
|
315
493
|
logger.info("Uploading file")
|
|
316
494
|
|
|
317
|
-
payload = UploadPayload().
|
|
495
|
+
payload = UploadPayload().to_payload()
|
|
318
496
|
|
|
319
497
|
try:
|
|
320
498
|
data = await self.app.invoke(
|
|
@@ -369,51 +547,51 @@ class UploadService:
|
|
|
369
547
|
|
|
370
548
|
file_id = upload_info.file_id
|
|
371
549
|
|
|
550
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
551
|
+
|
|
372
552
|
self.file_upload_waiters[file_id] = future
|
|
373
553
|
logger.debug("File upload waiter registered file_id=%s", file_id)
|
|
374
554
|
|
|
375
555
|
try:
|
|
376
|
-
async with
|
|
377
|
-
proxy=self.app.config.proxy,
|
|
378
|
-
|
|
379
|
-
async with session.post(
|
|
556
|
+
async with (
|
|
557
|
+
aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session,
|
|
558
|
+
session.post(
|
|
380
559
|
url=upload_info.url,
|
|
381
560
|
headers=headers,
|
|
382
561
|
data=file.iter_chunks(1024 * 1024),
|
|
383
|
-
) as response
|
|
384
|
-
|
|
385
|
-
|
|
562
|
+
) as response,
|
|
563
|
+
):
|
|
564
|
+
logger.debug(
|
|
565
|
+
"File upload HTTP response status=%s file_id=%s",
|
|
566
|
+
response.status,
|
|
567
|
+
file_id,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if response.status != HTTPStatus.OK:
|
|
571
|
+
logger.error(
|
|
572
|
+
"File upload failed with status %s file_id=%s",
|
|
386
573
|
response.status,
|
|
387
574
|
file_id,
|
|
388
575
|
)
|
|
576
|
+
raise UploadError(
|
|
577
|
+
f"File upload failed with status {response.status} file_id={file_id}"
|
|
578
|
+
)
|
|
389
579
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
"Waiting for file processing notification file_id=%s",
|
|
403
|
-
file_id,
|
|
404
|
-
)
|
|
405
|
-
await asyncio.wait_for(future, 60)
|
|
406
|
-
except asyncio.TimeoutError:
|
|
407
|
-
logger.warning(
|
|
408
|
-
"Timed out waiting for file processing notification file_id=%s",
|
|
409
|
-
file_id,
|
|
410
|
-
)
|
|
411
|
-
raise UploadError(
|
|
412
|
-
f"Timed out waiting for file processing file_id={file_id}"
|
|
413
|
-
)
|
|
580
|
+
try:
|
|
581
|
+
logger.debug(
|
|
582
|
+
"Waiting for file processing notification file_id=%s",
|
|
583
|
+
file_id,
|
|
584
|
+
)
|
|
585
|
+
await asyncio.wait_for(future, 60)
|
|
586
|
+
except asyncio.TimeoutError:
|
|
587
|
+
logger.warning(
|
|
588
|
+
"Timed out waiting for file processing notification file_id=%s",
|
|
589
|
+
file_id,
|
|
590
|
+
)
|
|
591
|
+
raise UploadError(f"Timed out waiting for file processing file_id={file_id}")
|
|
414
592
|
|
|
415
|
-
|
|
416
|
-
|
|
593
|
+
logger.debug("File upload complete file_id=%s", file_id)
|
|
594
|
+
return AttachFilePayload(file_id=file_id)
|
|
417
595
|
|
|
418
596
|
except UploadError:
|
|
419
597
|
raise
|
|
@@ -430,6 +608,20 @@ class UploadService:
|
|
|
430
608
|
self.file_upload_waiters.pop(file_id, None)
|
|
431
609
|
logger.debug("File upload waiter removed file=%s", file_id)
|
|
432
610
|
|
|
611
|
+
async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None:
|
|
612
|
+
future = self.voice_upload_waiters.pop(attach.audio_id, None)
|
|
613
|
+
|
|
614
|
+
if not future:
|
|
615
|
+
logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id)
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if future.done():
|
|
619
|
+
logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id)
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
future.set_result(attach)
|
|
623
|
+
logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id)
|
|
624
|
+
|
|
433
625
|
async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
|
|
434
626
|
logger.debug("Received attach event video_id=%s", attach.video_id)
|
|
435
627
|
|
pymax/app.py
CHANGED
|
@@ -8,14 +8,16 @@ 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
|
|
14
|
-
from pymax.session import SessionStore
|
|
15
|
-
from pymax.session.models import SessionInfo
|
|
15
|
+
from pymax.session import InMemoryStore, SessionStore
|
|
16
|
+
from pymax.session.models import SessionInfo, resolve_session_user_agent
|
|
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
|
|
@@ -36,8 +38,15 @@ class App(Generic[ClientT]):
|
|
|
36
38
|
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
|
|
37
39
|
self.api = ApiFacade(self)
|
|
38
40
|
self.config = config
|
|
39
|
-
self.store =
|
|
41
|
+
self.store = (
|
|
42
|
+
(self.config.store or SessionStore(config.work_dir, config.session_name))
|
|
43
|
+
if self.config.persist_session
|
|
44
|
+
else InMemoryStore()
|
|
45
|
+
)
|
|
40
46
|
self.auth_flow = auth_flow
|
|
47
|
+
self.fingerprint_generator: FingerprintGenerator | None = (
|
|
48
|
+
FingerprintGenerator(self.config.fingering) if self.config.fingering else None
|
|
49
|
+
)
|
|
41
50
|
|
|
42
51
|
self.me: Profile | None = None
|
|
43
52
|
self.chats: list[Chat] | None = None
|
|
@@ -46,6 +55,7 @@ class App(Generic[ClientT]):
|
|
|
46
55
|
self.messages: dict[int, list[Message]] = {}
|
|
47
56
|
|
|
48
57
|
self.session: SessionInfo | None = None
|
|
58
|
+
self.handshake_response: HandshakeResponse | None = None
|
|
49
59
|
|
|
50
60
|
self.started = False
|
|
51
61
|
self._ping_task: asyncio.Task[None] | None = None
|
|
@@ -73,6 +83,17 @@ class App(Generic[ClientT]):
|
|
|
73
83
|
else:
|
|
74
84
|
session_data.mt_instance_id = self.config.device.mt_instance_id
|
|
75
85
|
|
|
86
|
+
if self.config.restore_user_agent_from_session:
|
|
87
|
+
user_agent = resolve_session_user_agent(
|
|
88
|
+
self.config.device.user_agent,
|
|
89
|
+
session_data.user_agent,
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
user_agent = self.config.device.user_agent
|
|
93
|
+
self.config.device.user_agent = user_agent
|
|
94
|
+
if session_data.user_agent != user_agent:
|
|
95
|
+
session_data = session_data.model_copy(update={"user_agent": user_agent})
|
|
96
|
+
|
|
76
97
|
try:
|
|
77
98
|
logger.debug("opening connection")
|
|
78
99
|
await self.connection.open()
|
|
@@ -81,12 +102,14 @@ class App(Generic[ClientT]):
|
|
|
81
102
|
session_data.device_id if session_data else self.config.device.device_id
|
|
82
103
|
)
|
|
83
104
|
logger.debug("running handshake")
|
|
84
|
-
await self.handshake(handshake_device_id)
|
|
105
|
+
handshake_response = await self.handshake(handshake_device_id)
|
|
85
106
|
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
|
|
86
107
|
logger.exception("failed to connect or handshake")
|
|
87
108
|
await self.connection.close()
|
|
88
109
|
raise ConnectionError(f"Failed to connect and handshake: {e}") from e
|
|
89
110
|
|
|
111
|
+
self.handshake_response = handshake_response
|
|
112
|
+
|
|
90
113
|
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
91
114
|
|
|
92
115
|
if not session_data:
|
|
@@ -99,6 +122,7 @@ class App(Generic[ClientT]):
|
|
|
99
122
|
device_id=self.config.device.device_id,
|
|
100
123
|
phone=self.config.phone or "",
|
|
101
124
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
125
|
+
user_agent=self.config.device.user_agent,
|
|
102
126
|
)
|
|
103
127
|
)
|
|
104
128
|
else:
|
|
@@ -114,6 +138,7 @@ class App(Generic[ClientT]):
|
|
|
114
138
|
device_id=self.config.device.device_id,
|
|
115
139
|
phone=self.config.phone or "",
|
|
116
140
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
141
|
+
user_agent=self.config.device.user_agent,
|
|
117
142
|
)
|
|
118
143
|
)
|
|
119
144
|
logger.info("new session saved")
|
|
@@ -129,10 +154,26 @@ class App(Generic[ClientT]):
|
|
|
129
154
|
logger.debug("logging in")
|
|
130
155
|
|
|
131
156
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
login_response, login2_response = await self.login()
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
login2_response
|
|
161
|
+
and login_response.login2_flags
|
|
162
|
+
and login_response.login2_flags.profile_enabled
|
|
163
|
+
and login2_response.profile
|
|
164
|
+
):
|
|
165
|
+
self.me = login2_response.profile
|
|
166
|
+
elif login_response.profile:
|
|
167
|
+
self.me = login_response.profile
|
|
168
|
+
elif login2_response and login2_response.profile:
|
|
169
|
+
self.me = login2_response.profile
|
|
170
|
+
else:
|
|
171
|
+
logger.error("Unexpected internal state: login response does not contain profile")
|
|
172
|
+
raise RuntimeError("Login response does not contain profile")
|
|
135
173
|
except Exception as e:
|
|
174
|
+
if self._is_invalid_login_token_error(e):
|
|
175
|
+
raise
|
|
176
|
+
|
|
136
177
|
handled = False
|
|
137
178
|
if self.dispatcher.client is not None:
|
|
138
179
|
handled = await self.dispatcher.emit_error(
|
|
@@ -148,15 +189,23 @@ class App(Generic[ClientT]):
|
|
|
148
189
|
await self.close()
|
|
149
190
|
return
|
|
150
191
|
|
|
151
|
-
if
|
|
152
|
-
await self.store.update_token(self.session.token,
|
|
153
|
-
self.session.token =
|
|
192
|
+
if login_response.token is not None and login_response.token != self.session.token:
|
|
193
|
+
await self.store.update_token(self.session.token, login_response.token)
|
|
194
|
+
self.session.token = login_response.token
|
|
154
195
|
|
|
155
|
-
self.
|
|
156
|
-
self.chats = response.chats
|
|
196
|
+
self.chats = login_response.chats
|
|
157
197
|
self.users[self.me.contact.id] = self.me.contact
|
|
158
|
-
|
|
159
|
-
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
login2_response
|
|
201
|
+
and login_response.login2_flags
|
|
202
|
+
and login_response.login2_flags.contact_enabled
|
|
203
|
+
):
|
|
204
|
+
self.contacts = login2_response.contacts
|
|
205
|
+
else:
|
|
206
|
+
self.contacts = login_response.contacts
|
|
207
|
+
|
|
208
|
+
self.messages = login_response.messages
|
|
160
209
|
|
|
161
210
|
self.started = True
|
|
162
211
|
logger.info(
|
|
@@ -168,13 +217,37 @@ class App(Generic[ClientT]):
|
|
|
168
217
|
if self._telemetry:
|
|
169
218
|
self._telemetry.start()
|
|
170
219
|
|
|
171
|
-
|
|
172
|
-
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _is_invalid_login_token_error(exc: Exception) -> bool:
|
|
222
|
+
return (
|
|
223
|
+
isinstance(exc, ApiError)
|
|
224
|
+
and exc.opcode == Opcode.LOGIN
|
|
225
|
+
and any(
|
|
226
|
+
err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL")
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def login(self) -> tuple[LoginResponse, Login2Response | None]:
|
|
231
|
+
login_response = await self.api.auth.login(
|
|
232
|
+
self.config.device.user_agent,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if login_response.login2_flags and login_response.login2_flags.enabled:
|
|
236
|
+
logger.debug("login2 required; proceeding with login2")
|
|
237
|
+
login2_response = await self.api.auth.mobile_login2(login_response.login2_flags)
|
|
238
|
+
|
|
239
|
+
return login_response, login2_response
|
|
240
|
+
|
|
241
|
+
return login_response, None
|
|
242
|
+
|
|
243
|
+
async def handshake(self, device_id: str) -> HandshakeResponse:
|
|
244
|
+
response = await self.api.session.handshake(
|
|
173
245
|
self.config.device.mt_instance_id,
|
|
174
246
|
self.config.device.user_agent,
|
|
175
247
|
device_id,
|
|
176
248
|
)
|
|
177
249
|
logger.debug("handshake completed device_id=%s", device_id)
|
|
250
|
+
return response
|
|
178
251
|
|
|
179
252
|
async def close(self) -> None:
|
|
180
253
|
if self._telemetry:
|
|
@@ -246,7 +319,7 @@ class App(Generic[ClientT]):
|
|
|
246
319
|
while True:
|
|
247
320
|
await self.invoke(
|
|
248
321
|
opcode=Opcode.PING,
|
|
249
|
-
payload={"interactive":
|
|
322
|
+
payload={"interactive": self.config.interactive},
|
|
250
323
|
timeout=self.config.request_timeout,
|
|
251
324
|
)
|
|
252
325
|
await asyncio.sleep(30)
|
pymax/auth/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from .base import AuthFlow
|
|
2
|
+
from .exceptions import PasswordAttemptsExceededError
|
|
2
3
|
from .providers import (
|
|
3
4
|
ConsolePasswordProvider,
|
|
4
5
|
ConsoleQrHandler,
|
|
@@ -17,6 +18,7 @@ __all__ = (
|
|
|
17
18
|
"ConsoleQrHandler",
|
|
18
19
|
"ConsoleSmsCodeProvider",
|
|
19
20
|
"EmailCodeProvider",
|
|
21
|
+
"PasswordAttemptsExceededError",
|
|
20
22
|
"PasswordProvider",
|
|
21
23
|
"QrAuthFlow",
|
|
22
24
|
"QrHandler",
|
pymax/auth/exceptions.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class PasswordAttemptsExceededError(Exception):
|
|
2
|
+
"""Исчерпан лимит попыток ввода пароля 2FA.
|
|
3
|
+
|
|
4
|
+
Наследуется напрямую от ``Exception`` и не перехватывается как
|
|
5
|
+
``PyMaxError``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
super().__init__("2FA password attempts exhausted")
|
pymax/auth/providers.py
CHANGED
|
@@ -113,7 +113,11 @@ class EmailCodeProvider(Protocol):
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
class ConsoleEmailCodeProvider:
|
|
116
|
-
"""Консольный provider кода подтверждения email для 2FA.
|
|
116
|
+
"""Консольный provider кода подтверждения email для 2FA.
|
|
117
|
+
|
|
118
|
+
Блокирующий ``input()`` выполняется в worker thread и не останавливает
|
|
119
|
+
asyncio event loop.
|
|
120
|
+
"""
|
|
117
121
|
|
|
118
122
|
async def get_code(self, email: str) -> str:
|
|
119
123
|
"""Запрашивает email-код в консоли.
|
|
@@ -124,4 +128,4 @@ class ConsoleEmailCodeProvider:
|
|
|
124
128
|
Returns:
|
|
125
129
|
Введенный пользователем код.
|
|
126
130
|
"""
|
|
127
|
-
return input
|
|
131
|
+
return (await asyncio.to_thread(input, f"Enter 2FA email code for {email}: ")).strip()
|