diff --git a/examples/telegram_bridge.py b/examples/telegram_bridge.py index 6c3c51f..3d4791c 100644 --- a/examples/telegram_bridge.py +++ b/examples/telegram_bridge.py @@ -75,14 +75,17 @@ async def handle_message(message: Message) -> None: async with session.get(video.url) as response: response.raise_for_status() # Проверка на ошибки HTTP video_bytes = BytesIO(await response.read()) - video_bytes.name = response.headers.get("X-File-Name") + video_bytes.name = response.headers.get( + "X-File-Name" + ) # Отправляем видео через телеграм бота await telegram_bot.send_video( chat_id=tg_id, caption=f"{sender.names[0].name}: {message.text}", video=types.BufferedInputFile( - video_bytes.getvalue(), filename=video_bytes.name + video_bytes.getvalue(), + filename=video_bytes.name, ), ) @@ -102,14 +105,17 @@ async def handle_message(message: Message) -> None: async with session.get(attach.base_url) as response: response.raise_for_status() # Проверка на ошибки HTTP photo_bytes = BytesIO(await response.read()) - photo_bytes.name = response.headers.get("X-File-Name") + photo_bytes.name = response.headers.get( + "X-File-Name" + ) # Отправляем фото через телеграм бота await telegram_bot.send_photo( chat_id=tg_id, caption=f"{sender.names[0].name}: {message.text}", photo=types.BufferedInputFile( - photo_bytes.getvalue(), filename=photo_bytes.name + photo_bytes.getvalue(), + filename=photo_bytes.name, ), ) @@ -136,7 +142,9 @@ async def handle_message(message: Message) -> None: async with session.get(file.url) as response: response.raise_for_status() # Проверка на ошибки HTTP file_bytes = BytesIO(await response.read()) - file_bytes.name = response.headers.get("X-File-Name") + file_bytes.name = response.headers.get( + "X-File-Name" + ) # Отправляем файл через телеграм бота await telegram_bot.send_document( diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 37c7bb4..419e87f 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -9,14 +9,22 @@ from .exceptions import ( InvalidPhoneError, LoginError, + ResponseError, + ResponseStructureError, + SocketNotConnectedError, + SocketSendError, WebSocketNotConnectedError, ) from .static.enum import ( AccessType, + AttachType, AuthType, ChatType, + ContactAction, DeviceType, ElementType, + FormattingType, + MarkupType, MessageStatus, MessageType, Opcode, @@ -24,10 +32,26 @@ from .types import ( Channel, Chat, + Contact, + ControlAttach, Dialog, Element, + FileAttach, + FileRequest, + Me, + Member, Message, + MessageLink, + Name, + Names, + PhotoAttach, + Presence, + ReactionCounter, + ReactionInfo, + Session, User, + VideoAttach, + VideoRequest, ) __author__ = "ink-developer" @@ -35,19 +59,43 @@ __all__ = [ # Перечисления и константы "AccessType", + "AttachType", "AuthType", + "ContactAction", + "FormattingType", + "MarkupType", # Типы данных "Channel", "Chat", "ChatType", + "Contact", + "ControlAttach", "DeviceType", "Dialog", "Element", "ElementType", + "FileAttach", + "FileRequest", + "Me", + "Member", + "MessageLink", + "Name", + "Names", + "PhotoAttach", + "Presence", + "ReactionCounter", + "ReactionInfo", + "Session", + "VideoAttach", + "VideoRequest", # Исключения "InvalidPhoneError", "LoginError", "WebSocketNotConnectedError", + "ResponseError", + "ResponseStructureError", + "SocketNotConnectedError", + "SocketSendError", # Клиент "MaxClient", "Message", diff --git a/src/pymax/core.py b/src/pymax/core.py index b5cd42b..bd34f0c 100644 --- a/src/pymax/core.py +++ b/src/pymax/core.py @@ -4,7 +4,7 @@ import ssl import time from pathlib import Path -from typing import Literal +from typing import Any, Literal from typing_extensions import override @@ -88,7 +88,9 @@ def __init__( self._circuit_breaker: bool = False self._last_error_time: float = 0.0 self._device_id = self._database.get_device_id() - self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} + self._file_upload_waiters: dict[ + int, asyncio.Future[dict[str, Any]] + ] = {} self._token = self._database.get_auth_token() or token self.user_agent = headers self._send_fake_telemetry: bool = send_fake_telemetry diff --git a/src/pymax/files.py b/src/pymax/files.py index 1a360cf..7e4bdd5 100644 --- a/src/pymax/files.py +++ b/src/pymax/files.py @@ -9,7 +9,9 @@ class BaseFile(ABC): - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, url: str | None = None, path: str | None = None + ) -> None: self.url = url self.path = path @@ -45,7 +47,9 @@ class Photo(BaseFile): ".bmp", } # FIXME: костыль ✅ - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, url: str | None = None, path: str | None = None + ) -> None: super().__init__(url, path) def validate_photo(self) -> tuple[str, str] | None: @@ -67,7 +71,9 @@ def validate_photo(self) -> tuple[str, str] | None: mime_type = mimetypes.guess_type(self.url)[0] if not mime_type or not mime_type.startswith("image/"): - raise ValueError(f"URL does not appear to be an image: {self.url}") + raise ValueError( + f"URL does not appear to be an image: {self.url}" + ) return (extension[1:], mime_type) return None @@ -84,7 +90,9 @@ async def read(self) -> bytes: class File(BaseFile): - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, url: str | None = None, path: str | None = None + ) -> None: self.file_name: str = "" if path: self.file_name = Path(path).name diff --git a/src/pymax/interfaces.py b/src/pymax/interfaces.py index 413762b..17184a9 100644 --- a/src/pymax/interfaces.py +++ b/src/pymax/interfaces.py @@ -9,8 +9,6 @@ import websockets -from pymax.static.constant import DEFAULT_USER_AGENT - from .filters import Filter from .payloads import UserAgentPayload from .static.constant import DEFAULT_TIMEOUT @@ -50,14 +48,15 @@ def __init__(self, logger: Logger) -> None: self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {} self._recv_task: asyncio.Task[Any] | None = None self._incoming: asyncio.Queue[dict[str, Any]] | None = None - self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} + self._file_upload_waiters: dict[ + int, asyncio.Future[dict[str, Any]] + ] = {} self.user_agent = UserAgentPayload() self._outgoing: asyncio.Queue[dict[str, Any]] | None = None self._outgoing_task: asyncio.Task[Any] | None = None self._error_count: int = 0 self._circuit_breaker: bool = False self._last_error_time: float = 0.0 - self.user_agent = DEFAULT_USER_AGENT self._session_id: int self._action_id: int = 0 self._current_screen: str = "chats_list_tab" @@ -70,7 +69,9 @@ def __init__(self, logger: Logger) -> None: self._on_message_delete_handlers: list[ tuple[Callable[[Message], Any], Filter | None] ] = [] - self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None + self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = ( + None + ) self._background_tasks: set[asyncio.Task[Any]] = set() self._ssl_context: ssl.SSLContext self._socket: socket.socket | None = None diff --git a/src/pymax/mixins/auth.py b/src/pymax/mixins/auth.py index e87c826..c42019e 100644 --- a/src/pymax/mixins/auth.py +++ b/src/pymax/mixins/auth.py @@ -55,7 +55,9 @@ async def _send_code(self, code: str, token: str) -> dict[str, Any]: auth_token_type=AuthType.CHECK_CODE, ).model_dump(by_alias=True) - data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload) + data = await self._send_and_wait( + opcode=Opcode.AUTH, payload=payload + ) self.logger.debug( "Send code response opcode=%s seq=%s", data.get("opcode"), @@ -128,7 +130,9 @@ async def _submit_reg_info( self.logger.error("Submit registration info failed", exc_info=True) raise RuntimeError("Submit registration info failed") - async def _register(self, first_name: str, last_name: str | None = None) -> None: + async def _register( + self, first_name: str, last_name: str | None = None + ) -> None: self.logger.info("Starting registration flow") request_code_payload = await self._request_code(self.phone) @@ -146,7 +150,9 @@ async def _register(self, first_name: str, last_name: str | None = None) -> None registration_response = await self._send_code(code, temp_token) token: str | None = ( - registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token") + registration_response.get("tokenAttrs", {}) + .get("REGISTER", {}) + .get("token") ) if not token: self.logger.critical("Failed to register, token not received") diff --git a/src/pymax/mixins/message.py b/src/pymax/mixins/message.py index 6c07c0b..e44afd3 100644 --- a/src/pymax/mixins/message.py +++ b/src/pymax/mixins/message.py @@ -50,8 +50,14 @@ async def _upload_file(self, file: File) -> None | Attach: self.logger.error("Upload file error: %s", error) return None - url = data.get("payload", {}).get("info", [None])[0].get("url", None) - file_id = data.get("payload", {}).get("info", [None])[0].get("fileId", None) + url = ( + data.get("payload", {}).get("info", [None])[0].get("url", None) + ) + file_id = ( + data.get("payload", {}) + .get("info", [None])[0] + .get("fileId", None) + ) if not url or not file_id: self.logger.error("No upload URL or file ID received") return None @@ -79,7 +85,9 @@ async def _upload_file(self, file: File) -> None | Attach: ) as response, ): if response.status != 200: - self.logger.error(f"Upload failed with status {response.status}") + self.logger.error( + f"Upload failed with status {response.status}" + ) # cleanup waiter self._file_upload_waiters.pop(int(file_id), None) return None @@ -137,7 +145,9 @@ async def _upload_photo(self, photo: Photo) -> None | Attach: ) as response, ): if response.status != 200: - self.logger.error(f"Upload failed with status {response.status}") + self.logger.error( + f"Upload failed with status {response.status}" + ) return None result = await response.json() @@ -164,9 +174,9 @@ async def _upload_attachment(self, attach: Photo | File) -> dict | None: if isinstance(attach, Photo): uploaded = await self._upload_photo(attach) if uploaded and uploaded.photo_token: - return AttachPhotoPayload(photo_token=uploaded.photo_token).model_dump( - by_alias=True - ) + return AttachPhotoPayload( + photo_token=uploaded.photo_token + ).model_dump(by_alias=True) elif isinstance(attach, File): uploaded = await self._upload_file(attach) if uploaded and uploaded.file_id: @@ -190,16 +200,22 @@ async def send_message( Отправляет сообщение в чат. """ try: - self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify) + self.logger.info( + "Sending message to chat_id=%s notify=%s", chat_id, notify + ) if attachments and attachment: - self.logger.warning("Both photo and photos provided; using photos") + self.logger.warning( + "Both photo and photos provided; using photos" + ) attachment = None attaches = [] if attachment: self.logger.info("Uploading attachment for message") result = await self._upload_attachment(attachment) if not result: - self.logger.error("Attachment upload failed, message not sent") + self.logger.error( + "Attachment upload failed, message not sent" + ) return None attaches.append(result) @@ -235,13 +251,19 @@ async def send_message( cid=int(time.time() * 1000), elements=elements, attaches=attaches, - link=(ReplyLink(message_id=str(reply_to)) if reply_to else None), + link=( + ReplyLink(message_id=str(reply_to)) + if reply_to + else None + ), ), notify=notify, ).model_dump(by_alias=True) if use_queue: - await self._queue_message(opcode=Opcode.MSG_SEND, payload=payload) + await self._queue_message( + opcode=Opcode.MSG_SEND, payload=payload + ) self.logger.debug("Message queued for sending") return None else: @@ -252,7 +274,9 @@ async def send_message( self.logger.error("Send message error: %s", error) return None msg = ( - Message.from_dict(data["payload"]) if data.get("payload") else None + Message.from_dict(data["payload"]) + if data.get("payload") + else None ) self.logger.debug("send_message result: %r", msg) return msg @@ -275,20 +299,24 @@ async def edit_message( ) if attachments and attachment: - self.logger.warning("Both photo and photos provided; using photos") + self.logger.warning( + "Both photo and photos provided; using photos" + ) attachment = None attaches = [] if attachment: self.logger.info("Uploading attachment for message") result = await self._upload_attachment(attachment) if not result: - self.logger.error("Attachment upload failed, message not sent") + self.logger.error( + "Attachment upload failed, message not sent" + ) return None attaches.append(result) elif attachments: self.logger.info("Uploading multiple attachments for message") - for p in attachment: + for p in attachments: result = await self._upload_attachment(p) if result: attaches.append(result) @@ -320,7 +348,9 @@ async def edit_message( ).model_dump(by_alias=True) if use_queue: - await self._queue_message(opcode=Opcode.MSG_EDIT, payload=payload) + await self._queue_message( + opcode=Opcode.MSG_EDIT, payload=payload + ) self.logger.debug("Edit message queued for sending") return None else: @@ -330,7 +360,9 @@ async def edit_message( if error := data.get("payload", {}).get("error"): self.logger.error("Edit message error: %s", error) msg = ( - Message.from_dict(data["payload"]) if data.get("payload") else None + Message.from_dict(data["payload"]) + if data.get("payload") + else None ) self.logger.debug("edit_message result: %r", msg) return msg @@ -361,7 +393,9 @@ async def delete_message( ).model_dump(by_alias=True) if use_queue: - await self._queue_message(opcode=Opcode.MSG_DELETE, payload=payload) + await self._queue_message( + opcode=Opcode.MSG_DELETE, payload=payload + ) self.logger.debug("Delete message queued for sending") return True else: @@ -398,7 +432,9 @@ async def pin_message( pin_message_id=message_id, ).model_dump(by_alias=True) - data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload) + data = await self._send_and_wait( + opcode=Opcode.CHAT_UPDATE, payload=payload + ) if error := data.get("payload", {}).get("error"): self.logger.error("Pin message error: %s", error) return False @@ -448,7 +484,8 @@ async def fetch_history( return None messages = [ - Message.from_dict(msg) for msg in data["payload"].get("messages", []) + Message.from_dict(msg) + for msg in data["payload"].get("messages", []) ] self.logger.debug("History fetched: %d messages", len(messages)) return messages @@ -476,7 +513,9 @@ async def get_video_by_id( url (str): Ссылка на видео """ try: - self.logger.info("Getting video_id=%s message_id=%s", video_id, message_id) + self.logger.info( + "Getting video_id=%s message_id=%s", video_id, message_id + ) if self.is_connected and self._socket is not None: payload = GetVideoPayload( @@ -489,14 +528,18 @@ async def get_video_by_id( video_id=video_id, ).model_dump(by_alias=True) - data = await self._send_and_wait(opcode=Opcode.VIDEO_PLAY, payload=payload) + data = await self._send_and_wait( + opcode=Opcode.VIDEO_PLAY, payload=payload + ) if error := data.get("payload", {}).get("error"): self.logger.error("Get video error: %s", error) return None video = ( - VideoRequest.from_dict(data["payload"]) if data.get("payload") else None + VideoRequest.from_dict(data["payload"]) + if data.get("payload") + else None ) self.logger.debug("result: %r", video) return video @@ -523,7 +566,9 @@ async def get_file_by_id( url (str): Ссылка на скачивание файла """ try: - self.logger.info("Getting file_id=%s message_id=%s", file_id, message_id) + self.logger.info( + "Getting file_id=%s message_id=%s", file_id, message_id + ) if self.is_connected and self._socket is not None: payload = GetFilePayload( chat_id=chat_id, message_id=message_id, file_id=file_id @@ -543,7 +588,9 @@ async def get_file_by_id( return None file = ( - FileRequest.from_dict(data["payload"]) if data.get("payload") else None + FileRequest.from_dict(data["payload"]) + if data.get("payload") + else None ) self.logger.debug(" result: %r", file) return file diff --git a/src/pymax/mixins/socket.py b/src/pymax/mixins/socket.py index d799aa8..bfbfc38 100644 --- a/src/pymax/mixins/socket.py +++ b/src/pymax/mixins/socket.py @@ -440,7 +440,7 @@ async def _outgoing_loop(self) -> None: if self._outgoing is None: await asyncio.sleep(0.1) continue - + if self._circuit_breaker: if time.time() - self._last_error_time > 60: self._circuit_breaker = False @@ -449,48 +449,64 @@ async def _outgoing_loop(self) -> None: else: await asyncio.sleep(5) continue - - message = await self._outgoing.get() # TODO: persistent msg q mb? + + message = ( + await self._outgoing.get() + ) # TODO: persistent msg q mb? if not message: continue - + retry_count = message.get("retry_count", 0) max_retries = message.get("max_retries", 3) - + try: await self._send_and_wait( opcode=message["opcode"], payload=message["payload"], cmd=message.get("cmd", 0), - timeout=message.get("timeout", 10.0) + timeout=message.get("timeout", 10.0), + ) + self.logger.debug( + "Message sent successfully from queue (socket)" ) - self.logger.debug("Message sent successfully from queue (socket)") self._error_count = max(0, self._error_count - 1) except Exception as e: self._error_count += 1 self._last_error_time = time.time() - - if self._error_count > 10: # TODO: export to constant + + if self._error_count > 10: # TODO: export to constant self._circuit_breaker = True - self.logger.warning("Circuit breaker activated due to %d consecutive errors (socket)", self._error_count) + self.logger.warning( + "Circuit breaker activated due to %d consecutive errors (socket)", + self._error_count, + ) await self._outgoing.put(message) continue - + retry_delay = self._get_retry_delay(e, retry_count) - self.logger.warning("Failed to send message from queue (socket): %s (delay: %ds)", e, retry_delay) - + self.logger.warning( + "Failed to send message from queue (socket): %s (delay: %ds)", + e, + retry_delay, + ) + if retry_count < max_retries: message["retry_count"] = retry_count + 1 await asyncio.sleep(retry_delay) await self._outgoing.put(message) else: - self.logger.error("Message failed after %d retries, dropping (socket)", max_retries) - + self.logger.error( + "Message failed after %d retries, dropping (socket)", + max_retries, + ) + except Exception: self.logger.exception("Error in outgoing loop (socket)") await asyncio.sleep(1) - def _get_retry_delay(self, error: Exception, retry_count: int) -> float: # TODO: tune delays later + def _get_retry_delay( + self, error: Exception, retry_count: int + ) -> float: # TODO: tune delays later if isinstance(error, (ConnectionError, OSError, ssl.SSLError)): return 1.0 elif isinstance(error, TimeoutError): @@ -498,7 +514,7 @@ def _get_retry_delay(self, error: Exception, retry_count: int) -> float: # TODO: elif isinstance(error, SocketNotConnectedError): return 2.0 else: - return 2 ** retry_count + return float(2**retry_count) async def _queue_message( self, @@ -511,7 +527,7 @@ async def _queue_message( if self._outgoing is None: self.logger.warning("Outgoing queue not initialized (socket)") return - + message = { "opcode": opcode, "payload": payload, @@ -520,7 +536,7 @@ async def _queue_message( "retry_count": 0, "max_retries": max_retries, } - + await self._outgoing.put(message) self.logger.debug("Message queued for sending (socket)") diff --git a/src/pymax/mixins/user.py b/src/pymax/mixins/user.py index fce35c5..ce9b43c 100644 --- a/src/pymax/mixins/user.py +++ b/src/pymax/mixins/user.py @@ -1,7 +1,14 @@ +from typing import Any, Literal + +from pymax.exceptions import ResponseError, ResponseStructureError from pymax.interfaces import ClientProtocol -from pymax.payloads import FetchContactsPayload, SearchByPhonePayload -from pymax.static.enum import Opcode -from pymax.types import Session, User +from pymax.payloads import ( + ContactActionPayload, + FetchContactsPayload, + SearchByPhonePayload, +) +from pymax.static.enum import ContactAction, Opcode +from pymax.types import Contact, Session, User class UserMixin(ClientProtocol): @@ -24,7 +31,9 @@ async def get_users(self, user_ids: list[int]) -> list[User]: Получает информацию о пользователях по их ID (с кешем). """ self.logger.debug("get_users ids=%s", user_ids) - cached = {uid: self._users[uid] for uid in user_ids if uid in self._users} + cached = { + uid: self._users[uid] for uid in user_ids if uid in self._users + } missing_ids = [uid for uid in user_ids if uid not in self._users] if missing_ids: @@ -71,7 +80,9 @@ async def fetch_users(self, user_ids: list[int]) -> None | list[User]: self.logger.error("Fetch users error: %s", error) return None - users = [User.from_dict(u) for u in data["payload"].get("contacts", [])] + users = [ + User.from_dict(u) for u in data["payload"].get("contacts", []) + ] for user in users: self._users[user.id] = user @@ -94,7 +105,9 @@ async def search_by_phone(self, phone: str) -> User | None: try: self.logger.info("Searching user by phone: %s", phone) - payload = SearchByPhonePayload(phone=phone).model_dump(by_alias=True) + payload = SearchByPhonePayload(phone=phone).model_dump( + by_alias=True + ) data = await self._send_and_wait( opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload @@ -129,18 +142,83 @@ async def get_sessions(self) -> list[Session] | None: try: self.logger.info("Fetching sessions") - data = await self._send_and_wait(opcode=Opcode.SESSIONS_INFO, payload={}) + data = await self._send_and_wait( + opcode=Opcode.SESSIONS_INFO, payload={} + ) if error := data.get("payload", {}).get("error"): self.logger.error("Fetching sessions error: %s", error) return None - return [Session.from_dict(s) for s in data["payload"].get("sessions", [])] + return [ + Session.from_dict(s) + for s in data["payload"].get("sessions", []) + ] except Exception: self.logger.exception("Fetching sessions failed") return None + async def _contact_action( + self, payload: ContactActionPayload + ) -> dict[str, Any]: + """ + Действия с контактом + + Args: + payload (ContactActionPayload): Полезная нагрузка + + Return: + Полезная нагрузка ответа + """ + data = await self._send_and_wait( + opcode=Opcode.CONTACT_UPDATE, # 34 + payload=payload.model_dump(by_alias=True), + ) + response_payload = data.get("payload") + if not isinstance(response_payload, dict): + raise ResponseStructureError("Invalid response structure") + if error := response_payload.get("error"): + raise ResponseError(error) + return response_payload + + async def add_contact(self, contact_id: int) -> Contact: + """ + Добавляет контакт в список контактов + + Args: + contact_id (int): ID контакта + + Returns: + Contact: Объект контакта, иначе будут выброшены исключения + """ + payload = await self._contact_action( + ContactActionPayload( + contact_id=contact_id, action=ContactAction.ADD + ) + ) + contact_dict = payload.get("contact") + if isinstance(contact_dict, dict): + return Contact.from_dict(contact_dict) + raise ResponseStructureError("Wrong contact structure in response") + + async def remove_contact(self, contact_id: int) -> Literal[True]: + """ + Удаляет контакт из списка контактов + + Args: + contact_id (int): ID контакта + + Returns: + True если успешно, иначе будут выброшены исключения + """ + await self._contact_action( + ContactActionPayload( + contact_id=contact_id, action=ContactAction.REMOVE + ) + ) + return True + def get_chat_id(self, first_user_id: int, second_user_id: int) -> int: """ Получение айди лс (диалога) diff --git a/src/pymax/mixins/websocket.py b/src/pymax/mixins/websocket.py index f1796b7..0bafe6f 100644 --- a/src/pymax/mixins/websocket.py +++ b/src/pymax/mixins/websocket.py @@ -29,7 +29,9 @@ def __init__(self, token: str | None = None, *args, **kwargs) -> None: @property def ws(self) -> websockets.ClientConnection: if self._ws is None or not self.is_connected: - self.logger.critical("WebSocket not connected when access attempted") + self.logger.critical( + "WebSocket not connected when access attempted" + ) raise WebSocketNotConnectedError return self._ws @@ -142,7 +144,9 @@ async def _recv_loop(self) -> None: if fut and not fut.done(): fut.set_result(data) - self.logger.debug("Matched response for pending seq=%s", seq) + self.logger.debug( + "Matched response for pending seq=%s", seq + ) else: if self._incoming is not None: try: @@ -155,9 +159,13 @@ async def _recv_loop(self) -> None: try: # TODO: переделать, временное решение if data.get("opcode") == Opcode.NOTIF_ATTACH: - file_id = data.get("payload", {}).get("fileId", None) + file_id = data.get("payload", {}).get( + "fileId", None + ) if isinstance(file_id, int): - fut = self._file_upload_waiters.pop(file_id, None) + fut = self._file_upload_waiters.pop( + file_id, None + ) if fut and not fut.done(): fut.set_result(data) self.logger.debug( @@ -165,7 +173,9 @@ async def _recv_loop(self) -> None: file_id, ) except Exception: - self.logger.exception("Error handling file upload notification") + self.logger.exception( + "Error handling file upload notification" + ) if ( data.get("opcode") == Opcode.NOTIF_MESSAGE.value @@ -181,17 +191,23 @@ async def _recv_loop(self) -> None: for ( edit_handler, edit_filter, - ) in self._on_message_edit_handlers: + ) in ( + self._on_message_edit_handlers + ): await self._process_message_handler( edit_handler, edit_filter, msg, ) - elif msg.status == MessageStatus.REMOVED: + elif ( + msg.status == MessageStatus.REMOVED + ): for ( remove_handler, remove_filter, - ) in self._on_message_delete_handlers: + ) in ( + self._on_message_delete_handlers + ): await self._process_message_handler( remove_handler, remove_filter, @@ -201,13 +217,19 @@ async def _recv_loop(self) -> None: handler, filter, msg ) except Exception: - self.logger.exception("Error in on_message_handler") + self.logger.exception( + "Error in on_message_handler" + ) except websockets.exceptions.ConnectionClosed: - self.logger.info("WebSocket connection closed; exiting recv loop") + self.logger.info( + "WebSocket connection closed; exiting recv loop" + ) break except Exception: - self.logger.exception("Error in recv_loop; backing off briefly") + self.logger.exception( + "Error in recv_loop; backing off briefly" + ) await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY) def _log_task_exception(self, fut: asyncio.Future[Any]) -> None: @@ -296,7 +318,9 @@ async def _outgoing_loop(self) -> None: await asyncio.sleep(5) continue - message = await self._outgoing.get() # TODO: persistent msg q mb? + message = ( + await self._outgoing.get() + ) # TODO: persistent msg q mb? if not message: continue @@ -338,7 +362,8 @@ async def _outgoing_loop(self) -> None: await self._outgoing.put(message) else: self.logger.error( - "Message failed after %d retries, dropping", max_retries + "Message failed after %d retries, dropping", + max_retries, ) except Exception: @@ -353,7 +378,7 @@ def _get_retry_delay(self, error: Exception, retry_count: int) -> float: elif isinstance(error, WebSocketNotConnectedError): return 2.0 else: - return 2**retry_count + return float(2**retry_count) async def _sync(self) -> None: self.logger.info("Starting initial sync") @@ -369,7 +394,9 @@ async def _sync(self) -> None: ).model_dump(by_alias=True) try: - data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload) + data = await self._send_and_wait( + opcode=Opcode.LOGIN, payload=payload + ) raw_payload = data.get("payload", {}) if error := raw_payload.get("error"): diff --git a/src/pymax/payloads.py b/src/pymax/payloads.py index 9bf3d3c..0f91d35 100644 --- a/src/pymax/payloads.py +++ b/src/pymax/payloads.py @@ -13,7 +13,7 @@ DEFAULT_TIMEZONE, DEFAULT_USER_AGENT, ) -from pymax.static.enum import AttachType, AuthType +from pymax.static.enum import AttachType, AuthType, ContactAction def to_camel(string: str) -> str: @@ -285,6 +285,11 @@ class ReworkInviteLinkPayload(CamelModel): chat_id: int +class ContactActionPayload(CamelModel): + contact_id: int + action: ContactAction + + class RegisterPayload(CamelModel): last_name: str | None = None first_name: str diff --git a/src/pymax/static/enum.py b/src/pymax/static/enum.py index 1f7a0c4..cdddf0a 100644 --- a/src/pymax/static/enum.py +++ b/src/pymax/static/enum.py @@ -205,3 +205,8 @@ class MarkupType(StrEnum): ITALIC = "*" UNDERLINE = "__" STRIKETHROUGH = "~~" + + +class ContactAction(StrEnum): + ADD = "ADD" + REMOVE = "REMOVE" diff --git a/src/pymax/types.py b/src/pymax/types.py index 85339a7..b83ca31 100644 --- a/src/pymax/types.py +++ b/src/pymax/types.py @@ -163,7 +163,9 @@ def __repr__(self) -> str: @override def __str__(self) -> str: - return f"Contact {self.id}: {', '.join(str(n) for n in self.names or [])}" + return ( + f"Contact {self.id}: {', '.join(str(n) for n in self.names or [])}" + ) class Member: @@ -296,7 +298,9 @@ def __str__(self) -> str: class ControlAttach: - def __init__(self, type: AttachType, event: str, **kwargs: dict[str, Any]) -> None: + def __init__( + self, type: AttachType, event: str, **kwargs: dict[str, Any] + ) -> None: self.type = type self.event = event self.extra = kwargs @@ -537,13 +541,13 @@ def __init__( @classmethod def from_dict(cls, data: dict[Any, Any]) -> Self: - return cls(type=data["type"], length=data["length"], from_=data.get("from")) + return cls( + type=data["type"], length=data["length"], from_=data.get("from") + ) @override def __repr__(self) -> str: - return ( - f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})" - ) + return f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})" @override def __str__(self) -> str: @@ -606,7 +610,9 @@ def __init__( def from_dict(cls, data: dict[str, Any]) -> Self: return cls( total_count=data.get("totalCount", 0), - counters=[ReactionCounter.from_dict(c) for c in data.get("counters", [])], + counters=[ + ReactionCounter.from_dict(c) for c in data.get("counters", []) + ], your_reaction=data.get("yourReaction"), ) @@ -626,7 +632,13 @@ def __init__( status: MessageStatus | None, type: MessageType | str, attaches: ( - list[PhotoAttach | VideoAttach | FileAttach | ControlAttach | StickerAttach] + list[ + PhotoAttach + | VideoAttach + | FileAttach + | ControlAttach + | StickerAttach + ] | None ), ) -> None: @@ -647,7 +659,11 @@ def __init__( def from_dict(cls, data: dict[Any, Any]) -> Self: message = data["message"] if data.get("message") else data attaches: list[ - PhotoAttach | VideoAttach | FileAttach | ControlAttach | StickerAttach + PhotoAttach + | VideoAttach + | FileAttach + | ControlAttach + | StickerAttach ] = [] for a in message.get("attaches", []): if a["_type"] == AttachType.PHOTO: @@ -673,7 +689,9 @@ def from_dict(cls, data: dict[Any, Any]) -> Self: return cls( chat_id=data.get("chatId"), sender=message.get("sender"), - elements=[Element.from_dict(e) for e in message.get("elements", [])], + elements=[ + Element.from_dict(e) for e in message.get("elements", []) + ], options=message.get("options"), id=message["id"], time=message["time"], @@ -835,9 +853,13 @@ def from_dict(cls, data: dict[Any, Any]) -> Self: int(k): v for k, v in raw_admins.items() } raw_participants = data.get("participants", {}) or {} - participants: dict[int, int] = {int(k): v for k, v in raw_participants.items()} + participants: dict[int, int] = { + int(k): v for k, v in raw_participants.items() + } last_msg = ( - Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None + Message.from_dict(data["lastMessage"]) + if data.get("lastMessage") + else None ) return cls( participants_count=data.get("participantsCount", 0), @@ -849,7 +871,9 @@ def from_dict(cls, data: dict[Any, Any]) -> Self: description=data.get("description"), chat_type=ChatType(data.get("type", ChatType.CHAT.value)), title=data.get("title"), - last_fire_delayed_error_time=data.get("lastFireDelayedErrorTime", 0), + last_fire_delayed_error_time=data.get( + "lastFireDelayedErrorTime", 0 + ), last_delayed_update_time=data.get("lastDelayedUpdateTime", 0), options=data.get("options", {}), modified=data.get("modified", 0), @@ -871,7 +895,9 @@ def from_dict(cls, data: dict[Any, Any]) -> Self: @override def __repr__(self) -> str: - return f"Chat(id={self.id!r}, title={self.title!r}, type={self.type!r})" + return ( + f"Chat(id={self.id!r}, title={self.title!r}, type={self.type!r})" + ) @override def __str__(self) -> str: