maxapi-python 1.2.2__py3-none-any.whl → 1.2.4__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.
pymax/interfaces.py CHANGED
@@ -1,127 +1,35 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import json
3
4
  import logging
4
- import socket
5
- import ssl
5
+ import time
6
6
  import traceback
7
- from abc import ABC, abstractmethod
7
+ from abc import abstractmethod
8
8
  from collections.abc import Awaitable, Callable
9
- from logging import Logger
10
- from typing import TYPE_CHECKING, Any, Literal
9
+ from typing import Any
11
10
 
12
11
  from typing_extensions import Self
13
12
 
14
13
  from pymax.exceptions import WebSocketNotConnectedError
14
+ from pymax.filters import BaseFilter
15
15
  from pymax.formatter import ColoredFormatter
16
-
17
- from .payloads import UserAgentPayload
18
- from .static.constant import DEFAULT_TIMEOUT
19
- from .static.enum import Opcode
20
- from .types import Channel, Chat, Dialog, Me, Message, User
21
-
22
- if TYPE_CHECKING:
23
- from pathlib import Path
24
- from uuid import UUID
25
-
26
- import websockets
27
-
28
- from pymax import AttachType
29
- from pymax.types import ReactionInfo
30
-
31
- from .crud import Database
32
- from .filters import BaseFilter
33
-
34
-
35
- class ClientProtocol(ABC):
36
- def __init__(self, logger: Logger) -> None:
37
- super().__init__()
38
- self.logger = logger
39
- self._users: dict[int, User] = {}
40
- self.chats: list[Chat] = []
41
- self._database: Database
42
- self._device_id: UUID
43
- self.uri: str
44
- self.is_connected: bool = False
45
- self.phone: str
46
- self.dialogs: list[Dialog] = []
47
- self.channels: list[Channel] = []
48
- self.me: Me | None = None
49
- self.host: str
50
- self.port: int
51
- self.proxy: str | Literal[True] | None
52
- self.registration: bool
53
- self.first_name: str
54
- self.last_name: str | None
55
- self._token: str | None
56
- self._work_dir: str
57
- self.reconnect: bool
58
- self._database_path: Path
59
- self._ws: websockets.ClientConnection | None = None
60
- self._seq: int = 0
61
- self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
62
- self._recv_task: asyncio.Task[Any] | None = None
63
- self._incoming: asyncio.Queue[dict[str, Any]] | None = None
64
- self._file_upload_waiters: dict[
65
- int,
66
- asyncio.Future[dict[str, Any]],
67
- ] = {}
68
- self.user_agent = UserAgentPayload()
69
- self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
70
- self._outgoing_task: asyncio.Task[Any] | None = None
71
- self._error_count: int = 0
72
- self._circuit_breaker: bool = False
73
- self._last_error_time: float = 0.0
74
- self._session_id: int
75
- self._action_id: int = 0
76
- self._current_screen: str = "chats_list_tab"
77
- self._on_message_handlers: list[
78
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
79
- ] = []
80
- self._on_message_edit_handlers: list[
81
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
82
- ] = []
83
- self._on_message_delete_handlers: list[
84
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
85
- ] = []
86
- self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
87
- self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
88
- self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
89
- self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
90
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
91
- self._background_tasks: set[asyncio.Task[Any]] = set()
92
- self._ssl_context: ssl.SSLContext
93
- self._socket: socket.socket | None = None
94
-
95
- @abstractmethod
96
- async def _send_and_wait(
97
- self,
98
- opcode: Opcode,
99
- payload: dict[str, Any],
100
- cmd: int = 0,
101
- timeout: float = DEFAULT_TIMEOUT,
102
- ) -> dict[str, Any]:
103
- pass
104
-
105
- @abstractmethod
106
- async def _get_chat(self, chat_id: int) -> Chat | None:
107
- pass
108
-
109
- @abstractmethod
110
- async def _queue_message(
111
- self,
112
- opcode: int,
113
- payload: dict[str, Any],
114
- cmd: int = 0,
115
- timeout: float = DEFAULT_TIMEOUT,
116
- max_retries: int = 3,
117
- ) -> Message | None:
118
- pass
119
-
120
- @abstractmethod
121
- def _create_safe_task(
122
- self, coro: Awaitable[Any], name: str | None = None
123
- ) -> asyncio.Task[Any]:
124
- pass
16
+ from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
+ from pymax.protocols import ClientProtocol
18
+ from pymax.static.constant import DEFAULT_PING_INTERVAL, DEFAULT_TIMEOUT
19
+ from pymax.static.enum import Opcode
20
+ from pymax.types import (
21
+ Channel,
22
+ Chat,
23
+ ChatType,
24
+ Dialog,
25
+ Me,
26
+ Message,
27
+ MessageStatus,
28
+ ReactionCounter,
29
+ ReactionInfo,
30
+ User,
31
+ )
32
+ from pymax.utils import MixinsUtils
125
33
 
