maxapi-python 1.1.20__py3-none-any.whl → 1.2.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.
pymax/mixins/auth.py CHANGED
@@ -1,14 +1,17 @@
1
1
  import asyncio
2
+ import datetime
2
3
  import re
3
4
  import sys
4
5
  from typing import Any
5
6
 
7
+ import qrcode
8
+
6
9
  from pymax.exceptions import Error
7
10
  from pymax.interfaces import ClientProtocol
8
11
  from pymax.mixins.utils import MixinsUtils
9
12
  from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
10
13
  from pymax.static.constant import PHONE_REGEX
11
- from pymax.static.enum import AuthType, Opcode
14
+ from pymax.static.enum import AuthType, DeviceType, Opcode
12
15
 
13
16
 
14
17
  class AuthMixin(ClientProtocol):
@@ -19,15 +22,20 @@ class AuthMixin(ClientProtocol):
19
22
  """
20
23
  Запрашивает код аутентификации для указанного номера телефона и возвращает временный токен.
21
24
 
22
- Note:
23
- Использовать только в кастомном login flow.
25
+ Метод отправляет запрос на получение кода верификации на переданный номер телефона.
26
+ Используется в процессе аутентификации или регистрации.
24
27
 
25
- Args:
26
- phone (str): Номер телефона в международном формате.
27
- language (str, optional): Язык для сообщения с кодом. По умолчанию "ru".
28
+ :param phone: Номер телефона в международном формате.
29
+ :type phone: str
30
+ :param language: Язык для сообщения с кодом. По умолчанию "ru".
31
+ :type language: str
32
+ :return: Временный токен для дальнейшей аутентификации.
33
+ :rtype: str
34
+ :raises ValueError: Если полученные данные имеют неверный формат.
35
+ :raises Error: Если сервер вернул ошибку.
28
36
 
29
- Returns:
30
- str: Временный токен для дальнейшей аутентификации.
37
+ .. note::
38
+ Используется только в пользовательском flow аутентификации.
31
39
  """
32
40
  self.logger.info("Requesting auth code")
33
41
 
@@ -52,7 +60,54 @@ class AuthMixin(ClientProtocol):
52
60
  self.logger.error("Invalid payload data received")
53
61
  raise ValueError("Invalid payload data received")
54
62
 
63
+ async def resend_code(self, phone: str, language: str = "ru") -> str:
64
+ """
65
+ Повторно запрашивает код аутентификации для указанного номера телефона и возвращает временный токен.
66
+
67
+ :param phone: Номер телефона в международном формате.
68
+ :type phone: str
69
+ :param language: Язык для сообщения с кодом. По умолчанию "ru".
70
+ :type language: str
71
+ :return: Временный токен для дальнейшей аутентификации.
72
+ :rtype: str
73
+ :raises ValueError: Если полученные данные имеют неверный формат.
74
+ :raises Error: Если сервер вернул ошибку.
75
+ """
76
+ self.logger.info("Resending auth code")
77
+
78
+ payload = RequestCodePayload(
79
+ phone=phone, type=AuthType.RESEND, language=language
80
+ ).model_dump(by_alias=True)
81
+
82
+ data = await self._send_and_wait(opcode=Opcode.AUTH_REQUEST, payload=payload)
83
+
84
+ if data.get("payload", {}).get("error"):
85
+ MixinsUtils.handle_error(data)
86
+
87
+ self.logger.debug(
88
+ "Code resend response opcode=%s seq=%s",
89
+ data.get("opcode"),
90
+ data.get("seq"),
91
+ )
92
+ payload_data = data.get("payload")
93
+ if isinstance(payload_data, dict):
94
+ return payload_data["token"]
95
+ else:
96
+ self.logger.error("Invalid payload data received")
97
+ raise ValueError("Invalid payload data received")
98
+
55
99
  async def _send_code(self, code: str, token: str) -> dict[str, Any]:
100
+ """
101
+ Отправляет код верификации на сервер для подтверждения.
102
+
103
+ :param code: Код верификации (6 цифр).
104
+ :type code: str
105
+ :param token: Временный токен, полученный из request_code.
106
+ :type token: str
107
+ :return: Словарь с данными ответа сервера, содержащий токены аутентификации.
108
+ :rtype: dict[str, Any]
109
+ :raises Error: Если сервер вернул ошибку.
110
+ """
56
111
  self.logger.info("Sending verification code")
57
112
 
