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
@@ -32,8 +32,8 @@ class VideoAttachment(CamelModel):
32
32
  :vartype video_id: int
33
33
  :ivar duration: Длительность видео.
34
34
  :vartype duration: int | None
35
- :ivar preview_data: Данные превью.
36
- :vartype preview_data: bytes
35
+ :ivar preview_data: Данные превью, если Max их прислал.
36
+ :vartype preview_data: bytes | None
37
37
  :ivar type: Тип вложения.
38
38
  :vartype type: Literal[AttachmentType.VIDEO]
39
39
  :ivar thumbnail: URL миниатюры.
@@ -48,7 +48,7 @@ class VideoAttachment(CamelModel):
48
48
  width: int
49
49
  video_id: int
50
50
  duration: int | None = None
51
- preview_data: bytes
51
+ preview_data: bytes | None = None
52
52
  type: Literal[AttachmentType.VIDEO] = Field(alias="_type")
53
53
  thumbnail: str
54
54
  token: str
@@ -62,18 +62,18 @@ class VideoRequest(CamelModel):
62
62
  :vartype external: str | bool | None
63
63
  :ivar cache: Использовать ли кеш.
64
64
  :vartype cache: bool
65
- :ivar url: URL видео.
66
- :vartype url: str
65
+ :ivar url: Прямой URL видео или ``None`` для внешнего видео.
66
+ :vartype url: str | None
67
67
  """
68
68
 
69
69
  external: str | bool | None = Field(default=None, alias="EXTERNAL")
70
- cache: bool
71
- url: str
70
+ cache: bool = False # TODO: idk maybe | None = None better
71
+ url: str | None = None
72
72
 
73
73
  @model_validator(mode="before")
74
74
  @classmethod
75
- def unwrap_dynamic_url(cls, value: Any) -> Any:
76
- """Нормализует динамический ключ URL в поле ``url``.
75
+ def select_video_url(cls, value: Any) -> Any:
76
+ """Выбирает прямой URL с максимальным доступным MP4-качеством.
77
77
 
78
78
  :param value: Значение, переданное в валидатор модели.
79
79
  :type value: Any
@@ -83,8 +83,29 @@ class VideoRequest(CamelModel):
83
83
  if not isinstance(value, dict) or "url" in value:
84
84
  return value
85
85
 
86
+ mp4_urls: list[tuple[int, str]] = []
86
87
  for key, url in value.items():
87
- if key not in ("EXTERNAL", "cache"):
88
- return {**value, "url": url}
88
+ if not isinstance(key, str) or not isinstance(url, str):
89
+ continue
90
+
91
+ normalized_key = key.upper()
92
+ if not normalized_key.startswith("MP4_"):
93
+ continue
94
+
95
+ try:
96
+ quality = int(normalized_key.removeprefix("MP4_"))
97
+ except ValueError:
98
+ continue
99
+
100
+ if quality > 0:
101
+ mp4_urls.append((quality, url))
102
+
103
+ if mp4_urls:
104
+ _, url = max(mp4_urls, key=lambda item: item[0])
105
+ return {**value, "url": url}
106
+
107
+ legacy_url = value.get("dynamicUrl", value.get("dynamic_url"))
108
+ if isinstance(legacy_url, str):
109
+ return {**value, "url": legacy_url}
89
110
 
90
111
  return value
@@ -156,25 +156,24 @@ class Chat(CamelModel):
156
156
 
157
157
  async def answer(
158
158
  self,
159
- text: str,
159
+ text: str | None = None,
160
160
  reply_to: int | None = None,
161
161
  attachments: SendAttachments = None,
162
162
  *,
163
163
  notify: bool = True,
164
- ) -> Message | None:
164
+ ) -> Message:
165
165
  """Отправляет сообщение в этот чат.
166
166
 
167
167
  :param text: Текст сообщения.
168
- :type text: str
168
+ :type text: str | None
169
169
  :param reply_to: ID сообщения для ответа.
170
170
  :type reply_to: int | None
171
171
  :param attachments: Файлы, фотографии или видео для отправки.
172
172
  :type attachments: SendAttachments
173
173
  :param notify: Отправить ли получателям push-уведомление.
174
174
  :type notify: bool
175
- :returns: Отправленное сообщение или ``None``, если сервер не вернул
176
- его.
177
- :rtype: Message | None
175
+ :returns: Отправленное сообщение.
176
+ :rtype: Message
178
177
  :raises RuntimeError: Если чат не привязан к клиенту.