126
34
 
127
35
  class BaseClient(ClientProtocol):
@@ -254,3 +162,391 @@ class BaseClient(ClientProtocol):
254
162
  @abstractmethod
255
163
  async def close(self) -> None:
256
164
  pass
165
+
166
+
167
+ class BaseTransport(ClientProtocol):
168
+ @abstractmethod
169
+ async def connect(
170
+ self, user_agent: UserAgentPayload | None = None
171
+ ) -> dict[str, Any] | None: ...
172
+
173
+ @abstractmethod
174
+ async def _send_and_wait(
175
+ self,
176
+ opcode: Opcode,
177
+ payload: dict[str, Any],
178
+ cmd: int = 0,
179
+ timeout: float = DEFAULT_TIMEOUT,
180
+ ) -> dict[str, Any]: ...
181
+
182
+ @abstractmethod
183
+ async def _recv_loop(self) -> None: ...
184
+
185
+ def _make_message(
186
+ self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0
187
+ ) -> dict[str, Any]:
188
+ self._seq += 1
189
+
190
+ msg = BaseWebSocketMessage(
191
+ cmd=cmd,
192
+ seq=self._seq,
193
+ opcode=opcode.value,
194
+ payload=payload,
195
+ ).model_dump(by_alias=True)
196
+
197
+ self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
198
+ return msg
199
+
200
+ async def _send_interactive_ping(self) -> None:
201
+ while self.is_connected:
202
+ try:
203
+ await self._send_and_wait(
204
+ opcode=Opcode.PING,
205
+ payload={"interactive": True},
206
+ cmd=0,
207
+ )
208
+ self.logger.debug("Interactive ping sent successfully")
209
+ except Exception:
210
+ self.logger.warning("Interactive ping failed", exc_info=True)
211
+ await asyncio.sleep(DEFAULT_PING_INTERVAL)
212
+
213
+ async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]:
214
+ self.logger.debug(
215
+ "Sending handshake with user_agent keys=%s",
216
+ user_agent.model_dump(by_alias=True).keys(),
217
+ )
218
+
219
+ user_agent_json = user_agent.model_dump(by_alias=True)
220
+ resp = await self._send_and_wait(
221
+ opcode=Opcode.SESSION_INIT,
222
+ payload={"deviceId": str(self._device_id), "userAgent": user_agent_json},
223
+ )
224
+
225
+ if resp.get("payload", {}).get("error"):
226
+ MixinsUtils.handle_error(resp)
227
+
228
+ self.logger.info("Handshake completed")
229
+ return resp
230
+
231
+ async def _process_message_handler(
232
+ self,
233
+ handler: Callable[[Message], Any],
234
+ filter: BaseFilter[Message] | None,
235
+ message: Message,
236
+ ):
237
+ result = None
238
+ if filter:
239
+ if filter(message):
240
+ result = handler(message)
241
+ else:
242
+ return
243
+ else:
244
+ result = handler(message)
245
+ if asyncio.iscoroutine(result):
246
+ self._create_safe_task(result, name=f"handler-{handler.__name__}")
247
+
248
+ def _parse_json(self, raw: Any) -> dict[str, Any] | None:
249
+ try:
250
+ return json.loads(raw)
251
+ except Exception:
252
+ self.logger.warning("JSON parse error", exc_info=True)
253
+ return None
254
+
255
+ def _handle_pending(self, seq: int | None, data: dict) -> bool:
256
+ if isinstance(seq, int):
257
+ fut = self._pending.get(seq)
258
+ if fut and not fut.done():
259
+ fut.set_result(data)
260
+ self.logger.debug("Matched response for pending seq=%s", seq)
261
+ return True
262
+ return False
263
+
264
+ async def _handle_incoming_queue(self, data: dict[str, Any]) -> None:
265
+ if self._incoming:
266
+ try:
267
+ self._incoming.put_nowait(data)
268
+ except asyncio.QueueFull:
269
+ self.logger.warning(
270
+ "Incoming queue full; dropping message seq=%s", data.get("seq")
271
+ )
272
+
273
+ async def _handle_file_upload(self, data: dict[str, Any]) -> None:
274
+ if data.get("opcode") != Opcode.NOTIF_ATTACH:
275
+ return
276
+ payload = data.get("payload", {})
277
+ for key in ("fileId", "videoId"):
278
+ id_ = payload.get(key)
279
+ if id_ is not None:
280
+ fut = self._file_upload_waiters.pop(id_, None)
281
+ if fut and not fut.done():
282
+ fut.set_result(data)
283
+ self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
284
+
285
+ async def _send_notification_response(self, chat_id: int, message_id: str) -> None:
286
+ await self._send_and_wait(
287
+ opcode=Opcode.NOTIF_MESSAGE,
288
+ payload={"chatId": chat_id, "messageId": message_id},
289
+ cmd=0,
290
+ )
291
+ self.logger.debug(
292
+ "Sent NOTIF_MESSAGE_RECEIVED for chat_id=%s message_id=%s", chat_id, message_id
293
+ )
294
+
295
+ async def _handle_message_notifications(self, data: dict) -> None:
296
+ if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
297
+ return
298
+ payload = data.get("payload", {})
299
+ msg = Message.from_dict(payload)
300
+ if not msg:
301
+ return
302
+
303
+ if msg.chat_id and msg.id:
304
+ await self._send_notification_response(msg.chat_id, str(msg.id))
305
+
306
+ handlers_map = {
307
+ MessageStatus.EDITED: self._on_message_edit_handlers,
308
+ MessageStatus.REMOVED: self._on_message_delete_handlers,
309
+ }
310
+ if msg.status and msg.status in handlers_map:
311
+ for handler, filter in handlers_map[msg.status]:
312
+ await self._process_message_handler(handler, filter, msg)
313
+ if msg.status is None:
314
+ for handler, filter in self._on_message_handlers:
315
+ await self._process_message_handler(handler, filter, msg)
316
+
317
+ async def _handle_reactions(self, data: dict):
318
+ if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED:
319
+ return
320
+
321
+ payload = data.get("payload", {})
322
+ chat_id = payload.get("chatId")
323
+ message_id = payload.get("messageId")
324
+
325
+ if not (chat_id and message_id):
326
+ return
327
+
328
+ total_count = payload.get("totalCount")
329
+ your_reaction = payload.get("yourReaction")
330
+ counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])]
331
+
332
+ reaction_info = ReactionInfo(
333
+ total_count=total_count,
334
+ your_reaction=your_reaction,
335
+ counters=counters,
336
+ )
337
+
338
+ for handler in self._on_reaction_change_handlers:
339
+ try:
340
+ result = handler(message_id, chat_id, reaction_info)
341
+ if asyncio.iscoroutine(result):
342
+ await result
343
+ except Exception as e:
344
+ self.logger.exception("Error in on_reaction_change_handler: %s", e)
345
+
346
+ async def _handle_chat_updates(self, data: dict) -> None:
347
+ if data.get("opcode") != Opcode.NOTIF_CHAT:
348
+ return
349
+
350
+ payload = data.get("payload", {})
351
+ chat_data = payload.get("chat", {})
352
+ chat = Chat.from_dict(chat_data)
353
+ if not chat:
354
+ return
355
+
356
+ for handler in self._on_chat_update_handlers:
357
+ try:
358
+ result = handler(chat)
359
+ if asyncio.iscoroutine(result):
360
+ await result
361
+ except Exception as e:
362
+ self.logger.exception("Error in on_chat_update_handler: %s", e)
363
+
364
+ async def _handle_raw_receive(self, data: dict[str, Any]) -> None:
365
+ for handler in self._on_raw_receive_handlers:
366
+ try:
367
+ result = handler(data)
368
+ if asyncio.iscoroutine(result):
369
+ await result
370
+ except Exception as e:
371
+ self.logger.exception("Error in on_raw_receive_handler: %s", e)
372
+
373
+ async def _dispatch_incoming(self, data: dict[str, Any]) -> None:
374
+ await self._handle_raw_receive(data)
375
+ await self._handle_file_upload(data)
376
+ await self._handle_message_notifications(data)
377
+ await self._handle_reactions(data)
378
+ await self._handle_chat_updates(data)
379
+
380
+ def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
381
+ try:
382
+ fut.result()
383
+ except asyncio.CancelledError:
384
+ pass
385
+ except Exception as e:
386
+ self.logger.exception("Error retrieving task exception: %s", e)
387
+
388
+ async def _queue_message(
389
+ self,
390
+ opcode: int,
391
+ payload: dict[str, Any],
392
+ cmd: int = 0,
393
+ timeout: float = DEFAULT_TIMEOUT,
394
+ max_retries: int = 3,
395
+ ) -> None:
396
+ if self._outgoing is None:
397
+ self.logger.warning("Outgoing queue not initialized")
398
+ return
399
+
400
+ message = {
401
+ "opcode": opcode,
402
+ "payload": payload,
403
+ "cmd": cmd,
404
+ "timeout": timeout,
405
+ "retry_count": 0,
406
+ "max_retries": max_retries,
407
+ }
408
+
409
+ await self._outgoing.put(message)
410
+ self.logger.debug("Message queued for sending")
411
+
412
+ async def _outgoing_loop(self) -> None:
413
+ while self.is_connected:
414
+ try:
415
+ if self._outgoing is None:
416
+ await asyncio.sleep(0.1)
417
+ continue
418
+
419
+ if self._circuit_breaker:
420
+ if time.time() - self._last_error_time > 60:
421
+ self._circuit_breaker = False
422
+ self._error_count = 0
423
+ self.logger.info("Circuit breaker reset")
424
+ else:
425
+ await asyncio.sleep(5)
426
+ continue
427
+
428
+ message = await self._outgoing.get() # TODO: persistent msg q mb?
429
+ if not message:
430
+ continue
431
+
432
+ retry_count = message.get("retry_count", 0)
433
+ max_retries = message.get("max_retries", 3)
434
+
435
+ try:
436
+ await self._send_and_wait(
437
+ opcode=message["opcode"],
438
+ payload=message["payload"],
439
+ cmd=message.get("cmd", 0),
440
+ timeout=message.get("timeout", DEFAULT_TIMEOUT),
441
+ )
442
+ self.logger.debug("Message sent successfully from queue")
443
+ self._error_count = max(0, self._error_count - 1)
444
+ except Exception as e:
445
+ self._error_count += 1
446
+ self._last_error_time = time.time()
447
+
448
+ if self._error_count > 10:
449
+ self._circuit_breaker = True
450
+ self.logger.warning(
451
+ "Circuit breaker activated due to %d consecutive errors",
452
+ self._error_count,
453
+ )
454
+ await self._outgoing.put(message)
455
+ continue
456
+
457
+ retry_delay = self._get_retry_delay(e, retry_count)
458
+ self.logger.warning(
459
+ "Failed to send message from queue: %s (delay: %ds)",
460
+ e,
461
+ retry_delay,
462
+ )
463
+
464
+ if retry_count < max_retries:
465
+ message["retry_count"] = retry_count + 1
466
+ await asyncio.sleep(retry_delay)
467
+ await self._outgoing.put(message)
468
+ else:
469
+ self.logger.error(
470
+ "Message failed after %d retries, dropping",
471
+ max_retries,
472
+ )
473
+
474
+ except Exception:
475
+ self.logger.exception("Error in outgoing loop")
476
+ await asyncio.sleep(1)
477
+
478
+ def _get_retry_delay(self, error: Exception, retry_count: int) -> float:
479
+ if isinstance(error, (ConnectionError, OSError)):
480
+ return 1.0
481
+ elif isinstance(error, TimeoutError):
482
+ return 5.0
483
+ elif isinstance(error, WebSocketNotConnectedError):
484
+ return 2.0
485
+ else:
486
+ return float(2**retry_count)
487
+
488
+ async def _sync(self, user_agent: UserAgentPayload | None = None) -> None:
489
+ self.logger.info("Starting initial sync")
490
+
491
+ if user_agent is None:
492
+ user_agent = self.headers or UserAgentPayload()
493
+
494
+ payload = SyncPayload(
495
+ interactive=True,
496
+ token=self._token,
497
+ chats_sync=0,
498
+ contacts_sync=0,
499
+ presence_sync=0,
500
+ drafts_sync=0,
501
+ chats_count=40,
502
+ user_agent=user_agent,
503
+ ).model_dump(by_alias=True)
504
+ try:
505
+ data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
506
+ raw_payload = data.get("payload", {})
507
+
508
+ if error := raw_payload.get("error"):
509
+ MixinsUtils.handle_error(data)
510
+
511
+ for raw_chat in raw_payload.get("chats", []):
512
+ try:
513
+ if raw_chat.get("type") == ChatType.DIALOG.value:
514
+ self.dialogs.append(Dialog.from_dict(raw_chat))
515
+ elif raw_chat.get("type") == ChatType.CHAT.value:
516
+ self.chats.append(Chat.from_dict(raw_chat))
517
+ elif raw_chat.get("type") == ChatType.CHANNEL.value:
518
+ self.channels.append(Channel.from_dict(raw_chat))
519
+ except Exception:
520
+ self.logger.exception("Error parsing chat entry")
521
+
522
+ for raw_user in raw_payload.get("contacts", []):
523
+ try:
524
+ user = User.from_dict(raw_user)
525
+ if user:
526
+ self.contacts.append(user)
527
+ except Exception:
528
+ self.logger.exception("Error parsing contact entry")
529
+
530
+ if raw_payload.get("profile", {}).get("contact"):
531
+ self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
532
+
533
+ self.logger.info(
534
+ "Sync completed: dialogs=%d chats=%d channels=%d",
535
+ len(self.dialogs),
536
+ len(self.chats),
537
+ len(self.channels),
538
+ )
539
+
540
+ except Exception as e:
541
+ self.logger.exception("Sync failed")
542
+ self.is_connected = False
543
+ if self._ws:
544
+ await self._ws.close()
545
+ self._ws = None
546
+ raise
547
+
548
+ async def _get_chat(self, chat_id: int) -> Chat | None:
549
+ for chat in self.chats:
550
+ if chat.id == chat_id:
551
+ return chat
552
+ return None
pymax/mixins/auth.py CHANGED
@@ -7,11 +7,11 @@ from typing import Any
7
7
  import qrcode
