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.
Files changed (79) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/METADATA +8 -4
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/RECORD +79 -66
  3. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +9 -2
  5. pymax/_data/apk_fingerprints.json +767 -0
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/enums.py +1 -0
  8. pymax/api/auth/payloads.py +28 -2
  9. pymax/api/auth/service.py +100 -3
  10. pymax/api/chats/__init__.py +1 -0
  11. pymax/api/chats/enums.py +15 -0
  12. pymax/api/chats/payloads.py +18 -2
  13. pymax/api/chats/service.py +43 -1
  14. pymax/api/messages/__init__.py +1 -1
  15. pymax/api/messages/payloads.py +21 -7
  16. pymax/api/messages/service.py +161 -24
  17. pymax/api/self/__init__.py +2 -1
  18. pymax/api/self/enums.py +9 -0
  19. pymax/api/self/payloads.py +40 -1
  20. pymax/api/self/service.py +37 -0
  21. pymax/api/session/service.py +21 -8
  22. pymax/api/uploads/payloads.py +39 -1
  23. pymax/api/uploads/service.py +247 -55
  24. pymax/app.py +91 -18
  25. pymax/auth/__init__.py +2 -0
  26. pymax/auth/exceptions.py +9 -0
  27. pymax/auth/providers.py +6 -2
  28. pymax/auth/sms.py +11 -1
  29. pymax/base.py +90 -8
  30. pymax/client.py +39 -10
  31. pymax/client_web.py +17 -14
  32. pymax/config.py +98 -19
  33. pymax/connection/connection.py +29 -18
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +3 -1
  36. pymax/dispatch/resolvers.py +7 -1
  37. pymax/files/__init__.py +4 -1
  38. pymax/files/base.py +58 -1
  39. pymax/files/video.py +51 -1
  40. pymax/files/voice.py +53 -0
  41. pymax/fingerprint/__init__.py +1 -0
  42. pymax/fingerprint/fingerprint.py +30 -0
  43. pymax/fingerprint/models.py +12 -0
  44. pymax/infra/auth.py +4 -0
  45. pymax/infra/bots.py +3 -3
  46. pymax/infra/chat.py +47 -5
  47. pymax/infra/message.py +49 -14
  48. pymax/infra/protocol.py +5 -1
  49. pymax/infra/self.py +25 -1
  50. pymax/protocol/enums.py +13 -0
  51. pymax/protocol/tcp/payload.py +21 -1
  52. pymax/session/__init__.py +1 -1
  53. pymax/session/models.py +30 -0
  54. pymax/session/protocol.py +33 -7
  55. pymax/session/store.py +73 -5
  56. pymax/transport/tcp.py +17 -4
  57. pymax/transport/websocket.py +6 -3
  58. pymax/types/domain/__init__.py +2 -1
  59. pymax/types/domain/attachments/__init__.py +2 -1
  60. pymax/types/domain/attachments/call.py +7 -1
  61. pymax/types/domain/attachments/contact.py +3 -3
  62. pymax/types/domain/attachments/control.py +3 -0
  63. pymax/types/domain/attachments/enums.py +33 -1
  64. pymax/types/domain/attachments/poll.py +111 -0
  65. pymax/types/domain/attachments/sticker.py +1 -1
  66. pymax/types/domain/attachments/video.py +32 -11
  67. pymax/types/domain/chat.py +9 -9
  68. pymax/types/domain/enums.py +7 -0
  69. pymax/types/domain/handshake.py +16 -0
  70. pymax/types/domain/login.py +30 -1
  71. pymax/types/domain/message.py +108 -27
  72. pymax/types/domain/user.py +1 -3
  73. pymax/types/events/__init__.py +1 -0
  74. pymax/types/events/reaction.py +2 -2
  75. pymax/types/events/voice.py +5 -0
  76. pymax/versions/__init__.py +2 -0
  77. pymax/versions/catalog.py +104 -0
  78. pymax/versions/exceptions.py +10 -0
  79. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -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 typing import TYPE_CHECKING, TypeAlias
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,15 +18,19 @@ 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
21
- from pymax.files import File, Photo, Video
24
+ from pymax.exceptions import ApiError, UploadError
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
24
28
  from pymax.protocol import Opcode