179
178
  """
180
179
  actions, _ = self._bound()
@@ -198,7 +197,7 @@ class Chat(CamelModel):
198
197
  get_chat: bool = False,
199
198
  get_messages: bool = True,
200
199
  interactive: bool = False,
201
- ) -> list[Message] | None:
200
+ ) -> list[Message]:
202
201
  """Загружает историю сообщений этого чата.
203
202
 
204
203
  ``from_time``, ``backward_time`` и ``forward_time`` передаются в
@@ -224,8 +223,9 @@ class Chat(CamelModel):
224
223
  :type get_messages: bool
225
224
  :param interactive: Пометить запрос как интерактивный.
226
225
  :type interactive: bool
227
- :returns: Сообщения или ``None``, если сервер не вернул список.
228
- :rtype: list[Message] | None
226
+ :returns: Список сообщений. Если сервер не вернул сообщения, список
227
+ пуст.
228
+ :rtype: list[Message]
229
229
  :raises RuntimeError: Если чат не привязан к клиенту.
230
230
  """
231
231
  actions, _ = self._bound()
@@ -22,3 +22,10 @@ class MessageStatus(str, Enum):
22
22
 
23
23
  EDITED = "EDITED"
24
24
  REMOVED = "REMOVED"
25
+
26
+
27
+ class LinkType(str, Enum):
28
+ """Тип связи входящего сообщения с исходным сообщением."""
29
+
30
+ REPLY = "REPLY"
31
+ FORWARD = "FORWARD"
@@ -0,0 +1,16 @@
1
+ from pydantic import Field
2
+
3
+ from .base import CamelModel
4
+
5
+
6
+ class HandshakeResponse(CamelModel):
7
+ """Результат рукопожатия.
8
+
9
+ :ivar calls_seed: Сид для генерации хэшей вызовов.
10
+ :vartype calls_seed: int
11
+ """
12
+
13
+ calls_seed: int | None = None
14
+ app_update_type: int | None = Field(
15
+ alias="app-update-type", default=None
16
+ ) # very bizarre casing
@@ -13,14 +13,26 @@ class LoginConfig(CamelModel):
13
13
  hash: ConfigHash | None = None
14
14
 
15
15
 
16
+ class Login2Flags(CamelModel):
17
+ config_enabled: bool = False
18
+ contact_enabled: bool = False
19
+ profile_enabled: bool = False
20
+
21
+ @property
22
+ def enabled(self) -> bool:
23
+ return self.config_enabled or self.contact_enabled or self.profile_enabled
24
+
25
+
16
26
  class LoginResponse(CamelModel):
17
27
  chats: list[Chat] = Field(default_factory=list)
18
- profile: Profile
28
+ profile: Profile | None = None
19
29
  messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message]
20
30
  contacts: list[User | None] = Field(default_factory=list)
21
31
  token: str | None = None
22
32
  time: int | None = None
23
33
  config: LoginConfig | None = None
34
+ updates: int | None = None
35
+ login2_flags: Login2Flags | None = None
24
36
 
25
37
  def update_sync_state(self, current: SyncState) -> SyncState:
26
38
  sync_time = self.time
@@ -33,3 +45,20 @@ class LoginResponse(CamelModel):
33
45
  presence_sync=(sync_time if sync_time is not None else current.presence_sync),
34
46
  config_hash=(config_hash if config_hash is not None else current.config_hash),
35
47
  )
48
+
49
+
50
+ class Login2Response(CamelModel):
51
+ profile: Profile | None = None
52
+ contacts: list[User | None] = Field(default_factory=list, alias="contactInfos")
53
+ config: LoginConfig | None = None
54
+
55
+ def update_sync_state(self, current: SyncState) -> SyncState:
56
+ config_hash = self.config.hash if self.config is not None else None
57
+
58
+ return SyncState(
59
+ chats_sync=current.chats_sync,
60
+ contacts_sync=current.contacts_sync,
61
+ drafts_sync=current.drafts_sync,
62
+ presence_sync=current.presence_sync,
63
+ config_hash=(config_hash if config_hash is not None else current.config_hash),
64
+ )
@@ -1,11 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Sequence
4
- from typing import TYPE_CHECKING, Annotated, Any, TypeAlias
4
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias
5
5
 
6
6
  from pydantic import Field, PrivateAttr, model_validator
7
7
 
8
- from pymax.files import File, Photo, Video
8
+ from pymax.files import File, Photo, Video, VideoNote, Voice
9
9
  from pymax.types.domain import (
10
10
  AudioAttachment,
11
11
  CallAttachment,
@@ -14,6 +14,8 @@ from pymax.types.domain import (
14
14
  FileAttachment,
15
15
  InlineKeyboardAttachment,
16
16
  PhotoAttachment,
17
+ Poll,
18
+ PollAttachment,
17
19
  ShareAttachment,
18
20
  StickerAttachment,
19
21
  UnknownAttachment,
@@ -22,10 +24,10 @@ from pymax.types.domain import (
22
24
 
23
25
  from .base import CamelModel
24
26
  from .element import Element
25
- from .enums import MessageStatus
27
+ from .enums import AccessType, LinkType, MessageStatus
26
28
 
27
29
  if TYPE_CHECKING:
28
- from pymax.api.messages.service import MessageService
30
+ from pymax.api.messages import DateTimeUnion, MessageService
29
31
 
30
32
 
31
33
  KnownAttachment: TypeAlias = Annotated[
@@ -38,12 +40,14 @@ KnownAttachment: TypeAlias = Annotated[
38
40
  | ControlAttachment
39
41
  | InlineKeyboardAttachment
40
42
  | ShareAttachment
41
- | CallAttachment,
43
+ | CallAttachment
44
+ | PollAttachment,
42
45
  Field(discriminator="type"),
43
46
  ]
44
47
  Attachment: TypeAlias = KnownAttachment | UnknownAttachment
45
- SendAttachment: TypeAlias = Photo | File | Video
48
+ SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote
46
49
  SendAttachments: TypeAlias = Sequence[SendAttachment] | None
50
+ Link: TypeAlias = Annotated["ReplyLink | ForwardLink", Field(discriminator="type")]
47
51
 
48
52
 
49
53
  class ReactionCounter(CamelModel):
@@ -88,6 +92,66 @@ class ReadState(CamelModel):
88
92
  mark: int
89
93
 
90
94
 
95
+ class DelayedAttributes(CamelModel):
96
+ time_to_fire: int
97
+ notify_sender: bool
98
+ notify_opponents: bool
99
+
100
+
101
+ class ReplyLink(CamelModel):
102
+ """Источник сообщения, отправленного как reply.
103
+
104
+ :ivar message: Сообщение, на которое ответили.
105
+ :vartype message: Message
106
+ :ivar chat_id: ID чата исходного сообщения.
107
+ :vartype chat_id: int
108
+ """
109
+
110
+ type: Literal[LinkType.REPLY] = LinkType.REPLY
111
+ message: Message
112
+ chat_id: int
113
+
114
+
115
+ class ForwardLink(CamelModel):
116
+ """Источник пересланного сообщения.
117
+
118
+ Помимо исходного сообщения содержит доступные в payload-е имя, ссылку,
119
+ тип доступа и иконку исходного чата. Для скрытого источника эти метаданные
120
+ могут быть ``None``.
121
+
122
+ :ivar message: Исходное сообщение.
123
+ :vartype message: Message
124
+ :ivar chat_id: ID исходного чата.
125
+ :vartype chat_id: int
126
+ """
127
+
128
+ type: Literal[LinkType.FORWARD] = LinkType.FORWARD
129
+ message: Message
130
+ chat_id: int
131
+ chat_name: str | None = None
132
+ chat_link: str | None = None
133
+ chat_access_type: AccessType | None = None
134
+ chat_icon_url: str | None = None
135
+
136
+
137
+ """
138
+ "link": {
139
+ "type": "FORWARD",
140
+ "message": {
141
+ "id": "116641336752745888",
142
+ "time": 1779805553478,
143
+ "type": "CHANNEL",
144
+ "text": "Добро пожаловать на наше шоу!",
145
+ "attaches": []
146
+ },
147
+ "chatId": -75183661482741,
148
+ "chatName": "Max Bounty Test",
149
+ "chatLink": "https://max.ru/join/htlT5aTtlpAgcno_hc93lLM44Dxe45HvOEBqx22lKms",
150
+ "chatAccessType": "PRIVATE"
151
+ }
152
+ """
153
+
154
+
91
155
  class Message(CamelModel):
92
156
  """Сообщение Max с методами для действий над ним.