58
113
  payload = SendCodePayload(
@@ -78,32 +133,176 @@ class AuthMixin(ClientProtocol):
78
133
  self.logger.error("Invalid payload data received")
79
134
  raise ValueError("Invalid payload data received")
80
135
 
136
+ def _print_qr(self, qr_link: str) -> None:
137
+ qr = qrcode.QRCode(
138
+ version=1,
139
+ error_correction=qrcode.ERROR_CORRECT_L,
140
+ box_size=1,
141
+ border=1,
142
+ )
143
+ qr.add_data(qr_link)
144
+ qr.make(fit=True)
145
+
146
+ qr.print_ascii()
147
+
148
+ async def _request_qr_login(self) -> dict[str, Any]:
149
+ self.logger.info("Requesting QR login data")
150
+
151
+ data = await self._send_and_wait(opcode=Opcode.GET_QR, payload={})
152
+
153
+ if data.get("payload", {}).get("error"):
154
+ MixinsUtils.handle_error(data)
155
+
156
+ self.logger.debug(
157
+ "QR login data response opcode=%s seq=%s",
158
+ data.get("opcode"),
159
+ data.get("seq"),
160
+ )
161
+ payload_data = data.get("payload")
162
+ if isinstance(payload_data, dict):
163
+ return payload_data
164
+ else:
165
+ self.logger.error("Invalid payload data received")
166
+ raise ValueError("Invalid payload data received")
167
+
168
+ def _validate_version(self, version: str, min_version: str) -> bool:
169
+ def version_tuple(v: str) -> tuple[int, ...]:
170
+ return tuple(map(int, (v.split("."))))
171
+
172
+ return version_tuple(version) >= version_tuple(min_version)
173
+
81
174
  async def _login(self) -> None:
82
175
  self.logger.info("Starting login flow")
83
176
 
84
- temp_token = await self.request_code(self.phone)
85
- if not temp_token or not isinstance(temp_token, str):
86
- self.logger.critical("Failed to request code: token missing")
87
- raise ValueError("Failed to request code")
177
+ if self.user_agent.device_type == DeviceType.WEB.value and self._ws:
178
+ if not self._validate_version(self.user_agent.app_version, "25.12.13"):
179
+ self.logger.error("Your app version is too old")
180
+ raise ValueError("Your app version is too old")
88
181
 
89
- print("Введите код: ", end="", flush=True)
90
- code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
91
- if len(code) != 6 or not code.isdigit():
92
- self.logger.error("Invalid code format entered")
93
- raise ValueError("Invalid code format")
182
+ login_resp = await self._login_by_qr()
183
+ else:
184
+ temp_token = await self.request_code(self.phone)
185
+ if not temp_token or not isinstance(temp_token, str):
186
+ self.logger.critical("Failed to request code: token missing")
187
+ raise ValueError("Failed to request code")
188
+
189
+ print("Введите код: ", end="", flush=True)
190
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
191
+ if len(code) != 6 or not code.isdigit():
192
+ self.logger.error("Invalid code format entered")
193
+ raise ValueError("Invalid code format")
194
+
195
+ login_resp = await self._send_code(code, temp_token)
196
+
197
+ token = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token", "")
94
198
 
95
- login_resp = await self._send_code(code, temp_token)
96
- token: str | None = (
97
- login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
98
- )
99
199
  if not token:
100
200
  self.logger.critical("Failed to login, token not received")
101
201
  raise ValueError("Failed to login, token not received")
102
202
 
103
203
  self._token = token
104
- self._database.update_auth_token(self._device_id, self._token)
204
+ self._database.update_auth_token((self._device_id), self._token)
105
205
  self.logger.info("Login successful, token saved to database")
106
206
 
207
+ async def _poll_qr_login(self, track_id: str, poll_interval: int) -> bool:
208
+ self.logger.info("Polling for QR login confirmation")
209
+
210
+ while True:
211
+ data = await self._send_and_wait(
212
+ opcode=Opcode.GET_QR_STATUS,
213
+ payload={"trackId": track_id},
214
+ )
215
+
216
+ payload = data.get("payload", {})
217
+
218
+ if payload.get("error"):
219
+ MixinsUtils.handle_error(data)
220
+ status = payload.get("status")
221
+
222
+ if not status:
223
+ self.logger.warning("No status in QR login response")
224
+ continue
225
+
226
+ if status.get("loginAvailable"):
227
+ self.logger.info("QR login confirmed")
228
+ return True
229
+ else:
230
+ exp_at = status.get("expiresAt")
231
+ if (
232
+ exp_at
233
+ and isinstance(exp_at, (int, float))
234
+ and exp_at < datetime.datetime.now().timestamp() * 1000
235
+ ):
236
+ self.logger.warning("QR code expired")
237
+ return False
238
+
239
+ await asyncio.sleep(poll_interval / 1000)
240
+
241
+ async def _get_qr_login_data(self, track_id: str) -> dict[str, Any]:
242
+ self.logger.info("Getting QR login data")
243
+
244
+ data = await self._send_and_wait(
245
+ opcode=Opcode.LOGIN_BY_QR,
246
+ payload={"trackId": track_id},
247
+ )
248
+
249
+ self.logger.debug(
250
+ "QR login data response opcode=%s seq=%s",
251
+ data.get("opcode"),
252
+ data.get("seq"),
253
+ )
254
+ payload_data = data.get("payload")
255
+ if isinstance(payload_data, dict):
256
+ return payload_data
257
+ else:
258
+ self.logger.error("Invalid payload data received")
259
+ raise ValueError("Invalid payload data received")
260
+
261
+ async def _login_by_qr(self) -> dict[str, Any]:
262
+ data = await self._request_qr_login()
263
+
264
+ poll_interval = data.get("pollingInterval")
265
+ link = data.get("qrLink")
266
+ track_id = data.get("trackId")
267
+ expires_at = data.get("expiresAt")
268
+
269
+ if not poll_interval or not link or not track_id or not expires_at:
270
+ self.logger.critical("Invalid QR login data received")
271
+ raise ValueError("Invalid QR login data received")
272
+
273
+ self.logger.info("Starting QR login flow")
274
+ self._print_qr(link)
275
+
276
+ poll_qr_task = asyncio.create_task(self._poll_qr_login(track_id, poll_interval))
277
+
278
+ while True:
279
+ now_ms = datetime.datetime.now().timestamp() * 1000
280
+
281
+ done, pending = await asyncio.wait(
282
+ [poll_qr_task],
283
+ timeout=1,
284
+ return_when=asyncio.FIRST_COMPLETED,
285
+ )
286
+
287
+ if now_ms >= expires_at:
288
+ poll_qr_task.cancel()
289
+ self.logger.error("QR code expired before confirmation")
290
+ raise RuntimeError("QR code expired before confirmation")
291
+
292
+ if poll_qr_task in done:
293
+ exc = poll_qr_task.exception()
294
+ if exc is not None:
295
+ raise exc
296
+ elif poll_qr_task.result():
297
+ self.logger.info("QR login successful")
298
+
299
+ data = await self._get_qr_login_data(track_id)
300
+ return data
301
+
302
+ else:
303
+ self.logger.error("QR login failed or expired")
304
+ raise RuntimeError("QR login failed or expired")
305
+
107
306
  async def _submit_reg_info(
108
307
  self, first_name: str, last_name: str | None, token: str
109
308
  ) -> dict[str, Any]:
@@ -116,9 +315,7 @@ class AuthMixin(ClientProtocol):
116
315
  token=token,
117
316
  ).model_dump(by_alias=True)
