maxapi-python 0.1.0__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.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: maxapi-python
3
+ Version: 0.1.0
4
+ Summary: Python wrapper для API мессенджера Max
5
+ Author-email: noxzion <negroid2281488ilikrilex@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/noxzion/PyMax
8
+ Project-URL: Repository, https://github.com/noxzion/PyMax
9
+ Project-URL: Issues, https://github.com/noxzion/PyMax/issues
10
+ Keywords: max,messenger,api,wrapper,websocket
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: build>=1.3.0
17
+ Requires-Dist: pydocstring>=0.2.1
18
+ Requires-Dist: sqlmodel>=0.0.24
19
+ Requires-Dist: websockets>=11.0
20
+ Dynamic: license-file
21
+
22
+ ## MApi - Python api wrapper для Max'a
23
+
24
+ ## Установка
25
+
26
+ > [!IMPORTANT]
27
+ > Нужно иметь git для установки из репозитория
28
+
29
+ ```bash
30
+ pip install git+https://github.com/noxzion/PyMax
31
+ ```
32
+
33
+ Или (без git)
34
+ ```bash
35
+ pip install maxapi-python
36
+ ```
37
+
38
+ ## Пример использования:
39
+
40
+ ```python
41
+ import asyncio
42
+
43
+ from mapi import MaxClient, Message
44
+
45
+
46
+ phone = "+1234567890"
47
+ client = MaxClient(phone=phone, work_dir="cache")
48
+
49
+
50
+ async def main() -> None:
51
+ await client.start()
52
+
53
+ for chat in client.chats:
54
+ print(chat.title)
55
+
56
+ message = await client.send_message("Hello from MaxClient!", chat.id, notify=True)
57
+
58
+ await asyncio.sleep(5)
59
+ message = await client.edit_message(chat.id, message.id, "Hello from MaxClient! (edited)")
60
+ await asyncio.sleep(5)
61
+
62
+ await client.delete_message(chat.id, [message.id], for_me=False)
63
+
64
+ for dialog in client.dialogs:
65
+ print(dialog.last_message.text)
66
+
67
+ for channel in client.channels:
68
+ print(channel.title)
69
+
70
+ await client.close()
71
+
72
+
73
+ @client.on_message
74
+ async def handle_message(message: Message) -> None:
75
+ print(str(message.sender) + ": " + message.text)
76
+
77
+
78
+ @client.on_start
79
+ async def handle_start() -> None:
80
+ print("Client started successfully!")
81
+ history = await client.fetch_history(chat_id=0)
82
+ if history:
83
+ for message in history:
84
+ user_id = message.sender
85
+ user = await client.get_user(user_id)
86
+
87
+ if user:
88
+ print(f"{user.names[0].name}: {message.text}")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ asyncio.run(client.start())
93
+ ```
94
+
95
+ ## Разработка
96
+
97
+ Сборка пакета:
98
+
99
+ ```bash
100
+ python scripts/build.py
101
+ ```
102
+
103
+ ## Лицензия
104
+
105
+ [MIT](LICENSE)
106
+
107
+ ## Авторы
108
+
109
+ - [noxzion](https://github.com/noxzion) - исходный код пакета
110
+ - [ink](https://github.com/ink-developer) - вскрытие API и документация
@@ -0,0 +1,12 @@
1
+ maxapi_python-0.1.0.dist-info/licenses/LICENSE,sha256=Ud-0SKeXO_yA02Bb1nMDnEaSGwz2OqNlfGQbk0IzqPI,1085
2
+ pymax/__init__.py,sha256=I-ZUVKBfHN-MPkLUbLPxflsvnHSFsyXdW3TmbN2_zz0,950
3
+ pymax/core.py,sha256=DI3yjlCvSipilWMescXluLbP-sDwoOD092K67dJqR38,25132
4
+ pymax/crud.py,sha256=Cn7Psw-gGN0607nqWOLlIorP_wowYWrLomlsof0gLkI,3311
5
+ pymax/exceptions.py,sha256=tiD_JD-MYSb4qFyKov-KWOm0zlD1p_gG6nf1fV-0-SY,702
6
+ pymax/models.py,sha256=7sWAmVuJjM7SPnDkpYEi8CARbTpUKbXqtWKMQdwd0w0,209
7
+ pymax/static.py,sha256=ZIPPAyr3__gtkwSgS_-hXk_oNkcVKs55tY8pSzOrSlY,1711
8
+ pymax/types.py,sha256=6EofkAY_oUnDkNh-cC9Uk05GjKgl6ThBy3_8Hjownz4,11541
9
+ maxapi_python-0.1.0.dist-info/METADATA,sha256=DOVISXKymoucnNKgU1z84wPCLA1Mcwx9rzpxBPAuKCQ,2808
10
+ maxapi_python-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ maxapi_python-0.1.0.dist-info/top_level.txt,sha256=bdAekZwlWiYDxQTWsAUa-yjplJDWDeWEmyhRO3R8qV4,6
12
+ maxapi_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 noxzion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pymax
pymax/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """
2
+ Python wrapper для API мессенджера Max
3
+ """
4
+
5
+ from .core import (
6
+ InvalidPhoneError,
7
+ MaxClient,
8
+ WebSocketNotConnectedError,
9
+ )
10
+ from .static import (
11
+ AccessType,
12
+ AuthType,
13
+ ChatType,
14
+ Constants,
15
+ DeviceType,
16
+ ElementType,
17
+ MessageStatus,
18
+ MessageType,
19
+ Opcode,
20
+ )
21
+ from .types import (
22
+ Channel,
23
+ Chat,
24
+ Dialog,
25
+ Element,
26
+ Message,
27
+ User,
28
+ )
29
+
30
+ __author__ = "noxzion"
31
+
32
+ __all__ = [
33
+ # Перечисления и константы
34
+ "AccessType",
35
+ "AuthType",
36
+ # Типы данных
37
+ "Channel",
38
+ "Chat",
39
+ "ChatType",
40
+ "Constants",
41
+ "DeviceType",
42
+ "Dialog",
43
+ "Element",
44
+ "ElementType",
45
+ # Исключения
46
+ "InvalidPhoneError",
47
+ # Клиент
48
+ "MaxClient",
49
+ "Message",
50
+ "MessageStatus",
51
+ "MessageType",
52
+ "Opcode",
53
+ "User",
54
+ "WebSocketNotConnectedError",
55
+ ]
pymax/core.py ADDED
@@ -0,0 +1,600 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import re
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import websockets
11
+
12
+ from .crud import Database
13
+ from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
14
+ from .static import AuthType, ChatType, Constants, Opcode
15
+ from .types import Channel, Chat, Dialog, Message, User
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class MaxClient:
21
+ """
22
+ Основной клиент для работы с WebSocket API сервиса Max.
23
+
24
+
25
+ Args:
26
+ phone (str): Номер телефона для авторизации.
27
+ uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
28
+ work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
29
+ logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
30
+ логгер модуля с именем f"{__name__}.MaxClient".
31
+
32
+ Raises:
33
+ InvalidPhoneError: Если формат номера телефона неверный.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ phone: str,
39
+ uri: str = Constants.WEBSOCKET_URI.value,
40
+ work_dir: str = ".",
41
+ logger: logging.Logger | None = None,
42
+ ) -> None:
43
+ self.uri: str = uri
44
+ self.is_connected: bool = False
45
+ self.phone: str = phone
46
+ self.chats: list[Chat] = []
47
+ self.dialogs: list[Dialog] = []
48
+ self.channels: list[Channel] = []
49
+ self._users: dict[int, User] = {}
50
+ if not self._check_phone():
51
+ raise InvalidPhoneError(self.phone)
52
+ self._work_dir: str = work_dir
53
+ self._database_path: Path = Path(work_dir) / "session.db"
54
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
55
+ self._database_path.touch(exist_ok=True)
56
+ self._database = Database(self._work_dir)
57
+ self._ws: websockets.ClientConnection | None = None
58
+ self._seq: int = 0
59
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
60
+ self._recv_task: asyncio.Task[Any] | None = None
61
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
62
+ self._device_id = self._database.get_device_id()
63
+ self._token = self._database.get_auth_token()
64
+ self.user_agent = Constants.DEFAULT_USER_AGENT.value
65
+ self._on_message_handler: Callable[[Message], Any] | None = None
66
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
67
+ self._background_tasks: set[asyncio.Task[Any]] = set()
68
+ self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
69
+ self._setup_logger()
70
+
71
+ self.logger.debug("Initialized MaxClient uri=%s work_dir=%s", self.uri, self._work_dir)
72
+
73
+ def _setup_logger(self) -> None:
74
+ self.logger.setLevel(logging.INFO)
75
+
76
+ if not logger.handlers:
77
+ handler = logging.StreamHandler()
78
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
79
+ handler.setFormatter(formatter)
80
+ logger.addHandler(handler)
81
+
82
+ @property
83
+ def ws(self) -> websockets.ClientConnection:
84
+ if self._ws is None or not self.is_connected:
85
+ self.logger.critical("WebSocket not connected when access attempted")
86
+ raise WebSocketNotConnectedError
87
+ return self._ws
88
+
89
+ def on_message(
90
+ self, handler: Callable[[Message], Any | Awaitable[Any]]
91
+ ) -> Callable[[Message], Any | Awaitable[Any]]:
92
+ """
93
+ Устанавливает обработчик входящих сообщений.
94
+
95
+ Args:
96
+ handler: Функция или coroutine, принимающая объект Message.
97
+
98
+ Returns:
99
+ Установленный обработчик.
100
+ """
101
+ self._on_message_handler = handler
102
+ self.logger.debug("on_message handler set: %r", handler)
103
+ return handler
104
+
105
+ def on_start(
106
+ self, handler: Callable[[], Any | Awaitable[Any]]
107
+ ) -> Callable[[], Any | Awaitable[Any]]:
108
+ """
109
+ Устанавливает обработчик, вызываемый при старте клиента.
110
+
111
+ Args:
112
+ handler: Функция или coroutine без аргументов.
113
+
114
+ Returns:
115
+ Установленный обработчик.
116
+ """
117
+ self._on_start_handler = handler
118
+ self.logger.debug("on_start handler set: %r", handler)
119
+ return handler
120
+
121
+ def add_message_handler(
122
+ self, handler: Callable[[Message], Any | Awaitable[Any]]
123
+ ) -> Callable[[Message], Any | Awaitable[Any]]:
124
+ self.logger.debug("add_message_handler (alias) used")
125
+ self._on_message_handler = handler
126
+ return handler
127
+
128
+ def add_on_start_handler(
129
+ self, handler: Callable[[], Any | Awaitable[Any]]
130
+ ) -> Callable[[], Any | Awaitable[Any]]:
131
+ self.logger.debug("add_on_start_handler (alias) used")
132
+ self._on_start_handler = handler
133
+ return handler
134
+
135
+ def _check_phone(self) -> bool:
136
+ return bool(re.match(Constants.PHONE_REGEX.value, self.phone))
137
+
138
+ def _make_message(self, opcode: int, payload: dict[str, Any], cmd: int = 0) -> dict[str, Any]:
139
+ self._seq += 1
140
+ msg = {
141
+ "ver": 11,
142
+ "cmd": cmd,
143
+ "seq": self._seq,
144
+ "opcode": opcode,
145
+ "payload": payload,
146
+ }
147
+ self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
148
+ return msg
149
+
150
+ async def _send_interactive_ping(self) -> None:
151
+ while self.is_connected:
152
+ try:
153
+ await self._send_and_wait(
154
+ opcode=1,
155
+ payload={"interactive": True},
156
+ cmd=0,
157
+ )
158
+ self.logger.debug("Interactive ping sent successfully")
159
+ except Exception:
160
+ self.logger.warning("Interactive ping failed", exc_info=True)
161
+ await asyncio.sleep(30)
162
+
163
+ async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
164
+ try:
165
+ self.logger.info("Connecting to WebSocket %s", self.uri)
166
+ self._ws = await websockets.connect(self.uri)
167
+ self.is_connected = True
168
+ self._incoming = asyncio.Queue()
169
+ self._pending = {}
170
+ self._recv_task = asyncio.create_task(self._recv_loop())
171
+ self.logger.info("WebSocket connected, starting handshake")
172
+ return await self._handshake(user_agent)
173
+ except Exception as e:
174
+ self.logger.error("Failed to connect: %s", e, exc_info=True)
175
+ raise ConnectionError(f"Failed to connect: {e}")
176
+
177
+ async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
178
+ try:
179
+ self.logger.debug("Sending handshake with user_agent keys=%s", list(user_agent.keys()))
180
+ resp = await self._send_and_wait(
181
+ opcode=Opcode.HANDSHAKE,
182
+ payload={"deviceId": str(self._device_id), "userAgent": user_agent},
183
+ )
184
+ self.logger.info("Handshake completed")
185
+ return resp
186
+ except Exception as e:
187
+ self.logger.error("Handshake failed: %s", e, exc_info=True)
188
+ raise ConnectionError(f"Handshake failed: {e}")
189
+
190
+ async def _request_code(self, phone: str, language: str = "ru") -> dict[str, int | str]:
191
+ try:
192
+ self.logger.info("Requesting auth code")
193
+ payload = {
194
+ "phone": phone,
195
+ "type": AuthType.START_AUTH.value,
196
+ "language": language,
197
+ }
198
+ data = await self._send_and_wait(opcode=Opcode.REQUEST_CODE, payload=payload)
199
+ self.logger.debug(
200
+ "Code request response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
201
+ )
202
+ return data.get("payload")
203
+ except Exception:
204
+ self.logger.error("Request code failed", exc_info=True)
205
+ raise RuntimeError("Request code failed")
206
+
207
+ async def _send_code(self, code: str, token: str) -> dict[str, Any]:
208
+ try:
209
+ self.logger.info("Sending verification code")
210
+ payload = {
211
+ "token": token,
212
+ "verifyCode": code,
213
+ "authTokenType": AuthType.CHECK_CODE.value,
214
+ }
215
+ data = await self._send_and_wait(opcode=Opcode.SEND_CODE, payload=payload)
216
+ self.logger.debug(
217
+ "Send code response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
218
+ )
219
+ return data.get("payload")
220
+ except Exception:
221
+ self.logger.error("Send code failed", exc_info=True)
222
+ raise RuntimeError("Send code failed")
223
+
224
+ async def _recv_loop(self) -> None:
225
+ if self._ws is None:
226
+ self.logger.warning("Recv loop started without websocket instance")
227
+ return
228
+
229
+ self.logger.debug("Receive loop started")
230
+ while True:
231
+ try:
232
+ raw = await self._ws.recv()
233
+ try:
234
+ data = json.loads(raw)
235
+ except Exception:
236
+ self.logger.warning("JSON parse error", exc_info=True)
237
+ continue
238
+
239
+ seq = data.get("seq")
240
+ fut = self._pending.get(seq) if isinstance(seq, int) else None
241
+
242
+ if fut and not fut.done():
243
+ fut.set_result(data)
244
+ self.logger.debug("Matched response for pending seq=%s", seq)
245
+ else:
246
+ if self._incoming is not None:
247
+ try:
248
+ self._incoming.put_nowait(data)
249
+ except asyncio.QueueFull:
250
+ self.logger.warning(
251
+ "Incoming queue full; dropping message seq=%s", data.get("seq")
252
+ )
253
+
254
+ if data.get("opcode") == Opcode.NEW_MESSAGE and self._on_message_handler:
255
+ try:
256
+ payload = data.get("payload", {})
257
+ msg = Message.from_dict(payload.get("message"))
258
+ if msg:
259
+ result = self._on_message_handler(msg)
260
+ if asyncio.iscoroutine(result):
261
+ task = asyncio.create_task(result)
262
+ self._background_tasks.add(task)
263
+ task.add_done_callback(
264
+ lambda t: self._background_tasks.discard(t)
265
+ or self._log_task_exception(t)
266
+ )
267
+ except Exception:
268
+ self.logger.exception("Error in on_message_handler")
269
+
270
+ except websockets.exceptions.ConnectionClosed:
271
+ self.logger.info("WebSocket connection closed; exiting recv loop")
272
+ break
273
+ except Exception:
274
+ self.logger.exception("Error in recv_loop; backing off briefly")
275
+ await asyncio.sleep(0.5)
276
+
277
+ def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
278
+ try:
279
+ exc = task.exception()
280
+ if exc:
281
+ self.logger.exception("Background task exception: %s", exc)
282
+ except Exception:
283
+ # ignore inspection failures
284
+ pass
285
+
286
+ async def _send_and_wait(
287
+ self,
288
+ opcode: int,
289
+ payload: dict[str, Any],
290
+ cmd: int = 0,
291
+ timeout: float = Constants.DEFAULT_TIMEOUT.value,
292
+ ) -> dict[str, Any]:
293
+ # Проверка соединения — с логированием критичности
294
+ ws = self.ws # вызовет исключение и CRITICAL-лог, если не подключены
295
+
296
+ msg = self._make_message(opcode, payload, cmd)
297
+ loop = asyncio.get_running_loop()
298
+ fut: asyncio.Future[dict[str, Any]] = loop.create_future()
299
+ self._pending[msg["seq"]] = fut
300
+
301
+ try:
302
+ self.logger.debug("Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"])
303
+ await ws.send(json.dumps(msg))
304
+ data = await asyncio.wait_for(fut, timeout=timeout)
305
+ self.logger.debug(
306
+ "Received frame for seq=%s opcode=%s", data.get("seq"), data.get("opcode")
307
+ )
308
+ return data
309
+ except Exception:
310
+ self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
311
+ raise RuntimeError("Send and wait failed")
312
+ finally:
313
+ self._pending.pop(msg["seq"], None)
314
+
315
+ async def _sync(self) -> None:
316
+ try:
317
+ self.logger.info("Starting initial sync")
318
+ payload = {
319
+ "interactive": True,
320
+ "token": self._token,
321
+ "chatsSync": 0,
322
+ "contactsSync": 0,
323
+ "presenceSync": 0,
324
+ "draftsSync": 0,
325
+ "chatsCount": 40,
326
+ }
327
+ data = await self._send_and_wait(opcode=19, payload=payload)
328
+ if error := data.get("payload", {}).get("error"):
329
+ self.logger.error("Sync error: %s", error)
330
+ return
331
+
332
+ for raw_chat in data.get("payload", {}).get("chats", []):
333
+ try:
334
+ if raw_chat.get("type") == ChatType.DIALOG.value:
335
+ self.dialogs.append(Dialog.from_dict(raw_chat))
336
+ elif raw_chat.get("type") == ChatType.CHAT.value:
337
+ self.chats.append(Chat.from_dict(raw_chat))
338
+ elif raw_chat.get("type") == ChatType.CHANNEL.value:
339
+ self.channels.append(Channel.from_dict(raw_chat))
340
+ except Exception:
341
+ self.logger.exception("Error parsing chat entry")
342
+ self.logger.info(
343
+ "Sync completed: dialogs=%d chats=%d channels=%d",
344
+ len(self.dialogs),
345
+ len(self.chats),
346
+ len(self.channels),
347
+ )
348
+ except Exception:
349
+ self.logger.exception("Sync failed")
350
+
351
+ async def send_message(self, text: str, chat_id: int, notify: bool) -> Message | None:
352
+ """
353
+ Отправляет сообщение в чат.
354
+ """
355
+ try:
356
+ self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
357
+ payload = {
358
+ "chatId": chat_id,
359
+ "message": {
360
+ "text": text,
361
+ "cid": int(time.time() * 1000),
362
+ "elements": [],
363
+ "attaches": [],
364
+ },
365
+ "notify": notify,
366
+ }
367
+ data = await self._send_and_wait(opcode=Opcode.SEND_MESSAGE, payload=payload)
368
+ if error := data.get("payload", {}).get("error"):
369
+ self.logger.error("Send message error: %s", error)
370
+ msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
371
+ self.logger.debug("send_message result: %r", msg)
372
+ return msg
373
+ except Exception:
374
+ self.logger.exception("Send message failed")
375
+ return None
376
+
377
+ async def edit_message(self, chat_id: int, message_id: int, text: str) -> Message | None:
378
+ """
379
+ Редактирует сообщение.
380
+ """
381
+ try:
382
+ self.logger.info("Editing message chat_id=%s message_id=%s", chat_id, message_id)
383
+ payload = {
384
+ "chatId": chat_id,
385
+ "messageId": message_id,
386
+ "text": text,
387
+ "elements": [],
388
+ "attaches": [],
389
+ }
390
+ data = await self._send_and_wait(opcode=Opcode.EDIT_MESSAGE, payload=payload)
391
+ if error := data.get("payload", {}).get("error"):
392
+ self.logger.error("Edit message error: %s", error)
393
+ msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
394
+ self.logger.debug("edit_message result: %r", msg)
395
+ return msg
396
+ except Exception:
397
+ self.logger.exception("Edit message failed")
398
+ return None
399
+
400
+ async def delete_message(self, chat_id: int, message_ids: list[int], for_me: bool) -> bool:
401
+ """
402
+ Удаляет сообщения.
403
+ """
404
+ try:
405
+ self.logger.info(
406
+ "Deleting messages chat_id=%s ids=%s for_me=%s", chat_id, message_ids, for_me
407
+ )
408
+ payload = {"chatId": chat_id, "messageIds": message_ids, "forMe": for_me}
409
+ data = await self._send_and_wait(opcode=Opcode.DELETE_MESSAGE, payload=payload)
410
+ if error := data.get("payload", {}).get("error"):
411
+ self.logger.error("Delete message error: %s", error)
412
+ return False
413
+ self.logger.debug("delete_message success")
414
+ return True
415
+ except Exception:
416
+ self.logger.exception("Delete message failed")
417
+ return False
418
+
419
+ async def close(self) -> None:
420
+ try:
421
+ self.logger.info("Closing client")
422
+ if self._recv_task:
423
+ self._recv_task.cancel()
424
+ try:
425
+ await self._recv_task
426
+ except asyncio.CancelledError:
427
+ self.logger.debug("recv_task cancelled")
428
+ if self._ws:
429
+ await self._ws.close()
430
+ self.is_connected = False
431
+ self.logger.info("Client closed")
432
+ except Exception:
433
+ self.logger.exception("Error closing client")
434
+
435
+ def get_cached_user(self, user_id: int) -> User | None:
436
+ """
437
+ Получает юзера из кеша по его ID
438
+
439
+ Args:
440
+ user_id (int): ID пользователя.
441
+
442
+ Returns:
443
+ User | None: Объект User или None при ошибке.
444
+ """
445
+ user = self._users.get(user_id)
446
+ self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
447
+ return user
448
+
449
+ async def get_users(self, user_ids: list[int]) -> list[User]:
450
+ """
451
+ Получает информацию о пользователях по их ID (с кешем).
452
+ """
453
+ self.logger.debug("get_users ids=%s", user_ids)
454
+ cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
455
+ missing_ids = [uid for uid in user_ids if uid not in self._users]
456
+
457
+ if missing_ids:
458
+ self.logger.debug("Fetching missing users: %s", missing_ids)
459
+ fetched_users = await self.fetch_users(missing_ids)
460
+ if fetched_users:
461
+ for user in fetched_users:
462
+ self._users[user.id] = user
463
+ cached[user.id] = user
464
+
465
+ ordered = [cached[uid] for uid in user_ids if uid in cached]
466
+ self.logger.debug("get_users result_count=%d", len(ordered))
467
+ return ordered
468
+
469
+ async def get_user(self, user_id: int) -> User | None:
470
+ """
471
+ Получает информацию о пользователе по его ID (с кешем).
472
+ """
473
+ self.logger.debug("get_user id=%s", user_id)
474
+ if user_id in self._users:
475
+ return self._users[user_id]
476
+
477
+ users = await self.fetch_users([user_id])
478
+ if users:
479
+ self._users[user_id] = users[0]
480
+ return users[0]
481
+ return None
482
+
483
+ async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
484
+ """
485
+ Получает информацию о пользователях по их ID.
486
+ """
487
+ try:
488
+ self.logger.info("Fetching users count=%d", len(user_ids))
489
+ payload = {"contactIds": user_ids}
490
+
491
+ data = await self._send_and_wait(opcode=Opcode.GET_CONTACTS_INFO, payload=payload)
492
+ if error := data.get("payload", {}).get("error"):
493
+ self.logger.error("Fetch users error: %s", error)
494
+ return None
495
+
496
+ users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
497
+ for user in users:
498
+ self._users[user.id] = user
499
+
500
+ self.logger.debug("Fetched users: %d", len(users))
501
+ return users
502
+ except Exception:
503
+ self.logger.exception("Fetch users failed")
504
+ return []
505
+
506
+ async def fetch_history(
507
+ self,
508
+ chat_id: int,
509
+ from_time: int | None = None,
510
+ forward: int = 0,
511
+ backward: int = 200,
512
+ ) -> list[Message] | None:
513
+ """
514
+ Получает историю сообщений чата.
515
+ """
516
+ if from_time is None:
517
+ from_time = int(time.time() * 1000)
518
+
519
+ try:
520
+ self.logger.info(
521
+ "Fetching history chat_id=%s from=%s forward=%s backward=%s",
522
+ chat_id,
523
+ from_time,
524
+ forward,
525
+ backward,
526
+ )
527
+ payload = {
528
+ "chatId": chat_id,
529
+ "from": from_time,
530
+ "forward": forward,
531
+ "backward": backward,
532
+ "getMessages": True,
533
+ }
534
+
535
+ data = await self._send_and_wait(opcode=Opcode.FETCH_HISTORY, payload=payload)
536
+ if error := data.get("payload", {}).get("error"):
537
+ self.logger.error("Fetch history error: %s", error)
538
+ return None
539
+ messages = [Message.from_dict(msg) for msg in data["payload"].get("messages", [])]
540
+ self.logger.debug("History fetched: %d messages", len(messages))
541
+ return messages
542
+ except Exception:
543
+ self.logger.exception("Fetch history failed")
544
+ return None
545
+
546
+ async def _login(self) -> None:
547
+ self.logger.info("Starting login flow")
548
+ request_code_payload = await self._request_code(self.phone)
549
+ temp_token = request_code_payload.get("token")
550
+ if not temp_token or not isinstance(temp_token, str):
551
+ self.logger.critical("Failed to request code: token missing")
552
+ raise ValueError("Failed to request code")
553
+
554
+ code = await asyncio.to_thread(input, "Введите код: ")
555
+ if len(code) != 6 or not code.isdigit():
556
+ self.logger.error("Invalid code format entered")
557
+ raise ValueError("Invalid code format")
558
+
559
+ login_resp = await self._send_code(code, temp_token)
560
+ token: str | None = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
561
+ if not token:
562
+ self.logger.critical("Failed to login, token not received")
563
+ raise ValueError("Failed to login, token not received")
564
+
565
+ self._token = token
566
+ self._database.update_auth_token(self._device_id, self._token)
567
+ self.logger.info("Login successful, token saved to database")
568
+
569
+ async def start(self) -> None:
570
+ """
571
+ Запускает клиент, подключается к WebSocket, авторизует
572
+ пользователя (если нужно) и запускает фоновый цикл.
573
+ """
574
+ try:
575
+ self.logger.info("Client starting")
576
+ await self._connect(self.user_agent)
577
+ if self._token is None:
578
+ await self._login()
579
+ else:
580
+ await self._sync()
581
+
582
+ if self._on_start_handler:
583
+ self.logger.debug("Calling on_start handler")
584
+ result = self._on_start_handler()
585
+ if asyncio.iscoroutine(result):
586
+ await result
587
+
588
+ if self._ws:
589
+ ping_task = asyncio.create_task(self._send_interactive_ping())
590
+ self._background_tasks.add(ping_task)
591
+ ping_task.add_done_callback(
592
+ lambda t: self._background_tasks.discard(t) or self._log_task_exception(t)
593
+ )
594
+
595
+ try:
596
+ await self._ws.wait_closed()
597
+ except asyncio.CancelledError:
598
+ self.logger.debug("wait_closed cancelled")
599
+ except Exception:
600
+ self.logger.exception("Client start failed")
pymax/crud.py ADDED
@@ -0,0 +1,99 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy.engine.base import Engine
4
+ from sqlmodel import Session, SQLModel, create_engine, select
5
+
6
+ from .models import Auth
7
+ from .static import DeviceType
8
+
9
+
10
+ class Database:
11
+ def __init__(self, workdir: str) -> None:
12
+ self.workdir = workdir
13
+ self.engine = self.get_engine(workdir)
14
+ self.create_all()
15
+ self._ensure_single_auth()
16
+
17
+ self.workdir = workdir
18
+ self.engine = self.get_engine(workdir)
19
+ self.create_all()
20
+ self._ensure_single_auth()
21
+
22
+ def create_all(self) -> None:
23
+ SQLModel.metadata.create_all(self.engine)
24
+
25
+ def get_engine(self, workdir: str) -> Engine:
26
+ return create_engine(f"sqlite:///{workdir}/session.db")
27
+
28
+ def get_session(self) -> Session:
29
+ return Session(bind=self.engine)
30
+
31
+ def get_auth_token(self) -> str | None:
32
+ with self.get_session() as session:
33
+ return session.exec(select(Auth.token)).first()
34
+
35
+ def get_device_id(self) -> UUID:
36
+ with self.get_session() as session:
37
+ device_id = session.exec(select(Auth.device_id)).first()
38
+ if device_id is None:
39
+ auth = Auth()
40
+ session.add(auth)
41
+ session.commit()
42
+ session.refresh(auth)
43
+ return auth.device_id
44
+ return device_id
45
+
46
+ def insert_auth(self, auth: Auth) -> Auth:
47
+ with self.get_session() as session:
48
+ session.add(auth)
49
+ session.commit()
50
+ session.refresh(auth)
51
+ return auth
52
+
53
+ def update_auth_token(self, device_id: UUID, token: str) -> None:
54
+ with self.get_session() as session:
55
+ auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
56
+ if auth:
57
+ auth.token = token
58
+ session.add(auth)
59
+ session.commit()
60
+ session.refresh(auth)
61
+ return
62
+
63
+ existing = session.exec(select(Auth)).first()
64
+ if existing:
65
+ existing.device_id = device_id
66
+ existing.token = token
67
+ session.add(existing)
68
+ session.commit()
69
+ session.refresh(existing)
70
+ return
71
+
72
+ new_auth = Auth(device_id=device_id, token=token)
73
+ session.add(new_auth)
74
+ session.commit()
75
+ session.refresh(new_auth)
76
+
77
+ def update(self, auth: Auth) -> Auth:
78
+ with self.get_session() as session:
79
+ session.add(auth)
80
+ session.commit()
81
+ session.refresh(auth)
82
+ return auth
83
+
84
+ def _ensure_single_auth(self) -> None:
85
+ with self.get_session() as session:
86
+ rows = session.exec(select(Auth)).all()
87
+ if not rows:
88
+ # Create default Auth with device type from enum
89
+ auth = Auth(device_type=DeviceType.WEB.value)
90
+ session.add(auth)
91
+ session.commit()
92
+ session.refresh(auth)
93
+ return
94
+
95
+ if len(rows) > 1:
96
+ keeper = rows[0]
97
+ for extra in rows[1:]:
98
+ session.delete(extra)
99
+ session.commit()
pymax/exceptions.py ADDED
@@ -0,0 +1,20 @@
1
+ class InvalidPhoneError(Exception):
2
+ """
3
+ Исключение, вызываемое при неверном формате номера телефона.
4
+
5
+ Args:
6
+ phone (str): Некорректный номер телефона.
7
+ """
8
+
9
+ def __init__(self, phone: str) -> None:
10
+ super().__init__(f"Invalid phone number format: {phone}")
11
+
12
+
13
+ class WebSocketNotConnectedError(Exception):
14
+ """
15
+ Исключение, вызываемое при попытке обращения к WebSocket,
16
+ если соединение не установлено.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__("WebSocket is not connected")
pymax/models.py ADDED
@@ -0,0 +1,8 @@
1
+ from uuid import UUID, uuid4
2
+
3
+ from sqlmodel import Field, SQLModel
4
+
5
+
6
+ class Auth(SQLModel, table=True):
7
+ token: str | None = None
8
+ device_id: UUID = Field(default_factory=uuid4, primary_key=True)
pymax/static.py ADDED
@@ -0,0 +1,86 @@
1
+ from enum import Enum, IntEnum
2
+
3
+
4
+ class Opcode(IntEnum):
5
+ PING = 1
6
+ STATS = 5
7
+ HANDSHAKE = 6
8
+ PROFILE = 16
9
+ REQUEST_CODE = 17
10
+ SEND_CODE = 18
11
+ SYNC = 19
12
+ UNKNOWN_26 = 26
13
+ SYNC_STICKERS_EMOJIS = 27
14
+ GET_EMOJIS_BY_ID = 28
15
+ GET_CONTACTS_INFO = 32
16
+ GET_LAST_SEEN = 35
17
+ GET_CHATS_DATA = 48
18
+ FETCH_HISTORY = 49
19
+
20
+ GET_HISTORY = 79
21
+
22
+ SEND_MESSAGE = 64
23
+ EDIT_MESSAGE = 67
24
+ DELETE_MESSAGE = 68
25
+
26
+ NEW_MESSAGE = 128
27
+
28
+
29
+ class ChatType(str, Enum):
30
+ DIALOG = "DIALOG"
31
+ CHAT = "CHAT"
32
+ CHANNEL = "CHANNEL"
33
+
34
+
35
+ class MessageType(str, Enum):
36
+ TEXT = "TEXT"
37
+ SYSTEM = "SYSTEM"
38
+ SERVICE = "SERVICE"
39
+
40
+
41
+ class MessageStatus(str, Enum):
42
+ SENT = "SENT"
43
+ DELIVERED = "DELIVERED"
44
+ READ = "READ"
45
+ ERROR = "ERROR"
46
+
47
+
48
+ class ElementType(str, Enum):
49
+ TEXT = "text"
50
+ MENTION = "mention"
51
+ LINK = "link"
52
+ EMOJI = "emoji"
53
+
54
+
55
+ class AuthType(str, Enum):
56
+ START_AUTH = "START_AUTH"
57
+ CHECK_CODE = "CHECK_CODE"
58
+
59
+
60
+ class AccessType(str, Enum):
61
+ PUBLIC = "PUBLIC"
62
+ PRIVATE = "PRIVATE"
63
+ SECRET = "SECRET"
64
+
65
+
66
+ class DeviceType(str, Enum):
67
+ WEB = "WEB"
68
+ ANDROID = "ANDROID"
69
+ IOS = "IOS"
70
+
71
+
72
+ class Constants(Enum):
73
+ PHONE_REGEX = r"^\+?\d{10,15}$"
74
+ WEBSOCKET_URI = "wss://ws-api.oneme.ru/websocket"
75
+ DEFAULT_TIMEOUT = 10.0
76
+ DEFAULT_USER_AGENT = {
77
+ "deviceType": "WEB",
78
+ "locale": "ru",
79
+ "deviceLocale": "ru",
80
+ "osVersion": "Linux",
81
+ "deviceName": "Chrome",
82
+ "headerUserAgent": "Mozilla/5.0 ...",
83
+ "appVersion": "25.8.5",
84
+ "screen": "1080x1920 1.0x",
85
+ "timezone": "Europe/Moscow",
86
+ }
pymax/types.py ADDED
@@ -0,0 +1,327 @@
1
+ from typing import Any
2
+
3
+ from .static import AccessType, ChatType, ElementType, MessageStatus, MessageType
4
+
5
+
6
+ class Element:
7
+ def __init__(self, type: ElementType | str, length: int, from_: int | None = None) -> None:
8
+ self.type = type
9
+ self.length = length
10
+ self.from_ = from_
11
+
12
+ @classmethod
13
+ def from_dict(cls, data: dict[Any, Any]) -> "Element":
14
+ return cls(type=data["type"], length=data["length"], from_=data.get("from"))
15
+
16
+ def __repr__(self) -> str:
17
+ return f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})"
18
+
19
+ def __str__(self) -> str:
20
+ return f"{self.type}({self.length})"
21
+
22
+
23
+ class Message:
24
+ def __init__(
25
+ self,
26
+ sender: int | None,
27
+ elements: list[Element] | None,
28
+ reaction_info: dict[str, Any] | None,
29
+ options: int | None,
30
+ id: int,
31
+ time: int,
32
+ text: str,
33
+ status: MessageStatus | str | None,
34
+ type: MessageType | str,
35
+ attaches: list[Any],
36
+ ) -> None:
37
+ self.sender = sender
38
+ self.elements = elements
39
+ self.options = options
40
+ self.id = id
41
+ self.time = time
42
+ self.text = text
43
+ self.type = type
44
+ self.attaches = attaches
45
+ self.status = status
46
+ self.reactionInfo = reaction_info
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict[Any, Any]) -> "Message":
50
+ return cls(
51
+ sender=data.get("sender"),
52
+ elements=[Element.from_dict(e) for e in data.get("elements", [])],
53
+ options=data.get("options"),
54
+ id=data["id"],
55
+ time=data["time"],
56
+ text=data["text"],
57
+ type=data["type"],
58
+ attaches=data.get("attaches", []),
59
+ status=data.get("status"),
60
+ reaction_info=data.get("reactionInfo"),
61
+ )
62
+
63
+ def __repr__(self) -> str:
64
+ return (
65
+ f"Message(id={self.id!r}, sender={self.sender!r}, text={self.text!r}, "
66
+ f"type={self.type!r}, status={self.status!r}, elements={self.elements!r})"
67
+ )
68
+
69
+ def __str__(self) -> str:
70
+ return f"Message {self.id} from {self.sender}: {self.text}"
71
+
72
+
73
+ class Dialog:
74
+ def __init__(
75
+ self,
76
+ cid: int | None,
77
+ owner: int,
78
+ has_bots: bool | None,
79
+ join_time: int,
80
+ created: int,
81
+ last_message: Message | None,
82
+ type: ChatType | str,
83
+ last_fire_delayed_error_time: int,
84
+ last_delayed_update_time: int,
85
+ prev_message_id: str | None,
86
+ options: dict[str, bool],
87
+ modified: int,
88
+ last_event_time: int,
89
+ id: int,
90
+ status: str,
91
+ participants: dict[str, int],
92
+ ) -> None:
93
+ self.cid = cid
94
+ self.owner = owner
95
+ self.has_bots = has_bots
96
+ self.join_time = join_time
97
+ self.created = created
98
+ self.last_message = last_message
99
+ self.type = type
100
+ self.last_fire_delayed_error_time = last_fire_delayed_error_time
101
+ self.last_delayed_update_time = last_delayed_update_time
102
+ self.prev_message_id = prev_message_id
103
+ self.options = options
104
+ self.modified = modified
105
+ self.last_event_time = last_event_time
106
+ self.id = id
107
+ self.status = status
108
+ self.participants = participants
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[Any, Any]) -> "Dialog":
112
+ return cls(
113
+ cid=data.get("cid"),
114
+ owner=data["owner"],
115
+ has_bots=data.get("hasBots"),
116
+ join_time=data["joinTime"],
117
+ created=data["created"],
118
+ last_message=Message.from_dict(data["lastMessage"])
119
+ if data.get("lastMessage")
120
+ else None,
121
+ type=ChatType(data["type"]),
122
+ last_fire_delayed_error_time=data["lastFireDelayedErrorTime"],
123
+ last_delayed_update_time=data["lastDelayedUpdateTime"],
124
+ prev_message_id=data.get("prevMessageId"),
125
+ options=data.get("options", {}),
126
+ modified=data["modified"],
127
+ last_event_time=data["lastEventTime"],
128
+ id=data["id"],
129
+ status=data["status"],
130
+ participants=data["participants"],
131
+ )
132
+
133
+ def __repr__(self) -> str:
134
+ return f"Dialog(id={self.id!r}, owner={self.owner!r}, type={self.type!r}, last_message={self.last_message!r})"
135
+
136
+ def __str__(self) -> str:
137
+ return f"Dialog {self.id} ({self.type})"
138
+
139
+
140
+ class Chat:
141
+ def __init__(
142
+ self,
143
+ participants_count: int,
144
+ access: AccessType | str,
145
+ invited_by: int | None,
146
+ link: str | None,
147
+ chat_type: ChatType | str,
148
+ title: str | None,
149
+ last_fire_delayed_error_time: int,
150
+ last_delayed_update_time: int,
151
+ options: dict[str, bool],
152
+ base_raw_icon_url: str | None,
153
+ base_icon_url: str | None,
154
+ description: str | None,
155
+ modified: int,
156
+ id_: int,
157
+ admin_participants: dict[int, dict[Any, Any]],
158
+ participants: dict[int, int],
159
+ owner: int,
160
+ join_time: int,
161
+ created: int,
162
+ last_message: Message | None,
163
+ prev_message_id: str | None,
164
+ last_event_time: int,
165
+ messages_count: int,
166
+ admins: list[int],
167
+ restrictions: int | None,
168
+ status: str,
169
+ cid: int,
170
+ ) -> None:
171
+ self.participants_count = participants_count
172
+ self.access = access
173
+ self.invited_by = invited_by
174
+ self.link = link
175
+ self.type = chat_type
176
+ self.title = title
177
+ self.last_fire_delayed_error_time = last_fire_delayed_error_time
178
+ self.last_delayed_update_time = last_delayed_update_time
179
+ self.options = options
180
+ self.base_raw_icon_url = base_raw_icon_url
181
+ self.base_icon_url = base_icon_url
182
+ self.description = description
183
+ self.modified = modified
184
+ self.id = id_
185
+ self.admin_participants = admin_participants
186
+ self.participants = participants
187
+ self.owner = owner
188
+ self.join_time = join_time
189
+ self.created = created
190
+ self.last_message = last_message
191
+ self.prev_message_id = prev_message_id
192
+ self.last_event_time = last_event_time
193
+ self.messages_count = messages_count
194
+ self.admins = admins
195
+ self.restrictions = restrictions
196
+ self.status = status
197
+ self.cid = cid
198
+
199
+ @classmethod
200
+ def from_dict(cls, data: dict[Any, Any]) -> "Chat":
201
+ raw_admins = data.get("adminParticipants", {}) or {}
202
+ admin_participants: dict[int, dict[Any, Any]] = {int(k): v for k, v in raw_admins.items()}
203
+ raw_participants = data.get("participants", {}) or {}
204
+ participants: dict[int, int] = {int(k): v for k, v in raw_participants.items()}
205
+ last_msg = Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None
206
+ return cls(
207
+ participants_count=data.get("participantsCount", 0),
208
+ access=AccessType(data.get("access", AccessType.PUBLIC.value)),
209
+ invited_by=data.get("invitedBy"),
210
+ link=data.get("link"),
211
+ base_raw_icon_url=data.get("baseRawIconUrl"),
212
+ base_icon_url=data.get("baseIconUrl"),
213
+ description=data.get("description"),
214
+ chat_type=ChatType(data.get("type", ChatType.CHAT.value)),
215
+ title=data.get("title"),
216
+ last_fire_delayed_error_time=data.get("lastFireDelayedErrorTime", 0),
217
+ last_delayed_update_time=data.get("lastDelayedUpdateTime", 0),
218
+ options=data.get("options", {}),
219
+ modified=data.get("modified", 0),
220
+ id_=data.get("id", 0),
221
+ admin_participants=admin_participants,
222
+ participants=participants,
223
+ owner=data.get("owner", 0),
224
+ join_time=data.get("joinTime", 0),
225
+ created=data.get("created", 0),
226
+ last_message=last_msg,
227
+ prev_message_id=data.get("prevMessageId"),
228
+ last_event_time=data.get("lastEventTime", 0),
229
+ messages_count=data.get("messagesCount", 0),
230
+ admins=data.get("admins", []),
231
+ restrictions=data.get("restrictions"),
232
+ status=data.get("status", ""),
233
+ cid=data.get("cid", 0),
234
+ )
235
+
236
+ def __repr__(self) -> str:
237
+ return f"Chat(id={self.id!r}, title={self.title!r}, type={self.type!r})"
238
+
239
+ def __str__(self) -> str:
240
+ return f"{self.title} ({self.type})"
241
+
242
+
243
+ class Channel(Chat):
244
+ def __repr__(self) -> str:
245
+ return f"Channel(id={self.id!r}, title={self.title!r})"
246
+
247
+ def __str__(self) -> str:
248
+ return f"Channel: {self.title}"
249
+
250
+
251
+ class Names:
252
+ def __init__(self, name: str, first_name: str, last_name: str | None, type: str) -> None:
253
+ self.name = name
254
+ self.first_name = first_name
255
+ self.last_name = last_name
256
+ self.type = type
257
+
258
+ @classmethod
259
+ def from_dict(cls, data: dict[str, Any]) -> "Names":
260
+ return cls(
261
+ name=data["name"],
262
+ first_name=data["firstName"],
263
+ last_name=data.get("lastName"),
264
+ type=data["type"],
265
+ )
266
+
267
+ def __repr__(self) -> str:
268
+ return f"Names(name={self.name!r}, first_name={self.first_name!r}, last_name={self.last_name!r}, type={self.type!r})"
269
+
270
+ def __str__(self) -> str:
271
+ return self.name
272
+
273
+
274
+ class User:
275
+ def __init__(
276
+ self,
277
+ account_status: int,
278
+ update_time: int,
279
+ id: int,
280
+ names: list[Names],
281
+ options: list[str] | None = None,
282
+ base_url: str | None = None,
283
+ base_raw_url: str | None = None,
284
+ photo_id: int | None = None,
285
+ description: str | None = None,
286
+ gender: int | None = None,
287
+ link: str | None = None,
288
+ web_app: str | None = None,
289
+ menu_button: dict[str, Any] | None = None,
290
+ ) -> None:
291
+ self.account_status = account_status
292
+ self.update_time = update_time
293
+ self.id = id
294
+ self.names = names
295
+ self.options = options or []
296
+ self.base_url = base_url
297
+ self.base_raw_url = base_raw_url
298
+ self.photo_id = photo_id
299
+ self.description = description
300
+ self.gender = gender
301
+ self.link = link
302
+ self.web_app = web_app
303
+ self.menu_button = menu_button
304
+
305
+ @classmethod
306
+ def from_dict(cls, data: dict[str, Any]) -> "User":
307
+ return cls(
308
+ account_status=data["accountStatus"],
309
+ update_time=data["updateTime"],
310
+ id=data["id"],
311
+ names=[Names.from_dict(n) for n in data.get("names", [])],
312
+ options=data.get("options"),
313
+ base_url=data.get("baseUrl"),
314
+ base_raw_url=data.get("baseRawUrl"),
315
+ photo_id=data.get("photoId"),
316
+ description=data.get("description"),
317
+ gender=data.get("gender"),
318
+ link=data.get("link"),
319
+ web_app=data.get("webApp"),
320
+ menu_button=data.get("menuButton"),
321
+ )
322
+
323
+ def __repr__(self) -> str:
324
+ return f"User(id={self.id!r}, names={self.names!r}, status={self.account_status!r})"
325
+
326
+ def __str__(self) -> str:
327
+ return f"User {self.id}: {', '.join(str(n) for n in self.names)}"