25
29
  from pymax.types.domain import (
26
30
  FileRequest,
27
31
  Message,
32
+ Poll,
33
+ PollState,
28
34
  ReactionInfo,
29
35
  ReadState,
30
36
  VideoRequest,
@@ -34,6 +40,7 @@ from .enums import ItemType, MessagePayloadKey, ReadAction
34
40
  from .payloads import (
35
41
  AddReactionPayload,
36
42
  ChatHistoryPayload,
43
+ DelayedAttributes,
37
44
  DeleteMessagePayload,
38
45
  EditMessagePayload,
39
46
  ForwardLink,
@@ -50,13 +57,17 @@ from .payloads import (
50
57
  ReplyLink,
51
58
  SendMessagePayload,
52
59
  SendMessagePayloadMessage,
60
+ VotePollPayload,
53
61
  )
54
62
 
55
63
  if TYPE_CHECKING:
56
64
  from pymax.app import App
57
65
 
58
- SendAttachment: TypeAlias = Photo | File | Video
66
+ SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote
59
67
  SendAttachments: TypeAlias = Sequence[SendAttachment] | None
68
+ DateTimeUnion: TypeAlias = datetime | timedelta | int
69
+
70
+ T = TypeVar("T")
60
71
 
61
72
  logger = get_logger(__name__)
62
73
 
@@ -76,13 +87,25 @@ class MessageService:
76
87
 
77
88
  async def _upload_attachments(
78
89
  self, attachments: SendAttachments
79
- ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]:
80
- result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = []
90
+ ) -> list[
91
+ AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
92
+ ]: # TODO: TypeAlias
93
+ result: list[
94
+ AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | VoiceAttachPayload | Poll
95
+ ] = []
81
96
  if not attachments:
82
97
  return result
83
98
 
84
99
  for attachment in attachments:
85
- if isinstance(attachment, Photo):
100
+ if isinstance(attachment, Voice):
101
+ upload_result = await self.app.api.uploads.upload_voice(attachment)
102
+ if not upload_result:
103
+ logger.error("Voice uploading failed")
104
+ raise UploadError("Voice uploading failed")
105
+
106
+ result.append(upload_result)
107
+
108
+ elif isinstance(attachment, Photo):
86
109
  upload_result = await self.app.api.uploads.upload_photo(attachment)
87
110
  if not upload_result:
88
111
  logger.error("Photo uploading failed")
@@ -90,7 +113,7 @@ class MessageService:
90
113
 
91
114
  result.append(upload_result)
92
115
 
93
- elif isinstance(attachment, Video):
116
+ elif isinstance(attachment, (Video, VideoNote)):
94
117
  upload_result = await self.app.api.uploads.upload_video(attachment)
95
118
  if not upload_result:
96
119
  logger.error("Video uploading failed")
@@ -106,20 +129,89 @@ class MessageService:
106
129
 
107
130
  result.append(upload_result)
108
131
 
132
+ elif isinstance(attachment, Poll):
133
+ result.append(attachment)
134
+
109
135
  return result
110
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
+
111
193
  async def send_message(
112
194
  self,
113
195
  chat_id: int,
114
- text: str,
196
+ text: str | None = None,
115
197
  reply_to: int | None = None,
116
198
  attachments: SendAttachments = None,
117
199
  *,
118
200
  notify: bool = True,
119
- ) -> Message | None:
120
- logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text))
201
+ send_at: DateTimeUnion | None = None,
202
+ ) -> Message:
203
+ logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0)
204
+
205
+ if not text and not attachments:
206
+ logger.error("send_message failed: no text or attachments provided")
207
+ raise ValueError("Either text or attachments must be provided")
208
+
209
+ if text:
210
+ clean_text, elements = Formatter.format_markdown(text)
211
+ else:
212
+ clean_text, elements = None, []
121
213
 
122
- clean_text, elements = Formatter.format_markdown(text)
214
+ attaches = await self._upload_attachments(attachments)
123
215
 
124
216
  frame = SendMessagePayload(
125
217
  chat_id=chat_id,
@@ -127,13 +219,26 @@ class MessageService:
127
219
  text=clean_text,
128
220
  cid=self._next_cid(),
129
221
  elements=elements,
130
- attaches=await self._upload_attachments(attachments),
222
+ attaches=attaches,
131
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,
132
230
  ),
133
231
  notify=notify,
134
232
  )
135
233
 
136
- response = await self.app.invoke(Opcode.MSG_SEND, frame.to_payload())
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
137
242
 
138
243
  message = bind_api_model(
139
244
  self.app,
@@ -149,7 +254,7 @@ class MessageService:
149
254
  source_chat_id: int | None = None,
150
255
  *,
151
256
  notify: bool = True,
152
- ) -> Message | None:
257
+ ) -> Message:
153
258
  source_chat_id = chat_id if source_chat_id is None else source_chat_id
154
259
  logger.info(
155
260
  "forwarding message source_chat_id=%s chat_id=%s message_id=%s",
@@ -208,19 +313,37 @@ class MessageService:
208
313
  self,
209
314
  chat_id: int,
210
315
  message_id: int,
211
- text: str,
316
+ text: str | None = None,
212
317
  attachments: SendAttachments = None,
213
318
  ) -> Message:
214
- clean_text, elements = Formatter.format_markdown(text)
319
+ if not text and not attachments:
320
+ logger.error("edit_message failed: no text or attachments provided")
321
+ raise ValueError("Either text or attachments must be provided")
322
+
323
+ if text:
324
+ clean_text, elements = Formatter.format_markdown(text)
325
+ else:
326
+ clean_text, elements = None, []
327
+
328
+ attaches = await self._upload_attachments(attachments)
329
+
215
330
  frame = EditMessagePayload(
216
331
  chat_id=chat_id,
217
332
  message_id=message_id,
218
333
  text=clean_text,
219
334
  elements=elements,
220
- attachments=await self._upload_attachments(attachments),
335
+ attachments=attaches,
221
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
222
346
 
223
- response = await self.app.invoke(Opcode.MSG_EDIT, frame.to_payload())
224
347
  message = require_payload_item_model(
225
348
  response,
226
349
  MessagePayloadKey.MESSAGE,
@@ -243,7 +366,7 @@ class MessageService:
243
366
  get_chat: bool = False,
244
367
  get_messages: bool = True,
245
368
  interactive: bool = False,
246
- ) -> list[Message] | None:
369
+ ) -> list[Message]:
247
370
  frame = ChatHistoryPayload(
248
371
  chat_id=chat_id,
249
372
  forward=forward,
@@ -265,7 +388,7 @@ class MessageService:
265
388
  self.app,
266
389
  parse_payload_list(response, MessagePayloadKey.MESSAGES, Message),
267
390
  )
268
- return messages or None
391
+ return messages
269
392
 
270
393
  async def delete_message(
271
394
  self,
@@ -356,7 +479,7 @@ class MessageService:
356
479
  async def add_reaction(
357
480
  self,
358
481
  chat_id: int,
359
- message_id: str,
482
+ message_id: int,
360
483
  reaction: str,
361
484
  ) -> ReactionInfo | None:
362
485
  logger.info(
@@ -381,7 +504,7 @@ class MessageService:
381
504
  async def get_reactions(
382
505
  self,
383
506
  chat_id: int,
384
- message_ids: list[str],
507
+ message_ids: list[int],
385
508
  ) -> dict[str, ReactionInfo] | None:
386
509
  logger.info(
387
510
  "getting reactions chat_id=%s message_ids=%s",
@@ -409,7 +532,7 @@ class MessageService:
409
532
  async def remove_reaction(
410
533
  self,
411
534
  chat_id: int,
412
- message_id: str,
535
+ message_id: int,
413
536
  ) -> ReactionInfo | None:
414
537
  logger.info(
415
538
  "removing reaction chat_id=%s message_id=%s",
@@ -443,3 +566,17 @@ class MessageService:
443
566
 
444
567
  response = await self.app.invoke(Opcode.CHAT_MARK, frame.to_payload())
445
568
  return require_payload_model(response, ReadState)
569
+
570
+ async def vote_poll(
571
+ self,
572
+ chat_id: int,
573
+ message_id: int,
574
+ poll_id: int,
575
+ answer_ids: list[int],
576
+ ) -> PollState:
577
+ frame = VotePollPayload(
578
+ chat_id=chat_id, message_id=message_id, poll_id=poll_id, answers_ids=answer_ids
579
+ )
580
+ response = await self.app.invoke(Opcode.SEND_VOTE, frame.to_payload())
581
+
582
+ return require_payload_item_model(response, "state", PollState)
@@ -1,2 +1,3 @@
1
- from .enums import AvatarType, SelfPayloadKey
1
+ from .enums import AvatarType, PrivacyAccess, SelfPayloadKey
2
+ from .payloads import PrivacySettingsUpdate
2
3
  from .service import SelfService
pymax/api/self/enums.py CHANGED
@@ -9,3 +9,12 @@ class SelfPayloadKey(str, Enum):
9
9
  PROFILE = "profile"
10
10
  URL = "url"
11
11
  TOKEN = "token"
12
+ HASH = "hash"
13
+
14
+
15
+ class PrivacyAccess(str, Enum):
16
+ """Уровень доступа к данным и действиям аккаунта."""
17
+
18
+ ALL = "ALL"
19
+ CONTACTS = "CONTACTS"
20
+ NOBODY = "_NONE_"
@@ -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
@@ -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
- ) -> None:
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
- ) -> None:
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
- async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str) -> None:
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
+ )
@@ -1,4 +1,6 @@
1
- from pydantic import Field
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
@@ -13,6 +15,40 @@ class VideoAttachPayload(CamelModel):
13
15
  type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
14
16
  video_id: int
15
17
  token: str
18
+ video_type: int = 0
19
+ thumbhash: bytes | None = None
20
+ duration: int | None = None
21
+ wave: bytes | None = None
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
16
52
 
17
53
 
18
54
  class AttachFilePayload(CamelModel):
@@ -22,4 +58,6 @@ class AttachFilePayload(CamelModel):
22
58
 
23
59
  class UploadPayload(CamelModel):
24
60
  count: int = 1
61
+ type: int = 0
62
+ uploader_type: int = 0
25
63
  profile: bool = False