118
317
 
119
- data = await self._send_and_wait(
120
- opcode=Opcode.AUTH_CONFIRM, payload=payload
121
- )
318
+ data = await self._send_and_wait(opcode=Opcode.AUTH_CONFIRM, payload=payload)
122
319
  if data.get("payload", {}).get("error"):
123
320
  MixinsUtils.handle_error(data)
124
321
 
@@ -152,9 +349,7 @@ class AuthMixin(ClientProtocol):
152
349
  raise ValueError("Invalid code format")
153
350
 
154
351
  registration_response = await self._send_code(code, temp_token)
155
- token: str | None = (
156
- registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token")
157
- )
352
+ token = registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token", "")
158
353
  if not token:
159
354
  self.logger.critical("Failed to register, token not received")
160
355
  raise ValueError("Failed to register, token not received")
@@ -165,5 +360,9 @@ class AuthMixin(ClientProtocol):
165
360
  self.logger.critical("Failed to register, token not received")
166
361
  raise ValueError("Failed to register, token not received")
167
362
 
168
- self._database.update_auth_token(self._device_id, self._token)
169
- self.logger.info("Registration successful, token saved to database")
363
+ self.logger.info("Registration successful")
364
+ self.logger.info("Token: %s", self._token)
365
+ self.logger.warning(
366
+ "IMPORTANT: Use this token ONLY with device_type='DESKTOP' and the special init user agent"
367
+ )
368
+ self.logger.warning("This token MUST NOT be used in web clients")
pymax/mixins/channel.py CHANGED
@@ -12,23 +12,18 @@ from pymax.static.constant import (
12
12
  DEFAULT_MARKER_VALUE,
13
13
  )
