maxapi-python 2.4.0__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.4.0.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +6 -4
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +47 -42
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
- pymax/__init__.py +4 -2
- pymax/_data/apk_fingerprints.json +102 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +14 -2
- pymax/api/chats/payloads.py +2 -1
- pymax/api/chats/service.py +7 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/payloads.py +9 -3
- pymax/api/messages/service.py +106 -12
- pymax/api/uploads/payloads.py +33 -1
- pymax/api/uploads/service.py +77 -62
- pymax/app.py +23 -4
- 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 +83 -9
- pymax/client.py +39 -10
- pymax/client_web.py +15 -12
- pymax/config.py +63 -21
- pymax/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- pymax/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -0
- pymax/infra/chat.py +9 -5
- pymax/infra/message.py +9 -3
- pymax/infra/protocol.py +5 -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 +11 -3
- 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 +16 -0
- pymax/types/domain/attachments/video.py +3 -3
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +87 -7
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
pymax/api/messages/service.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import time
|
|
4
5
|
from collections.abc import Sequence
|
|
5
|
-
from
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING, TypeAlias, TypeVar
|
|
6
8
|
|
|
7
9
|
from pymax.api.binding import bind_api_model, bind_api_models
|
|
8
10
|
from pymax.api.response import (
|
|
@@ -16,8 +18,10 @@ from pymax.api.uploads.payloads import (
|
|
|
16
18
|
AttachFilePayload,
|
|
17
19
|
AttachPhotoPayload,
|
|
18
20
|
VideoAttachPayload,
|
|
21
|
+
VideoNoteAttachPayload,
|
|
22
|
+
VoiceAttachPayload,
|
|
19
23
|
)
|
|
20
|
-
from pymax.exceptions import UploadError
|
|
24
|
+
from pymax.exceptions import ApiError, UploadError
|
|
21
25
|
from pymax.files import File, Photo, Video, VideoNote, Voice
|
|
22
26
|
from pymax.formatting.markdown import Formatter
|
|
23
27
|
from pymax.logging import get_logger
|
|
@@ -36,6 +40,7 @@ from .enums import ItemType, MessagePayloadKey, ReadAction
|
|
|
36
40
|
from .payloads import (
|
|
37
41
|
AddReactionPayload,
|
|
38
42
|
ChatHistoryPayload,
|
|
43
|
+
DelayedAttributes,
|
|
39
44
|
DeleteMessagePayload,
|
|
40
45
|
EditMessagePayload,
|
|
41
46
|
ForwardLink,
|
|
@@ -60,6 +65,9 @@ if TYPE_CHECKING:
|
|
|
60
65
|
|
|
61
66
|
SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote
|
|
62
67
|
SendAttachments: TypeAlias = Sequence[SendAttachment] | None
|
|
68
|
+
DateTimeUnion: TypeAlias = datetime | timedelta | int
|
|
69
|
+
|
|
70
|
+
T = TypeVar("T")
|
|
63
71
|
|
|
64
72
|
logger = get_logger(__name__)
|
|
65
73
|
|
|
@@ -79,8 +87,12 @@ class MessageService:
|
|
|
79
87
|
|
|
80
88
|
async def _upload_attachments(
|
|
81
89
|
self, attachments: SendAttachments
|
|
82
|
-
) -> list[
|
|
83
|
-
|
|
90
|
+
) -> list[
|
|
91
|
+
AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
|
|
92
|
+
]: # TODO: TypeAlias
|
|
93
|
+
result: list[
|
|
94
|
+
AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
|
|
95
|
+
] = []
|
|
84
96
|
if not attachments:
|
|
85
97
|
return result
|
|
86
98
|
|
|
@@ -101,7 +113,7 @@ class MessageService:
|
|
|
101
113
|
|
|
102
114
|
result.append(upload_result)
|
|
103
115
|
|
|
104
|
-
elif isinstance(attachment, Video):
|
|
116
|
+
elif isinstance(attachment, (Video, VideoNote)):
|
|
105
117
|
upload_result = await self.app.api.uploads.upload_video(attachment)
|
|
106
118
|
if not upload_result:
|
|
107
119
|
logger.error("Video uploading failed")
|
|
@@ -122,6 +134,62 @@ class MessageService:
|
|
|
122
134
|
|
|
123
135
|
return result
|
|
124
136
|
|
|
137
|
+
def _convert_time(self, time: DateTimeUnion) -> int:
|
|
138
|
+
if isinstance(time, datetime):
|
|
139
|
+
return int(time.timestamp() * 1000)
|
|
140
|
+
|
|
141
|
+
if isinstance(time, timedelta):
|
|
142
|
+
return int((datetime.now() + time).timestamp() * 1000)
|
|
143
|
+
|
|
144
|
+
return time * 1000
|
|
145
|
+
|
|
146
|
+
async def _wait_for_upload_signal(
|
|
147
|
+
self,
|
|
148
|
+
waiters: dict[int, asyncio.Future[T]],
|
|
149
|
+
video_id: int,
|
|
150
|
+
) -> None:
|
|
151
|
+
loop = asyncio.get_running_loop()
|
|
152
|
+
future: asyncio.Future[T] = loop.create_future()
|
|
153
|
+
|
|
154
|
+
waiters[video_id] = future
|
|
155
|
+
try:
|
|
156
|
+
await asyncio.wait_for(future, timeout=60)
|
|
157
|
+
except TimeoutError:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"Timed out waiting for video processing notification video_id=%s",
|
|
160
|
+
video_id,
|
|
161
|
+
)
|
|
162
|
+
raise UploadError(f"Timed out waiting for video processing video_id={video_id}")
|
|
163
|
+
finally:
|
|
164
|
+
waiters.pop(video_id, None)
|
|
165
|
+
|
|
166
|
+
async def _process_attachment_error(
|
|
167
|
+
self,
|
|
168
|
+
attaches: list[
|
|
169
|
+
AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
|
|
170
|
+
],
|
|
171
|
+
):
|
|
172
|
+
attachment: VoiceAttachPayload | VideoNoteAttachPayload | None = None
|
|
173
|
+
for attach in attaches:
|
|
174
|
+
if isinstance(attach, (VoiceAttachPayload, VideoNoteAttachPayload)):
|
|
175
|
+
attachment = attach
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if attachment is None:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"Unexpected internal state: Non voice/video_note attach is not ready. "
|
|
181
|
+
+ "Please, report that issue"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if isinstance(attachment, VoiceAttachPayload):
|
|
185
|
+
await self._wait_for_upload_signal(
|
|
186
|
+
self.app.api.uploads.voice_upload_waiters, attachment.video_id
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
await self._wait_for_upload_signal(
|
|
190
|
+
self.app.api.uploads.video_upload_waiters, attachment.video_id
|
|
191
|
+
)
|
|
192
|
+
|
|
125
193
|
async def send_message(
|
|
126
194
|
self,
|
|
127
195
|
chat_id: int,
|
|
@@ -130,6 +198,7 @@ class MessageService:
|
|
|
130
198
|
attachments: SendAttachments = None,
|
|
131
199
|
*,
|
|
132
200
|
notify: bool = True,
|
|
201
|
+
send_at: DateTimeUnion | None = None,
|
|
133
202
|
) -> Message:
|
|
134
203
|
logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0)
|
|
135
204
|
|
|
@@ -142,19 +211,34 @@ class MessageService:
|
|
|
142
211
|
else:
|
|
143
212
|
clean_text, elements = None, []
|
|
144
213
|
|
|
214
|
+
attaches = await self._upload_attachments(attachments)
|
|
215
|
+
|
|
145
216
|
frame = SendMessagePayload(
|
|
146
217
|
chat_id=chat_id,
|
|
147
218
|
message=SendMessagePayloadMessage(
|
|
148
219
|
text=clean_text,
|
|
149
220
|
cid=self._next_cid(),
|
|
150
221
|
elements=elements,
|
|
151
|
-
attaches=
|
|
222
|
+
attaches=attaches,
|
|
152
223
|
link=ReplyLink(message_id=reply_to) if reply_to else None,
|
|
224
|
+
delayed_attributes=DelayedAttributes(
|
|
225
|
+
time_to_fire=self._convert_time(send_at),
|
|
226
|
+
notify_sender=notify,
|
|
227
|
+
)
|
|
228
|
+
if send_at
|
|
229
|
+
else None,
|
|
153
230
|
),
|
|
154
231
|
notify=notify,
|
|
155
232
|
)
|
|
156
233
|
|
|
157
|
-
|
|
234
|
+
try:
|
|
235
|
+
response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
|
|
236
|
+
except ApiError as e:
|
|
237
|
+
if e.error == "attachment.not.ready":
|
|
238
|
+
await self._process_attachment_error(attaches)
|
|
239
|
+
response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
|
|
240
|
+
else:
|
|
241
|
+
raise
|
|
158
242
|
|
|
159
243
|
message = bind_api_model(
|
|
160
244
|
self.app,
|
|
@@ -241,15 +325,25 @@ class MessageService:
|
|
|
241
325
|
else:
|
|
242
326
|
clean_text, elements = None, []
|
|
243
327
|
|
|
328
|
+
attaches = await self._upload_attachments(attachments)
|
|
329
|
+
|
|
244
330
|
frame = EditMessagePayload(
|
|
245
331
|
chat_id=chat_id,
|
|
246
332
|
message_id=message_id,
|
|
247
333
|
text=clean_text,
|
|
248
334
|
elements=elements,
|
|
249
|
-
attachments=
|
|
335
|
+
attachments=attaches,
|
|
250
336
|
)
|
|
337
|
+
try:
|
|
338
|
+
response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
|
|
339
|
+
except ApiError as e:
|
|
340
|
+
if e.error == "attachment.not.ready":
|
|
341
|
+
await self._process_attachment_error(attaches)
|
|
342
|
+
|
|
343
|
+
response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
|
|
344
|
+
else:
|
|
345
|
+
raise
|
|
251
346
|
|
|
252
|
-
response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
|
|
253
347
|
message = require_payload_item_model(
|
|
254
348
|
response,
|
|
255
349
|
MessagePayloadKey.MESSAGE,
|
|
@@ -385,7 +479,7 @@ class MessageService:
|
|
|
385
479
|
async def add_reaction(
|
|
386
480
|
self,
|
|
387
481
|
chat_id: int,
|
|
388
|
-
message_id:
|
|
482
|
+
message_id: int,
|
|
389
483
|
reaction: str,
|
|
390
484
|
) -> ReactionInfo | None:
|
|
391
485
|
logger.info(
|
|
@@ -410,7 +504,7 @@ class MessageService:
|
|
|
410
504
|
async def get_reactions(
|
|
411
505
|
self,
|
|
412
506
|
chat_id: int,
|
|
413
|
-
message_ids: list[
|
|
507
|
+
message_ids: list[int],
|
|
414
508
|
) -> dict[str, ReactionInfo] | None:
|
|
415
509
|
logger.info(
|
|
416
510
|
"getting reactions chat_id=%s message_ids=%s",
|
|
@@ -438,7 +532,7 @@ class MessageService:
|
|
|
438
532
|
async def remove_reaction(
|
|
439
533
|
self,
|
|
440
534
|
chat_id: int,
|
|
441
|
-
message_id:
|
|
535
|
+
message_id: int,
|
|
442
536
|
) -> ReactionInfo | None:
|
|
443
537
|
logger.info(
|
|
444
538
|
"removing reaction chat_id=%s message_id=%s",
|
pymax/api/uploads/payloads.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, SerializerFunctionWrapHandler, model_serializer
|
|
2
4
|
|
|
3
5
|
from pymax.api.models import CamelModel
|
|
4
6
|
from pymax.types import AttachmentType
|
|
@@ -18,6 +20,36 @@ class VideoAttachPayload(CamelModel):
|
|
|
18
20
|
duration: int | None = None
|
|
19
21
|
wave: bytes | None = None
|
|
20
22
|
|
|
23
|
+
@model_serializer(mode="wrap")
|
|
24
|
+
def serialize_attachment(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
|
25
|
+
payload = handler(self)
|
|
26
|
+
|
|
27
|
+
if self.type == AttachmentType.AUDIO:
|
|
28
|
+
payload.pop("videoId", None)
|
|
29
|
+
payload.pop("videoType", None)
|
|
30
|
+
if not self.token:
|
|
31
|
+
payload.pop("token", None)
|
|
32
|
+
payload["audioId"] = self.video_id
|
|
33
|
+
elif self.video_type == 1:
|
|
34
|
+
if self.token:
|
|
35
|
+
payload.pop("videoId", None)
|
|
36
|
+
else:
|
|
37
|
+
payload.pop("token", None)
|
|
38
|
+
payload["videoId"] = self.video_id
|
|
39
|
+
|
|
40
|
+
return payload
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VoiceAttachPayload(VideoAttachPayload):
|
|
44
|
+
type: AttachmentType = Field(default=AttachmentType.AUDIO, serialization_alias="_type")
|
|
45
|
+
video_id: int = Field(exclude=True)
|
|
46
|
+
video_type: int = Field(default=0, exclude=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VideoNoteAttachPayload(VideoAttachPayload):
|
|
50
|
+
video_id: int = Field(exclude=True)
|
|
51
|
+
video_type: int = 1
|
|
52
|
+
|
|
21
53
|
|
|
22
54
|
class AttachFilePayload(CamelModel):
|
|
23
55
|
type: AttachmentType = Field(default=AttachmentType.FILE, serialization_alias="_type")
|
pymax/api/uploads/service.py
CHANGED
|
@@ -15,7 +15,7 @@ from pymax.exceptions import UploadError
|
|
|
15
15
|
from pymax.files import File, Photo, Video, VideoNote, Voice
|
|
16
16
|
from pymax.logging import get_logger
|
|
17
17
|
from pymax.protocol import Opcode
|
|
18
|
-
from pymax.types import
|
|
18
|
+
from pymax.types import AudioUploadSignal
|
|
19
19
|
|
|
20
20
|
from .models import (
|
|
21
21
|
FileUploadResponse,
|
|
@@ -27,6 +27,8 @@ from .payloads import (
|
|
|
27
27
|
AttachPhotoPayload,
|
|
28
28
|
UploadPayload,
|
|
29
29
|
VideoAttachPayload,
|
|
30
|
+
VideoNoteAttachPayload,
|
|
31
|
+
VoiceAttachPayload,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
if TYPE_CHECKING:
|
|
@@ -182,7 +184,7 @@ class UploadService:
|
|
|
182
184
|
logger.debug("Photo upload complete photo_id=%s", photo_id)
|
|
183
185
|
return AttachPhotoPayload(photo_token=token)
|
|
184
186
|
|
|
185
|
-
async def upload_voice(self, voice: Voice) ->
|
|
187
|
+
async def upload_voice(self, voice: Voice) -> VoiceAttachPayload:
|
|
186
188
|
logger.info("Uploading voice")
|
|
187
189
|
|
|
188
190
|
payload = UploadPayload(
|
|
@@ -227,12 +229,23 @@ class UploadService:
|
|
|
227
229
|
logger.exception("Failed to get voice size")
|
|
228
230
|
raise UploadError("Failed to get voice size") from e
|
|
229
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
|
+
|
|
230
242
|
headers = {
|
|
231
243
|
"Content-Disposition": f"attachment; filename={quote(voice.name)}",
|
|
232
|
-
"Content-Range": f"0-{file_size - 1}/{file_size}",
|
|
244
|
+
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
233
245
|
"Content-Length": str(file_size),
|
|
234
246
|
"Connection": "keep-alive",
|
|
235
247
|
"Content-Type": "application/octet-stream",
|
|
248
|
+
"User-Agent": quote(user_agent),
|
|
236
249
|
}
|
|
237
250
|
|
|
238
251
|
logger.debug(
|
|
@@ -240,15 +253,11 @@ class UploadService:
|
|
|
240
253
|
headers["Content-Range"],
|
|
241
254
|
)
|
|
242
255
|
|
|
243
|
-
|
|
244
|
-
future: asyncio.Future[AudioUploadSignal] = loop.create_future()
|
|
245
|
-
|
|
246
|
-
timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
|
|
256
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
247
257
|
|
|
248
258
|
video_id = upload_info.video_id
|
|
249
259
|
token = upload_info.token
|
|
250
260
|
|
|
251
|
-
self.voice_upload_waiters[video_id] = future
|
|
252
261
|
logger.debug("Voice upload waiter registered voice_id=%s", video_id)
|
|
253
262
|
|
|
254
263
|
try:
|
|
@@ -279,24 +288,29 @@ class UploadService:
|
|
|
279
288
|
f"{response.status} voice_id={video_id}"
|
|
280
289
|
)
|
|
281
290
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
# )
|
|
296
307
|
|
|
297
308
|
logger.debug("Voice upload complete voice_id=%s", video_id)
|
|
298
|
-
return
|
|
299
|
-
|
|
309
|
+
return VoiceAttachPayload(
|
|
310
|
+
video_id=video_id,
|
|
311
|
+
token=token,
|
|
312
|
+
duration=await voice.get_duration(),
|
|
313
|
+
wave=b"\x00" * 80,
|
|
300
314
|
)
|
|
301
315
|
|
|
302
316
|
except UploadError:
|
|
@@ -314,7 +328,9 @@ class UploadService:
|
|
|
314
328
|
self.voice_upload_waiters.pop(video_id, None)
|
|
315
329
|
logger.debug("Voice upload waiter removed voice_id=%s", video_id)
|
|
316
330
|
|
|
317
|
-
async def upload_video(
|
|
331
|
+
async def upload_video(
|
|
332
|
+
self, uploadable_video: Video | VideoNote
|
|
333
|
+
) -> VideoAttachPayload | VideoNoteAttachPayload:
|
|
318
334
|
logger.info("Uploading video")
|
|
319
335
|
logger.debug("Preparing video upload payload")
|
|
320
336
|
|
|
@@ -366,10 +382,10 @@ class UploadService:
|
|
|
366
382
|
file_size,
|
|
367
383
|
)
|
|
368
384
|
|
|
369
|
-
timeout = aiohttp.ClientTimeout(total=
|
|
385
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
370
386
|
|
|
371
387
|
headers = {
|
|
372
|
-
"Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}",
|
|
388
|
+
"Content-Disposition": f"attachment; filename={quote(str(uploadable_video.name))}",
|
|
373
389
|
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
|
|
374
390
|
"Content-Length": str(file_size),
|
|
375
391
|
"Connection": "keep-alive",
|
|
@@ -427,10 +443,9 @@ class UploadService:
|
|
|
427
443
|
thumbhash += "=" * (-len(thumbhash) % 4)
|
|
428
444
|
thumbhash = base64.b64decode(thumbhash)
|
|
429
445
|
|
|
430
|
-
return
|
|
446
|
+
return VideoNoteAttachPayload(
|
|
431
447
|
video_id=video_id,
|
|
432
448
|
token=token,
|
|
433
|
-
video_type=1,
|
|
434
449
|
thumbhash=thumbhash,
|
|
435
450
|
duration=await uploadable_video.get_duration(),
|
|
436
451
|
)
|
|
@@ -532,51 +547,51 @@ class UploadService:
|
|
|
532
547
|
|
|
533
548
|
file_id = upload_info.file_id
|
|
534
549
|
|
|
550
|
+
timeout = aiohttp.ClientTimeout(total=self.app.config.upload_timeout, sock_read=60)
|
|
551
|
+
|
|
535
552
|
self.file_upload_waiters[file_id] = future
|
|
536
553
|
logger.debug("File upload waiter registered file_id=%s", file_id)
|
|
537
554
|
|
|
538
555
|
try:
|
|
539
|
-
async with
|
|
540
|
-
proxy=self.app.config.proxy,
|
|
541
|
-
|
|
542
|
-
async with session.post(
|
|
556
|
+
async with (
|
|
557
|
+
aiohttp.ClientSession(proxy=self.app.config.proxy, timeout=timeout) as session,
|
|
558
|
+
session.post(
|
|
543
559
|
url=upload_info.url,
|
|
544
560
|
headers=headers,
|
|
545
561
|
data=file.iter_chunks(1024 * 1024),
|
|
546
|
-
) as response
|
|
547
|
-
|
|
548
|
-
|
|
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",
|
|
549
573
|
response.status,
|
|
550
574
|
file_id,
|
|
551
575
|
)
|
|
576
|
+
raise UploadError(
|
|
577
|
+
f"File upload failed with status {response.status} file_id={file_id}"
|
|
578
|
+
)
|
|
552
579
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
"Waiting for file processing notification file_id=%s",
|
|
566
|
-
file_id,
|
|
567
|
-
)
|
|
568
|
-
await asyncio.wait_for(future, 60)
|
|
569
|
-
except asyncio.TimeoutError:
|
|
570
|
-
logger.warning(
|
|
571
|
-
"Timed out waiting for file processing notification file_id=%s",
|
|
572
|
-
file_id,
|
|
573
|
-
)
|
|
574
|
-
raise UploadError(
|
|
575
|
-
f"Timed out waiting for file processing file_id={file_id}"
|
|
576
|
-
)
|
|
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}")
|
|
577
592
|
|
|
578
|
-
|
|
579
|
-
|
|
593
|
+
logger.debug("File upload complete file_id=%s", file_id)
|
|
594
|
+
return AttachFilePayload(file_id=file_id)
|
|
580
595
|
|
|
581
596
|
except UploadError:
|
|
582
597
|
raise
|
pymax/app.py
CHANGED
|
@@ -12,8 +12,8 @@ from pymax.fingerprint import FingerprintGenerator
|
|
|
12
12
|
from pymax.logging import get_logger
|
|
13
13
|
from pymax.protocol import Command, InboundFrame, OutboundFrame
|
|
14
14
|
from pymax.protocol.enums import Opcode
|
|
15
|
-
from pymax.session import SessionStore
|
|
16
|
-
from pymax.session.models import SessionInfo
|
|
15
|
+
from pymax.session import InMemoryStore, SessionStore
|
|
16
|
+
from pymax.session.models import SessionInfo, resolve_session_user_agent
|
|
17
17
|
from pymax.telemetry import TelemetryService
|
|
18
18
|
from pymax.types import MaxApiError, Message
|
|
19
19
|
from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
|
|
@@ -38,9 +38,15 @@ class App(Generic[ClientT]):
|
|
|
38
38
|
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
|
|
39
39
|
self.api = ApiFacade(self)
|
|
40
40
|
self.config = config
|
|
41
|
-
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
|
+
)
|
|
42
46
|
self.auth_flow = auth_flow
|
|
43
|
-
self.fingerprint_generator =
|
|
47
|
+
self.fingerprint_generator: FingerprintGenerator | None = (
|
|
48
|
+
FingerprintGenerator(self.config.fingering) if self.config.fingering else None
|
|
49
|
+
)
|
|
44
50
|
|
|
45
51
|
self.me: Profile | None = None
|
|
46
52
|
self.chats: list[Chat] | None = None
|
|
@@ -77,6 +83,17 @@ class App(Generic[ClientT]):
|
|
|
77
83
|
else:
|
|
78
84
|
session_data.mt_instance_id = self.config.device.mt_instance_id
|
|
79
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
|
+
|
|
80
97
|
try:
|
|
81
98
|
logger.debug("opening connection")
|
|
82
99
|
await self.connection.open()
|
|
@@ -105,6 +122,7 @@ class App(Generic[ClientT]):
|
|
|
105
122
|
device_id=self.config.device.device_id,
|
|
106
123
|
phone=self.config.phone or "",
|
|
107
124
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
125
|
+
user_agent=self.config.device.user_agent,
|
|
108
126
|
)
|
|
109
127
|
)
|
|
110
128
|
else:
|
|
@@ -120,6 +138,7 @@ class App(Generic[ClientT]):
|
|
|
120
138
|
device_id=self.config.device.device_id,
|
|
121
139
|
phone=self.config.phone or "",
|
|
122
140
|
mt_instance_id=self.config.device.mt_instance_id,
|
|
141
|
+
user_agent=self.config.device.user_agent,
|
|
123
142
|
)
|
|
124
143
|
)
|
|
125
144
|
logger.info("new session saved")
|
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()
|
pymax/auth/sms.py
CHANGED
|
@@ -6,6 +6,7 @@ from pymax.auth.base import AuthFlow
|
|
|
6
6
|
from pymax.exceptions import ApiError
|
|
7
7
|
from pymax.logging import get_logger
|
|
8
8
|
|
|
9
|
+
from .exceptions import PasswordAttemptsExceededError
|
|
9
10
|
from .models import AuthResult
|
|
10
11
|
from .providers import (
|
|
11
12
|
ConsolePasswordProvider,
|
|
@@ -116,7 +117,10 @@ class SmsAuthFlow(AuthFlow):
|
|
|
116
117
|
hint: str | None,
|
|
117
118
|
) -> str:
|
|
118
119
|
logger.info("starting 2fa password authentication")
|
|
119
|
-
|
|
120
|
+
attempt = 0
|
|
121
|
+
while (
|
|
122
|
+
app.config.password_max_attempts is None or app.config.password_max_attempts > attempt
|
|
123
|
+
):
|
|
120
124
|
password = await self.password_provider.get_password(hint)
|
|
121
125
|
logger.debug(
|
|
122
126
|
"2fa password provider returned password_set=%s",
|
|
@@ -124,16 +128,19 @@ class SmsAuthFlow(AuthFlow):
|
|
|
124
128
|
)
|
|
125
129
|
if not password:
|
|
126
130
|
logger.warning("2fa password is empty; retrying")
|
|
131
|
+
attempt += 1
|
|
127
132
|
continue
|
|
128
133
|
|
|
129
134
|
try:
|
|
130
135
|
response = await app.api.auth.check_password(track_id, password)
|
|
131
136
|
except ApiError as e:
|
|
132
137
|
logger.error("2fa password check failed: %s", e)
|
|
138
|
+
attempt += 1
|
|
133
139
|
continue
|
|
134
140
|
|
|
135
141
|
if response.error:
|
|
136
142
|
logger.error("2fa password check failed error=%s", response.error)
|
|
143
|
+
attempt += 1
|
|
137
144
|
continue
|
|
138
145
|
|
|
139
146
|
if response.login_token:
|
|
@@ -141,3 +148,6 @@ class SmsAuthFlow(AuthFlow):
|
|
|
141
148
|
return response.login_token
|
|
142
149
|
|
|
143
150
|
logger.error("2fa password response did not contain login token; retrying")
|
|
151
|
+
attempt += 1
|
|
152
|
+
|
|
153
|
+
raise PasswordAttemptsExceededError
|