8
8
 
9
9
  from pymax.exceptions import Error
10
- from pymax.interfaces import ClientProtocol
11
- from pymax.mixins.utils import MixinsUtils
12
10
  from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
11
+ from pymax.protocols import ClientProtocol
13
12
  from pymax.static.constant import PHONE_REGEX
14
13
  from pymax.static.enum import AuthType, DeviceType, Opcode
14
+ from pymax.utils import MixinsUtils
15
15
 
16
16
 
17
17
  class AuthMixin(ClientProtocol):
pymax/mixins/channel.py CHANGED
@@ -1,18 +1,18 @@
1
1
  from pymax.exceptions import Error, ResponseError, ResponseStructureError
2
- from pymax.interfaces import ClientProtocol
3
- from pymax.mixins.utils import MixinsUtils
4
2
  from pymax.payloads import (
5
3
  GetGroupMembersPayload,
6
4
  JoinChatPayload,
7
5
  ResolveLinkPayload,
8
6
  SearchGroupMembersPayload,
9
7
  )
8
+ from pymax.protocols import ClientProtocol
10
9
  from pymax.static.constant import (
11
10
  DEFAULT_CHAT_MEMBERS_LIMIT,
12
11
  DEFAULT_MARKER_VALUE,
13
12
  )
14
13
  from pymax.static.enum import Opcode