14
14
  from pymax.static.enum import Opcode
15
- from pymax.types import Member
15
+ from pymax.types import Channel, Member
16
16
 
17
17
 
18
18
  class ChannelMixin(ClientProtocol):
19
- async def resolve_channel_by_name(self, name: str) -> bool:
19
+ async def resolve_channel_by_name(self, name: str) -> Channel | None:
20
20
  """
21
- Пытается найти канал по его имени
21
+ Получает информацию о канале по его имени
22
22
 
23
- Args:
24
- name (str): Имя канала
25
-
26
- Exceptions:
27
- ResponseError: Ошибка в ответе сервера
28
- ResponseStructureError: Ошибка структуры ответа сервера
29
-
30
- Returns:
31
- bool: True, если канал найден
23
+ :param name: Имя канала
24
+ :type name: str
25
+ :return: Объект Channel или None, если канал не найден
26
+ :rtype: Channel | None
32
27
  """
33
28
  payload = ResolveLinkPayload(
34
29
  link=f"https://max.ru/{name}",
@@ -37,21 +32,20 @@ class ChannelMixin(ClientProtocol):
37
32
  data = await self._send_and_wait(opcode=Opcode.LINK_INFO, payload=payload)
38
33
  if data.get("payload", {}).get("error"):
39
34
  MixinsUtils.handle_error(data)
40
- return True
41
35
 
42
- async def join_channel(self, link: str) -> bool:
36
+ channel = Channel.from_dict(data.get("payload", {}).get("chat", {}))
37
+ if channel not in self.channels:
38
+ self.channels.append(channel)
39
+ return channel
40
+
41
+ async def join_channel(self, link: str) -> Channel | None:
43
42
  """
44
43
  Присоединяется к каналу по ссылке
45
44
 
46
- Args:
47
- link (str): Ссылка на канал
48
-
49
- Exceptions:
50
- ResponseError: Ошибка в ответе сервера
51
- ResponseStructureError: Ошибка структуры ответа сервера
52
-
53
- Returns:
54
- bool: True, если присоединение прошло успешно
45
+ :param link: Ссылка на канал
46
+ :type link: str
47
+ :return: Объект канала, если присоединение прошло успешно, иначе None
48
+ :rtype: Channel | None
55
49
  """
56
50
  payload = JoinChatPayload(
57
51
  link=link,
@@ -60,7 +54,11 @@ class ChannelMixin(ClientProtocol):
60
54
  data = await self._send_and_wait(opcode=Opcode.CHAT_JOIN, payload=payload)
61
55
  if data.get("payload", {}).get("error"):
62
56
  MixinsUtils.handle_error(data)
63
- return True
57
+
58
+ channel = Channel.from_dict(data.get("payload", {}).get("chat", {}))
59
+ if channel not in self.channels:
60
+ self.channels.append(channel)
61
+ return channel
64
62
 
65
63
  async def _query_members(
66
64
  self, payload: GetGroupMembersPayload | SearchGroupMembersPayload
@@ -102,13 +100,14 @@ class ChannelMixin(ClientProtocol):
102
100
  """
103
101
  Загружает членов канала
104
102
 
105
- Args:
106
- chat_id (int): Идентификатор канала
107
- marker (int, optional): Маркер для пагинации. По умолчанию DEFAULT_MARKER_VALUE
108
- count (int, optional): Количество членов для загрузки. По умолчанию DEFAULT_CHAT_MEMBERS_LIMIT.
109
-
110
- Returns:
111
- tuple[list[Member], int | None]: Список участников канала и маркер для следующей страницы
103
+ :param chat_id: Идентификатор канала
104
+ :type chat_id: int
105
+ :param marker: Маркер для пагинации. По умолчанию DEFAULT_MARKER_VALUE
106
+ :type marker: int | None
107
+ :param count: Количество членов для загрузки. По умолчанию DEFAULT_CHAT_MEMBERS_LIMIT.
108
+ :type count: int
109
+ :return: Список участников канала и маркер для следующей страницы
110
+ :rtype: tuple[list[Member], int | None]
112
111
  """
113
112
 
114
113
  payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count)
@@ -122,12 +121,12 @@ class ChannelMixin(ClientProtocol):
122
121
  Внимание! веб-клиент всегда возвращает только определённое количество пользователей,
123
122
  тоесть пагинация здесь не реализована!
124
123
 
125
- Args:
126
- chat_id (int): Идентификатор канала
127
- query (str): Строка для поиска участников
128
-
129
- Returns:
130
- list[Member]: Список участников канала
124
+ :param chat_id: Идентификатор канала
125
+ :type chat_id: int
126
+ :param query: Строка для поиска участников
127
+ :type query: str
128
+ :return: Список участников канала
129
+ :rtype: tuple[list[Member], int | None]
131
130
  """
132
131
  payload = SearchGroupMembersPayload(chat_id=chat_id, query=query)
133
132
  return await self._query_members(payload)
pymax/mixins/group.py CHANGED
@@ -10,9 +10,11 @@ from pymax.payloads import (
10
10
  CreateGroupAttach,
11
11
  CreateGroupMessage,
12
12
  CreateGroupPayload,
13
+ FetchChatsPayload,
13
14
  GetChatInfoPayload,
14
15
  InviteUsersPayload,
15
16
  JoinChatPayload,
17
+ LeaveChatPayload,
16
18
  RemoveUsersPayload,
17
19
  ReworkInviteLinkPayload,
18
20
  )
@@ -74,7 +76,7 @@ class GroupMixin(ClientProtocol):
74
76
  chat_id: int,
75
77
  user_ids: list[int],
76
78
  show_history: bool = True,
77
- ) -> bool:
79
+ ) -> Chat | None:
78
80
  """
79
81
  Приглашает пользователей в группу
80
82
 
@@ -84,7 +86,7 @@ class GroupMixin(ClientProtocol):
84
86
  show_history (bool, optional): Флаг оповещения. Defaults to True.
85
87
 
86
88
  Returns:
87
- bool: True, если пользователи успешно приглашены
89
+ Chat | None: Объект Chat или None при ошибке.
88
90
  """
89
91
  payload = InviteUsersPayload(
90
92
  chat_id=chat_id,
@@ -109,7 +111,26 @@ class GroupMixin(ClientProtocol):
109
111
  idx = self.chats.index(cached_chat)
110
112
  self.chats[idx] = chat
111
113
 
112
- return True
114
+ return chat
115
+
116
+ async def invite_users_to_channel(
117
+ self,
118
+ chat_id: int,
119
+ user_ids: list[int],
120
+ show_history: bool = True,
121
+ ) -> Chat | None:
122
+ """
123
+ Приглашает пользователей в канал
124
+
125
+ Args:
126
+ chat_id (int): ID канала.
127
+ user_ids (list[int]): Список идентификаторов пользователей.
128
+ show_history (bool, optional): Флаг оповещения. Defaults to True.
129
+
130
+ Returns:
131
+ Chat | None: Объект Chat или None при ошибке.
132
+ """
133
+ return await self.invite_users_to_group(chat_id, user_ids, show_history)
113
134
 
114
135
  async def remove_users_from_group(
115
136
  self,
@@ -117,6 +138,17 @@ class GroupMixin(ClientProtocol):
117
138
  user_ids: list[int],
118
139
  clean_msg_period: int,
119
140
  ) -> bool:
141
+ """
142
+ Удаляет пользователей из группы
143
+
144
+ Args:
145
+ chat_id (int): ID группы.
146
+ user_ids (list[int]): Список идентификаторов пользователей.
147
+ clean_msg_period (int): Период очистки сообщений.
148
+
149
+ Returns:
150
+ bool: True, если удаление прошло успешно, иначе False.
151
+ """
120
152
  payload = RemoveUsersPayload(
121
153
  chat_id=chat_id,
122
154
  user_ids=user_ids,
@@ -150,6 +182,19 @@ class GroupMixin(ClientProtocol):
150
182
  only_admin_can_call: bool | None = None,
151
183
  members_can_see_private_link: bool | None = None,
152
184
  ) -> None:
185
+ """
186
+ Изменяет настройки группы
187
+
188
+ Args:
189
+ chat_id (int): ID группы.
190
+ all_can_pin_message (bool | None, optional): Все могут закреплять сообщения. Defaults to None.
191
+ only_owner_can_change_icon_title (bool | None, optional): Только владелец может менять иконку и название. Defaults to None.
192
+ only_admin_can_add_member (bool | None, optional): Только администраторы могут добавлять участников. Defaults to None.
193
+ only_admin_can_call (bool | None, optional): Только администраторы могут звонить. Defaults to None.
194
+ members_can_see_private_link (bool | None, optional): Участники могут видеть приватную ссылку. Defaults to None.
195
+ Returns:
196
+ None
197
+ """
153
198
  payload = ChangeGroupSettingsPayload(
154
199
  chat_id=chat_id,
155
200
  options=ChangeGroupSettingsOptions(
@@ -181,6 +226,17 @@ class GroupMixin(ClientProtocol):
181
226
  name: str | None,
182
227
  description: str | None = None,
183
228
  ) -> None:
229
+ """
230
+ Изменяет профиль группы
231
+
232
+ Args:
233
+ chat_id (int): ID группы.
234
+ name (str | None): Название группы.
235
+ description (str | None, optional): Описание группы. Defaults to None.
236
+
237
+ Returns:
238
+ None
239
+ """
184
240
  payload = ChangeGroupProfilePayload(
185
241
  chat_id=chat_id,
186
242
  theme=name,
@@ -264,11 +320,10 @@ class GroupMixin(ClientProtocol):
264
320
  """
265
321
  Получает информацию о группах по их ID
266
322
 
267
- Args:
268
- chat_ids (list[int]): Список идентификаторов групп.
269
-
270
- Returns:
271
- list[Chat]: Список объектов Chat.
323
+ :param chat_ids: Список идентификаторов групп.
324
+ :type chat_ids: list[int]
325
+ :return: Список объектов Chat.
326
+ :rtype: list[Chat]
272
327
  """
273
328
  missed_chat_ids = [
274
329
  chat_id for chat_id in chat_ids if await self._get_chat(chat_id) is None
@@ -318,3 +373,67 @@ class GroupMixin(ClientProtocol):
318
373
  if not chats:
319
374
  raise Error("no_chat", "Chat not found in response", "Chat Error")
320
375
  return chats[0]
376
+
377
+ async def leave_group(self, chat_id: int) -> None:
378
+ """
379
+ Покидает группу
380
+
381
+ :param chat_id: Идентификатор группы.
382
+ :type chat_id: int
383
+ :return: None
384
+ :rtype: None
385
+ """
386
+ payload = LeaveChatPayload(chat_id=chat_id).model_dump(by_alias=True)
387
+
388
+ data = await self._send_and_wait(opcode=Opcode.CHAT_LEAVE, payload=payload)
389
+
390
+ if data.get("payload", {}).get("error"):
391
+ MixinsUtils.handle_error(data)
392
+
393
+ cached_chat = await self._get_chat(chat_id)
394
+ if cached_chat is not None:
395
+ self.chats.remove(cached_chat)
396
+
397
+ async def leave_channel(self, chat_id: int) -> None:
398
+ """
399
+ Покидает канал
400
+
401
+ :param chat_id: Идентификатор канала.
402
+ :type chat_id: int
403
+ :return: None
404
+ :rtype: None
405
+ """
406
+ await self.leave_group(chat_id)
407
+
408
+ async def fetch_chats(self, marker: int | None = None) -> list[Chat]:
409
+ """
410
+ Загружает список чатов
411
+
412
+ :param marker: Маркер для пагинации, по умолчанию None
413
+ :type marker: int | None
414
+ :return: Список объектов Chat
415
+ :rtype: list[Chat]
416
+ """
417
+ if marker is None:
418
+ marker = int(time.time() * 1000)
419
+
420
+ payload = FetchChatsPayload(marker=marker).model_dump(by_alias=True)
421
+
422
+ data = await self._send_and_wait(opcode=Opcode.CHATS_LIST, payload=payload)
423
+
424
+ if data.get("payload", {}).get("error"):
425
+ MixinsUtils.handle_error(data)
426
+
427
+ chats_data = data["payload"].get("chats", [])
428
+ chats: list[Chat] = []
429
+ for chat_dict in chats_data:
430
+ chat = Chat.from_dict(chat_dict)
431
+ chats.append(chat)
432
+ cached_chat = await self._get_chat(chat.id)
433
+ if cached_chat is None:
434
+ self.chats.append(chat)
435
+ else:
436
+ idx = self.chats.index(cached_chat)
437
+ self.chats[idx] = chat
438
+
439
+ return chats