93
157
 
@@ -127,7 +191,8 @@ class Message(CamelModel):
127
191
  :vartype type: str
128
192
  :ivar cid: Клиентский ID сообщения, если он есть в payload-е.
129
193
  :vartype cid: int | None
130
- :ivar attaches: Вложения сообщения.
194
+ :ivar attaches: Вложения сообщения. Неизвестный ``_type`` представлен как
195
+ ``UnknownAttachment`` с сохраненными дополнительными полями.
131
196
  :vartype attaches: list[Attachment]
132
197
  :ivar stats: Дополнительная статистика сообщения от Max.
133
198
  :vartype stats: dict[str, Any] | None
@@ -147,6 +212,12 @@ class Message(CamelModel):
147
212
  :vartype mark: int | None
148
213
  :ivar elements: Форматированные элементы текста сообщения.
149
214
  :vartype elements: list[Element]
215
+ :ivar delayed_attributes: Время срабатывания в миллисекундах и настройки
216
+ уведомлений для отложенного сообщения; ``None`` для обычного.
217
+ :vartype delayed_attributes: DelayedAttributes | None
218
+ :ivar link: Исходное сообщение и чат для reply или forward; ``None`` для
219
+ обычного сообщения без связи.
220
+ :vartype link: ReplyLink | ForwardLink | None
150
221
  """
151
222
 
152
223
  id: int
@@ -166,6 +237,8 @@ class Message(CamelModel):
166
237
  unread: int | None = None
167
238
  mark: int | None = None
168
239
  elements: list[Element] = Field(default_factory=list)
240
+ delayed_attributes: DelayedAttributes | None = None
241
+ link: Link | None = None
169
242
 
170
243
  _actions: MessageService | None = PrivateAttr(default=None)
171
244
 
@@ -182,22 +255,25 @@ class Message(CamelModel):
182
255
 
183
256
  async def reply(
184
257
  self,
185
- text: str,
258
+ text: str | None = None,
186
259
  attachments: SendAttachments = None,
187
260
  *,
188
261
  notify: bool = True,
189
- ) -> Message | None:
262
+ send_at: DateTimeUnion | None = None,
263
+ ) -> Message:
190
264
  """Отправляет ответ на это сообщение в тот же чат.
191
265
 
192
266
  :param text: Текст сообщения.
193
- :type text: str
267
+ :type text: str | None
194
268
  :param attachments: Файлы, фотографии или видео для отправки.
195
269
  :type attachments: SendAttachments
196
270
  :param notify: Отправить ли получателям push-уведомление.
197
271
  :type notify: bool
198
- :returns: Отправленное сообщение или ``None``, если сервер не вернул
199
- его.
200
- :rtype: Message | None
272
+ :param send_at: Абсолютный ``datetime``, относительный ``timedelta``
273
+ или Unix time в секундах. ``None`` и ``0`` отправляют сразу.
274
+ :type send_at: DateTimeUnion | None
275
+ :returns: Отправленное сообщение.
276
+ :rtype: Message
201
277
  :raises RuntimeError: Если сообщение не привязано к сервису или не
202
278
  содержит ``chat_id``.
203
279
  """
@@ -209,29 +285,33 @@ class Message(CamelModel):
209
285
  reply_to=self.id,
210
286
  attachments=attachments,
211
287
  notify=notify,
288
+ send_at=send_at,
212
289
  )
213
290
 
214
291
  async def answer(
215
292
  self,
216
- text: str,
293
+ text: str | None = None,
217
294
  reply_to: int | None = None,
218
295
  attachments: SendAttachments = None,
219
296
  *,
220
297
  notify: bool = True,
221
- ) -> Message | None:
298
+ send_at: DateTimeUnion | None = None,
299
+ ) -> Message:
222
300
  """Отправляет сообщение в тот же чат.
223
301
 
224
302
  :param text: Текст сообщения.
225
- :type text: str
303
+ :type text: str | None
226
304
  :param reply_to: ID сообщения для ответа.
227
305
  :type reply_to: int | None
228
306
  :param attachments: Файлы, фотографии или видео для отправки.
229
307
  :type attachments: SendAttachments
230
308
  :param notify: Отправить ли получателям push-уведомление.
231
309
  :type notify: bool
232
- :returns: Отправленное сообщение или ``None``, если сервер не вернул
233
- его.
234
- :rtype: Message | None
310
+ :param send_at: Абсолютный ``datetime``, относительный ``timedelta``
311
+ или Unix time в секундах. ``None`` и ``0`` отправляют сразу.
312
+ :type send_at: DateTimeUnion | None
313
+ :returns: Отправленное сообщение.
314
+ :rtype: Message
235
315
  :raises RuntimeError: Если сообщение не привязано к сервису или не
236
316
  содержит ``chat_id``.
237
317
  """
@@ -243,6 +323,7 @@ class Message(CamelModel):
243
323
  reply_to=reply_to,
244
324
  attachments=attachments,
245
325
  notify=notify,
326
+ send_at=send_at,
246
327
  )
247
328
 
248
329
  async def forward(
@@ -250,15 +331,15 @@ class Message(CamelModel):
250
331
  chat_id: int,
251
332
  *,
252
333
  notify: bool = True,
253
- ) -> Message | None:
334
+ ) -> Message:
254
335
  """Пересылает это сообщение в другой чат.
255
336
 
256
337
  :param chat_id: ID целевого чата.
257
338
  :type chat_id: int
258
339
  :param notify: Отправить ли получателям push-уведомление.
259
340
  :type notify: bool
260
- :returns: Пересланное сообщение или ``None``, если сервер его не вернул.
261
- :rtype: Message | None
341
+ :returns: Пересланное сообщение.
342
+ :rtype: Message
262
343
  :raises RuntimeError: Если сообщение не привязано к сервису или не
263
344
  содержит ``chat_id``.
264
345
  """
@@ -291,13 +372,13 @@ class Message(CamelModel):
291
372
 
292
373
  async def edit(
293
374
  self,
294
- text: str,
375
+ text: str | None = None,
295
376
  attachments: SendAttachments = None,
296
377
  ) -> Message:
297
378
  """Редактирует текст и вложения этого сообщения.
298
379
 
299
380
  :param text: Новый текст сообщения с поддержкой markdown.
300
- :type text: str
381
+ :type text: str | None
301
382
  :param attachments: Новые файлы, фотографии или видео для сообщения.
302
383
  :type attachments: SendAttachments
303
384
  :returns: Отредактированное сообщение.
@@ -361,7 +442,7 @@ class Message(CamelModel):
361
442
 
362
443
  return await actions.add_reaction(
363
444
  chat_id=chat_id,
364
- message_id=str(self.id), # :C
445
+ message_id=self.id,
365
446
  reaction=reaction,
366
447
  )
367
448
 
@@ -377,7 +458,7 @@ class Message(CamelModel):
377
458
 
378
459
  return await actions.remove_reaction(
379
460
  chat_id=chat_id,
380
- message_id=str(self.id), # :C x2
461
+ message_id=self.id,
381
462
  )
382
463
 
383
464
  async def get_reactions(self) -> dict[str, ReactionInfo] | None:
@@ -393,7 +474,7 @@ class Message(CamelModel):
393
474
 
394
475
  return await actions.get_reactions(
395
476
  chat_id=chat_id,
396
- message_ids=[str(self.id)], # :C x3
477
+ message_ids=[self.id],
397
478
  )
398
479
 
399
480
  def _bound(self) -> tuple[MessageService, int]:
@@ -71,10 +71,8 @@ class User(CamelModel):
71
71
  phone: int | None = None
72
72
  status: str | None = None
73
73
  description: str | None = None
74
- # Bots may send ``gender`` as a numeric code and ``web_app`` as a URL
75
- # string instead of an object; accept these so profile parsing won't fail.
76
74
  gender: str | int | None = None
77
- link: str | None = None
75
+ link: str | int | None = None
78
76
  web_app: dict[str, Any] | str | None = None
79
77
  menu_button: dict[str, Any] | None = None
80
78
 
@@ -5,3 +5,4 @@ from .presence import PresenceEvent
5
5
  from .reaction import ReactionUpdateEvent
6
6
  from .typing import TypingEvent
7
7
  from .video import VideoUploadSignal
8
+ from .voice import AudioUploadSignal
@@ -17,5 +17,5 @@ class ReactionUpdateEvent(CamelModel):
17
17
 
18
18
  message_id: str
19
19
  chat_id: int