15
14
  from pymax.types import Channel, Member
15
+ from pymax.utils import MixinsUtils
16
16
 
17
17
 
18
18
  class ChannelMixin(ClientProtocol):
@@ -113,9 +113,7 @@ class ChannelMixin(ClientProtocol):
113
113
  payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count)
114
114
  return await self._query_members(payload)
115
115
 
116
- async def find_members(
117
- self, chat_id: int, query: str
118
- ) -> tuple[list[Member], int | None]:
116
+ async def find_members(self, chat_id: int, query: str) -> tuple[list[Member], int | None]:
119
117
  """
120
118
  Поиск участников канала по строке
121
119
  Внимание! веб-клиент всегда возвращает только определённое количество пользователей,
pymax/mixins/group.py CHANGED
@@ -1,8 +1,6 @@
1
1
  import time
2
2
 
3
3
  from pymax.exceptions import Error
4
- from pymax.interfaces import ClientProtocol
5
- from pymax.mixins.utils import MixinsUtils
6
4
  from pymax.payloads import (
7
5
  ChangeGroupProfilePayload,
8
6
  ChangeGroupSettingsOptions,
@@ -18,8 +16,10 @@ from pymax.payloads import (
18
16
  RemoveUsersPayload,
19
17
  ReworkInviteLinkPayload,
20
18
  )
19
+ from pymax.protocols import ClientProtocol
21
20
  from pymax.static.enum import Opcode
22
21
  from pymax.types import Chat, Message
22
+ from pymax.utils import MixinsUtils
23
23
 
24
24
 
25
25
  class GroupMixin(ClientProtocol):
@@ -95,9 +95,7 @@ class GroupMixin(ClientProtocol):
95
95
  operation="add",
96
96
  ).model_dump(by_alias=True)
97
97
 
98
- data = await self._send_and_wait(
99
- opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
100
- )
98
+ data = await self._send_and_wait(opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload)
101
99
 
102
100
  if data.get("payload", {}).get("error"):
103
101
  MixinsUtils.handle_error(data)
@@ -155,9 +153,7 @@ class GroupMixin(ClientProtocol):
155
153
  clean_msg_period=clean_msg_period,
156
154
  ).model_dump(by_alias=True)
157
155
 
158
- data = await self._send_and_wait(
159
- opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
160
- )
156
+ data = await self._send_and_wait(opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload)
161
157
 
162
158
  if data.get("payload", {}).get("error"):
163
159
  MixinsUtils.handle_error(data)
@@ -293,6 +289,33 @@ class GroupMixin(ClientProtocol):
293
289
 
294
290
  return chat
295
291
 
292
+ async def resolve_group_by_link(self, link: str) -> Chat | None:
293
+ """
294
+ Разрешает группу по ссылке
295
+
296
+ Args:
297
+ link (str): Ссылка на группу.
298
+
299
+ Returns:
300
+ Chat | None: Объект чата группы или None, если не найдено.
301
+ """
302
+ proceed_link = self._process_chat_join_link(link)
303
+ if proceed_link is None:
304
+ raise ValueError("Invalid group link")
305
+
306
+ data = await self._send_and_wait(
307
+ opcode=Opcode.LINK_INFO,
308
+ payload={
309
+ "link": proceed_link,
310
+ },
311
+ )
312
+
313
+ if data.get("payload", {}).get("error"):
314
+ MixinsUtils.handle_error(data)
315
+
316
+ chat = Chat.from_dict(data["payload"].get("chat", {}))
317
+ return chat
318
+
296
319
  async def rework_invite_link(self, chat_id: int) -> Chat:
297
320
  """
298
321
  Пересоздает ссылку для приглашения в группу
@@ -329,14 +352,10 @@ class GroupMixin(ClientProtocol):
329
352
  chat_id for chat_id in chat_ids if await self._get_chat(chat_id) is None
330
353
  ]
331
354
  if missed_chat_ids:
332
- payload = GetChatInfoPayload(chat_ids=missed_chat_ids).model_dump(
333
- by_alias=True
334
- )
355
+ payload = GetChatInfoPayload(chat_ids=missed_chat_ids).model_dump(by_alias=True)
335
356
  else:
336
357
  chats: list[Chat] = [
337
- chat
338
- for chat_id in chat_ids
339
- if (chat := await self._get_chat(chat_id)) is not None
358
+ chat for chat_id in chat_ids if (chat := await self._get_chat(chat_id)) is not None
340
359
  ]
341
360
  return chats
342
361