20
- counters: list[ReactionCounter]
21
- total_count: int
20
+ counters: list[ReactionCounter] | None = None
21
+ total_count: int = 0
@@ -0,0 +1,5 @@
1
+ from pymax.types.domain.base import CamelModel
2
+
3
+
4
+ class AudioUploadSignal(CamelModel):
5
+ audio_id: int
@@ -0,0 +1,2 @@
1
+ from .catalog import VersionCatalog
2
+ from .exceptions import VersionNotFoundError
@@ -0,0 +1,104 @@
1
+ import json
2
+ from importlib import resources
3
+ from typing import Any
4
+
5
+ import aiohttp
6
+
7
+ from pymax.fingerprint.models import ApkBuildFingerprint
8
+
9
+ from .exceptions import VersionAlreadyExistsError, VersionNotFoundError
10
+
11
+
12
+ class VersionCatalog:
13
+ """Каталог fingerprints Android-клиента Max.
14
+
15
+ Встроенные версии загружаются при создании объекта. При ``remote=True``
16
+ вызов :meth:`load` дополнительно получает каталог с
17
+ ``DEFAULT_REMOTE_URL`` и объединяет его со встроенным. Ошибки сети, HTTP и
18
+ валидации удаленного ответа не подавляются.
19
+
20
+ Args:
21
+ remote: Загружать ли удаленные версии при :meth:`load`.
22
+ """
23
+
24
+ RECOMMENDED_APP_VERSION: str = "26.25.0"
25
+ DEFAULT_REMOTE_URL: str = "https://hashes.pymax.org/versions.json"
26
+
27
+ def __init__(self, remote: bool = False) -> None:
28
+ self.path = resources.files("pymax._data") / "apk_fingerprints.json"
29
+ self.remote = remote
30
+ self._data = self._load_fingerprints()
31
+ self.versions = self._convert_data()
32
+
33
+ @staticmethod
34
+ def recommended() -> str:
35
+ """Возвращает версию, используемую ``Client`` по умолчанию."""
36
+ return VersionCatalog.RECOMMENDED_APP_VERSION
37
+
38
+ async def load(self) -> "VersionCatalog":
39
+ """Загружает удаленные версии, если включен ``remote``.
40
+
41
+ Returns:
42
+ Этот же каталог с добавленными удаленными версиями.
43
+ """
44
+ if not self.remote:
45
+ return self
46
+
47
+ versions = await self._fetch()
48
+ self.versions.update(versions)
49
+
50
+ return self
51
+
52
+ def add(self, version: str, fingerprint: ApkBuildFingerprint, override: bool = False) -> None:
53
+ """Добавляет fingerprint версии в текущий каталог.
54
+
55
+ Args:
56
+ version: Строка версии Android-клиента.
57
+ fingerprint: Fingerprint и build number этой версии.
58
+ override: Заменить существующую запись. По умолчанию ``False``.
59
+
60
+ Raises:
61
+ ValueError: Если версия уже существует и ``override=False``.
62
+ """
63
+ if self.versions.get(version) is not None and override is False:
64
+ raise VersionAlreadyExistsError(version)
65
+
66
+ self.versions[version] = fingerprint
67
+
68
+ def resolve(self, version: str) -> ApkBuildFingerprint:
69
+ """Возвращает fingerprint версии.
70
+
71
+ Raises:
72
+ VersionNotFoundError: Если версии нет в каталоге.
73
+ """
74
+ try:
75
+ return self.versions[version]
76
+ except KeyError as e:
77
+ raise VersionNotFoundError(version) from e
78
+
79
+ async def _fetch(self) -> dict[str, ApkBuildFingerprint]:
80
+ async with (
81
+ aiohttp.ClientSession() as session,
82
+ session.get(self.DEFAULT_REMOTE_URL) as response,
83
+ ):
84
+ response.raise_for_status()
85
+ data = await response.json()
86
+
87
+ if not isinstance(data, dict):
88
+ raise ValueError # TODO: msg
89
+
90
+ versions: dict[str, ApkBuildFingerprint] = {}
91
+ for k, v in data.items():
92
+ versions[k] = ApkBuildFingerprint.model_validate(v)
93
+
94
+ return versions
95
+
96
+ def _load_fingerprints(self) -> dict[str, Any]:
97
+ with self.path.open("r", encoding="utf-8") as f:
98
+ return json.load(f)
99
+
100
+ def _convert_data(self) -> dict[str, ApkBuildFingerprint]:
101
+ versions: dict[str, ApkBuildFingerprint] = {}
102
+ for k, v in self._data.items():
103
+ versions[k] = ApkBuildFingerprint.model_validate(v)
104
+ return versions
@@ -0,0 +1,10 @@
1
+ class VersionNotFoundError(ValueError):
2
+ """Выбранная версия приложения отсутствует в каталоге fingerprints."""
3
+
4
+ def __init__(self, version: str) -> None:
5
+ super().__init__(f"Could not find version {version} in registry")
6
+
7
+
8
+ class VersionAlreadyExistsError(ValueError):
9
+ def __init__(self, version: str) -> None:
10
+ super().__init__(f"Version {version} already exists in registry")