diff --git a/README.md b/README.md index 277e713..2a8864c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,6 @@ if __name__ == "__main__": Спасибо всем за помощь в разработке! - + diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index daf125f..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,123 +0,0 @@ -site_name: Документация PyMax -site_description: Python wrapper для API мессенджера Max -site_author: ink-developer -site_url: https://github.com/ink-developer/PyMax - -repo_name: ink-developer/PyMax -repo_url: https://github.com/ink-developer/PyMax -edit_uri: edit/main/docs/ - -theme: - name: material - language: ru - palette: - - scheme: slate - primary: black - accent: blue - toggle: - icon: material/brightness-7 - name: Переключить на светлую тему - - scheme: default - primary: white - accent: blue - toggle: - icon: material/brightness-3 - name: Переключить на темную тему - features: - - announce.dismiss - - content.action.edit - - content.action.view - - content.code.annotate - - content.code.copy - - content.code.select - - content.tooltips - - header.autohide - - navigation.expand - - navigation.footer - - navigation.indexes - - navigation.instant - - navigation.instant.download - - navigation.instant.loading - - navigation.prune - - navigation.sections - - navigation.top - - navigation.tracking - - search.highlight - - search.share - - search.suggest - - toc.follow - icon: - repo: fontawesome/brands/github - edit: material/pencil - view: material/eye - logo: assets/icon.svg - favicon: assets/icon.svg - -plugins: - - search - # - mkdocstrings: - # default_handler: python - # handlers: - # python: - # paths: [src] - # options: - # show_source: true - # show_root_heading: true - # show_category_heading: true - # show_signature_annotations: true - # show_bases: true - # show_submodules: true - # heading_level: 2 - # members_order: source - # docstring_style: google - # preload_modules: [pymax] - # filters: ["!^_"] - # merge_init_into_class: true - -markdown_extensions: - - abbr - - admonition - - attr_list - - def_list - - footnotes - - md_in_html - - toc: - permalink: true - - pymdownx.arithmatex: - generic: true - - pymdownx.betterem: - smart_enable: all - - pymdownx.caret - - pymdownx.details - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.keys - - pymdownx.magiclink - - pymdownx.mark - - pymdownx.smartsymbols - - pymdownx.snippets: - check_paths: true - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - - pymdownx.tabbed: - alternate_style: true - combine_header_slug: true - slugify: !!python/object/apply:pymdownx.slugs.slugify - kwds: - case: lower - - pymdownx.tasklist: - custom_checkbox: true - - pymdownx.tilde - -extra: - social: - - icon: fontawesome/brands/github - link: https://github.com/ink-developer/PyMax - - icon: fontawesome/brands/python - link: https://pypi.org/project/maxapi-python diff --git a/pyproject.toml b/pyproject.toml index 08399c9..78cada8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "1.2.3" +version = "1.2.4" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" @@ -19,12 +19,13 @@ dependencies = [ "aiohttp>=3.12.15", "aiofiles>=24.1.0", "qrcode>=8.2", + "ua-generator>=2.0.19", ] [project.urls] -Homepage = "https://github.com/ink-developer/PyMax" -Repository = "https://github.com/ink-developer/PyMax" -Issues = "https://github.com/ink-developer/PyMax/issues" +Homepage = "https://github.com/MaxApiTeam/PyMax" +Repository = "https://github.com/MaxApiTeam/PyMax" +Issues = "https://github.com/MaxApiTeam/PyMax/issues" [build-system] requires = ["hatchling"] diff --git a/redocs/source/clients.rst b/redocs/source/clients.rst index dbba07e..f828f06 100644 --- a/redocs/source/clients.rst +++ b/redocs/source/clients.rst @@ -1,11 +1,44 @@ Clients ======= +Выбор между MaxClient и SocketMaxClient +---------------------------------------- + +PyMax предоставляет два клиента с разной функциональностью в зависимости от выбранного протокола подключения: + +.. list-table:: Сравнение клиентов + :widths: 30 35 35 + :header-rows: 1 + + * - Функция + - MaxClient (WebSocket) + - SocketMaxClient (Socket) + * - Протокол подключения + - WebSocket + - TCP Socket + * - Способ авторизации + - Вход по QR-коду + - Вход/регистрация по номеру телефона + * - Регистрация новых пользователей + - ❌ Не поддерживается + - ✅ Поддерживается + * - Скорость подключения + - Быстрое + - Медленнее + * - Рекомендуемое использование + - Базовые боты и приложения + - Массовая регистрация, системная авторизация + MaxClient --------- Основной асинхронный WebSocket клиент для взаимодействия с Max API. +**Поддерживаемые методы авторизации:** + - ✅ Вход по QR-коду (WEB device_type) + - ❌ Вход по номеру телефона (больше не поддерживается) + - ❌ Регистрация по номеру телефона + Инициализация: .. code-block:: python @@ -20,27 +53,10 @@ MaxClient logger=None, # Пользовательский логгер ) -.. warning:: - - Параметр ``device_type`` в ``UserAgentPayload`` **критически важен** для выбора способа авторизации: - - **DESKTOP** — вход по номеру телефона: - - .. code-block:: python - - from pymax.payloads import UserAgentPayload - - ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13") - client = MaxClient(phone="+79111111111", headers=ua) - - **WEB** — вход через QR-код; токен совместим с веб-версией Max: - - .. code-block:: python - - from pymax.payloads import UserAgentPayload +.. note:: - ua = UserAgentPayload(device_type="WEB", app_version="25.12.13") - client = MaxClient(phone="+79111111111", headers=ua) + MaxClient по умолчанию использует **WEB** device_type и поддерживает только вход по QR-коду. + Это является рекомендуемым способом авторизации для большинства приложений. Основные методы: @@ -84,6 +100,19 @@ MaxClient limit=50 ) + # Изменить профиль с загрузкой фото + result = await client.change_profile( + first_name="Иван", + last_name="Петров", + description="Привет!", + photo=Photo(...) # Новая фотография профиля + ) + + # Разрешить группу по ссылке + group = await client.resolve_group_by_link( + link="https://max.app/g/ABC123" + ) + Свойства: .. code-block:: python @@ -95,6 +124,7 @@ MaxClient client.channels # Список каналов (list[Channel]) client.phone # Номер телефона (str) client.token # Токен сессии (str | None) + client.contacts # Список контактов (list[User]) Обработчики событий: @@ -140,9 +170,83 @@ MaxClient SocketMaxClient --------------- -Низкоуровневый WebSocket клиент для прямого взаимодействия с API. -Обычно не требуется использовать напрямую - используйте MaxClient вместо этого. +Асинхронный TCP Socket клиент для взаимодействия с Max API. Используется для входа и регистрации по номеру телефона. + +**Поддерживаемые методы авторизации:** + - ✅ Вход по номеру телефона (DESKTOP, ANDROID, IOS device_types) + - ✅ Регистрация нового пользователя по номеру телефона + +**Когда использовать SocketMaxClient:** + - Необходимо зарегистрировать новых пользователей + - Требуется вход по номеру телефона (без QR-кода) + - Необходимо использовать DESKTOP, ANDROID или IOS device_types + - Разрабатываете системы массовой регистрации или авторизации + - Нужна автоматизация входа (вход по номеру телефона удобнее для автоматизации, чем сканирование QR-кода) + +.. note:: + + **SocketMaxClient — это полноценный и рекомендуемый способ авторизации!** + + Не воспринимайте Socket клиент как что-то вспомогательное или альтернативное. + Вход по номеру телефона — это основной способ авторизации в Max, и ``SocketMaxClient`` обеспечивает надежный доступ к этому функционалу. + + Для многих сценариев (особенно для автоматизации и интеграции) вход по номеру телефона **удобнее и практичнее**, чем сканирование QR-кода. + +Инициализация и вход: + +.. code-block:: python + + from pymax import SocketMaxClient + from pymax.payloads import UserAgentPayload + + # Для входа по номеру телефона + client = SocketMaxClient( + phone="+79001234567", + work_dir="./cache", + headers=UserAgentPayload(device_type="DESKTOP"), + ) + + await client.start() # Потребуется ввести код подтверждения + +Регистрация нового пользователя: + +.. code-block:: python + + from pymax import SocketMaxClient + from pymax.payloads import UserAgentPayload + + client = SocketMaxClient( + phone="+79001234567", + registration=True, # Флаг регистрации + first_name="Иван", + last_name="Петров", + headers=UserAgentPayload(device_type="DESKTOP"), + ) + + await client.start() # Потребуется ввести код подтверждения + +.. important:: + + SocketMaxClient должен использоваться для: + + 1. **Регистрации новых пользователей** — MaxClient не поддерживает регистрацию + 2. **Входа по номеру телефона** — требуется phone verification код + 3. **Системной авторизации** — когда QR-код недоступен или неудобен + 4. **Автоматизации** — вход по номеру телефона легче автоматизировать .. note:: - Если вам нужны низкоуровневые детали, смотрите исходный код библиотеки. + После успешной авторизации через SocketMaxClient вы можете сохранить токен и использовать его с MaxClient для более быстрого подключения к WebSocket API. + + .. code-block:: python + + # Первый раз: получаем токен через Socket + socket_client = SocketMaxClient(phone="+79001234567") + await socket_client.start() + token = socket_client.token + + # Сохраняем токен + + # Следующие разы: используем токен с WebSocket клиентом + ws_client = MaxClient(phone="+79001234567", token=token) + await ws_client.start() diff --git a/redocs/source/conf.py b/redocs/source/conf.py index 83611e1..49751be 100644 --- a/redocs/source/conf.py +++ b/redocs/source/conf.py @@ -6,7 +6,7 @@ project = "PyMax" author = "ink-developer" copyright = "2025, ink-developer" -release = "1.1.21" +release = "1.2.4" # -- Path setup --------------------------------------------------------------- sys.path.insert(0, os.path.abspath("../../src")) diff --git a/redocs/source/examples.rst b/redocs/source/examples.rst index e4fb23d..1cd22b8 100644 --- a/redocs/source/examples.rst +++ b/redocs/source/examples.rst @@ -38,7 +38,7 @@ Greeter Bot client = MaxClient(phone="+79001234567") - @client.on_message(Filters.private()) + @client.on_message(Filters.chat(123)) async def greet(message): user = await client.get_user(message.sender) if user and user.names: @@ -150,13 +150,8 @@ File Manager for attach in message.attaches: if attach.type == AttachType.PHOTO: print("Получено фото!") - file_info = await client.get_file_by_id( - chat_id=message.chat_id, - message_id=message.id, - file_id=attach.file_id - ) - if file_info: - print(f"URL: {file_info.url}") + + print(f"URL: {attach.base_url}") @client.on_message(Filters.text("файл")) async def send_file(message): diff --git a/redocs/source/guides.rst b/redocs/source/guides.rst index bc4e6ce..4e21cbc 100644 --- a/redocs/source/guides.rst +++ b/redocs/source/guides.rst @@ -93,11 +93,6 @@ Guides async def greeting(message: Message) -> None: pass - # Только личные - @client.on_message(Filters.dialog()) - async def private(message: Message) -> None: - pass - # Только группы @client.on_message(Filters.chat()) async def in_group(message: Message) -> None: diff --git a/redocs/source/index.rst b/redocs/source/index.rst index ee340c5..f627289 100644 --- a/redocs/source/index.rst +++ b/redocs/source/index.rst @@ -5,6 +5,7 @@ .. image:: _static/logo.svg :align: center :width: 320px + PyMax ===== @@ -34,6 +35,7 @@ PyMax decorators examples guides + release_notes .. rubric:: Особенности @@ -41,6 +43,10 @@ PyMax - Отправка / редактирование / удаление сообщений - Управление чатами, каналами и диалогами - Получение истории сообщений +- Загрузка фотографий профиля +- Разрешение групп по ссылке +- Поддержка контактов в сообщениях +- Управление списком контактов --- @@ -97,7 +103,7 @@ Disclaimer Star History ------------ -.. image:: https://api.star-history.com/svg?repos=ink-developer/PyMax&type=date&legend=top-left +.. image:: https://api.star-history.com/svg?repos=MaxApiTeam/PyMax&type=date&legend=top-left Авторы ------ @@ -108,5 +114,5 @@ Star History Контрибьюторы ------------- -.. image:: https://contrib.rocks/image?repo=ink-developer/PyMax +.. image:: https://contrib.rocks/image?repo=MaxApiTeam/PyMax :alt: Contributors diff --git a/redocs/source/installation.rst b/redocs/source/installation.rst index b4c68e7..58eb562 100644 --- a/redocs/source/installation.rst +++ b/redocs/source/installation.rst @@ -49,7 +49,7 @@ UV — это быстрый пакетный менеджер, написанн .. code-block:: bash - git clone https://github.com/ink-developer/PyMax.git + git clone https://github.com/MaxApiTeam/PyMax.git cd PyMax pip install -e . @@ -57,7 +57,7 @@ UV — это быстрый пакетный менеджер, написанн .. code-block:: bash - git clone https://github.com/ink-developer/PyMax.git + git clone https://github.com/MaxApiTeam/PyMax.git cd PyMax uv sync diff --git a/redocs/source/quickstart.rst b/redocs/source/quickstart.rst index afc3804..d74a260 100644 --- a/redocs/source/quickstart.rst +++ b/redocs/source/quickstart.rst @@ -10,10 +10,29 @@ Quick Start pip install -U maxapi-python +Выбор клиента +-------------- + +PyMax предоставляет два клиента для подключения к Max API: + +**MaxClient (WebSocket)** — рекомендуется для большинства приложений: + - Используется WebSocket протокол + - Вход по QR-коду + - Более быстрое подключение + - Подходит для ботов, помощников и приложений + +**SocketMaxClient (TCP Socket)** — для специальных случаев: + - Используется TCP Socket протокол + - Вход по номеру телефона + - Поддерживает регистрацию новых пользователей + - Требуется, если вы регистрируете новых пользователей или нужен вход по phone number + +Для получения полной информации смотрите :doc:`clients`. + Первый бот: Echo ---------------- -Самый простой бот — повторяет сообщения пользователя: +Самый простой бот — повторяет сообщения пользователя (используя MaxClient): .. code-block:: python @@ -40,7 +59,7 @@ Quick Start python bot.py -При первом запуске вам потребуется ввести код подтверждения из приложения Max. +При первом запуске вам потребуется отсканировать QR-код из приложения Max. Фильтры сообщений ------------------ @@ -68,10 +87,6 @@ Quick Start text="И тебе привет!" ) - # Только личные сообщения - @client.on_message(Filters.dialog()) - async def private_handler(message: Message) -> None: - print(f"Личное сообщение: {message.text}") Обработчики событий -------------------- diff --git a/redocs/source/release_notes.rst b/redocs/source/release_notes.rst new file mode 100644 index 0000000..715aa3a --- /dev/null +++ b/redocs/source/release_notes.rst @@ -0,0 +1,143 @@ +Release Notes v1.2.4 +==================== + +Новые функции +------------- + +**Поддержка различных типов файлов в классах File и Photo** + Классы ``Photo``, ``File`` и ``Video`` теперь поддерживают работу с байтами, что позволяет загружать файлы из памяти напрямую. + +**Автоматическая отправка уведомлений о прочтении сообщений** + Клиент теперь автоматически отправляет сервису уведомления о получении сообщений для улучшения синхронизации. + +**Параметр session_name для управления сессией** + Параметр ``session_name`` позволяет указать пользовательское имя файла для сохранения сессии. + +**Получение текущей версии веб-приложения** + Новый метод ``get_current_web_version()`` в утилитах для получения текущей версии веб-приложения Max. + +**Улучшенная генерация User-Agent** + Теперь используется библиотека ``ua-generator`` для более реалистичной генерации User-Agent строк и параметров устройства. + +Новые методы +------------ + +read_message(chat_id: int, message_id: int) -> ReadState + Отмечает сообщение как прочитанное. Возвращает объект ReadState с информацией о состоянии. + +pymax.utils.MixinsUtils.get_current_web_version() -> str | None + Получает текущую версию веб-приложения Max из источника. Возвращает версию в формате "XX.XX.XX" или None. + +Измененные методы +----------------- + +MaxClient.start() + Улучшена логика работы цикла переподключения с использованием ``asyncio.Event`` для более чистого завершения. + Исправлена обработка состояния при отключении и переподключении. + +MaxClient.close() + Упрощена логика закрытия клиента. Теперь использует ``asyncio.Event`` для сигнала остановки. + +Новые параметры +--------------- + +MaxClient.__init__(session_name: str = "session.db") + Позволяет указать пользовательское имя файла базы данных сессии. + +Измененные типы +--------------- + +BaseFile + Теперь поддерживает работу с байтами через параметр ``raw`` во всех подклассах. + +Photo + Добавлен параметр ``name`` для явного указания имени файла при работе с байтами. + Улучшена валидация расширений файлов. + +File + Добавлена поддержка работы с байтами через параметр ``raw``. + Улучшена обработка имен файлов. + +Video + Добавлена поддержка работы с байтами через параметр ``raw``. + Улучшена работа с видеофайлами. + +Исправления и улучшения +------------------------ + +- Добавлена валидация ``device_type`` для MaxClient (поддерживает только WEB) и SocketMaxClient (поддерживает ANDROID, IOS, DESKTOP) +- Улучшена обработка ошибок WebSocket при отключении +- Добавлена опция ``ua-generator`` для более реалистичной генерации параметров устройства +- Обновлена версия приложения до 25.12.14 +- Улучшена обработка уведомлений о доставке сообщений +- Исправлены проблемы с завершением async задач при закрытии клиента + +Зависимости +----------- + +Добавлены новые зависимости: + - ``ua-generator>=2.0.19`` — для генерации реалистичных User-Agent строк и параметров устройства + +Версия +------ + +**1.2.4** - выпущена 30 декабря 2025 г. + +--- + +Release Notes v1.2.3 +==================== + +Новые функции +------------- + +**Загрузка фотографий профиля** + Профиль теперь может быть обновлен с загрузкой новой фотографии через метод ``change_profile()``. + +**Разрешение групп по ссылке** + Группы теперь могут быть разрешены (получены) прямо по их ссылке через метод ``resolve_group_by_link()``. + +**Поддержка контактов в сообщениях** + Сообщения теперь поддерживают вложения типа контакта с информацией о контакте (ContactAttach). + +**Список контактов клиента** + Клиент теперь ведет список всех контактов пользователя через свойство ``client.contacts``. + +Новые методы +------------ + +MaxClient.resolve_group_by_link(link: str) -> Chat | None + Разрешает группу по ссылке. Возвращает объект чата группы или None, если не найдено. + +MaxClient.change_profile(first_name, last_name, description, photo) + Изменяет информацию профиля текущего пользователя, включая загрузку новой фотографии. + +Новые типы +---------- + +ContactAttach + Представляет контакт в сообщении. Содержит информацию о контакте (ID, имя, фамилия, фото). + +Измененные типы +--------------- + +Message + Теперь поддерживает вложения типа ContactAttach в список attaches. + +Names + Улучшен для работы с различными форматами имен пользователя. + +StickerAttach + Улучшено представление стикеров в сообщениях. + +Photo + Улучшено для работы с фотографиями профиля. + +AttachType + Добавлено значение CONTACT для контактов. + +Новые параметры +--------------- + +MaxClient.contacts: list[User] + Список контактов текущего пользователя. diff --git a/src/pymax/core.py b/src/pymax/core.py index 0766144..4cc9bb5 100644 --- a/src/pymax/core.py +++ b/src/pymax/core.py @@ -17,15 +17,12 @@ from .exceptions import ( InvalidPhoneError, SocketNotConnectedError, + WebSocketNotConnectedError, ) from .interfaces import BaseClient from .mixins import ApiMixin, SocketMixin, WebSocketMixin from .payloads import UserAgentPayload -from .static.constant import ( - HOST, - PORT, - WEBSOCKET_URI, -) +from .static.constant import HOST, PORT, SESSION_STORAGE_DB, WEBSOCKET_URI if TYPE_CHECKING: from collections.abc import Callable @@ -34,7 +31,6 @@ from pymax.filters import BaseFilter - from .filters import Filters from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User @@ -42,6 +38,7 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient): + allowed_device_types: set[str] = {"WEB"} """ Основной клиент для работы с WebSocket API сервиса Max. @@ -49,6 +46,8 @@ class MaxClient(ApiMixin, WebSocketMixin, BaseClient): :type phone: str :param uri: URI WebSocket сервера. :type uri: str, optional + :param session_name: Название сессии для хранения базы данных. + :type session_name: str, optional :param work_dir: Рабочая директория для хранения базы данных. :type work_dir: str, optional :param logger: Пользовательский логгер. Если не передан, используется логгер модуля с именем f"{__name__}.MaxClient". @@ -81,7 +80,8 @@ def __init__( self, phone: str, uri: str = WEBSOCKET_URI, - headers: UserAgentPayload = UserAgentPayload(), + session_name: str = SESSION_STORAGE_DB, + headers: UserAgentPayload | None = None, token: str | None = None, send_fake_telemetry: bool = True, host: str = HOST, @@ -120,7 +120,7 @@ def __init__( self._users: dict[int, User] = {} self._work_dir: str = work_dir - self._database_path: Path = Path(work_dir) / "session.db" + self._database_path: Path = Path(work_dir) / session_name self._database_path.parent.mkdir(parents=True, exist_ok=True) self._database_path.touch(exist_ok=True) self._database = Database(self._work_dir) @@ -132,6 +132,7 @@ def __init__( self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {} self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} self._background_tasks: set[asyncio.Task[Any]] = set() + self._stop_event = asyncio.Event() self._seq: int = 0 self._error_count: int = 0 @@ -142,7 +143,10 @@ def __init__( self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} self._token = self._database.get_auth_token() or token + if headers is None: + headers = self._default_headers() self.user_agent = headers + self._validate_device_type() self._send_fake_telemetry: bool = send_fake_telemetry self._session_id: int = int(time.time() * 1000) self._action_id: int = 1 @@ -180,11 +184,24 @@ def __init__( self._work_dir, ) + @staticmethod + def _default_headers() -> UserAgentPayload: + return UserAgentPayload(device_type="WEB") + + def _validate_device_type(self) -> None: + if self.user_agent.device_type not in self.allowed_device_types: + raise ValueError( + f"{self.__class__.__name__} does not support " + f"device_type={self.user_agent.device_type}" + ) + async def _wait_forever(self) -> None: try: await self.ws.wait_closed() except asyncio.CancelledError: self.logger.debug("wait_closed cancelled") + except WebSocketNotConnectedError: + self.logger.info("WebSocket not connected, exiting wait_forever") async def close(self) -> None: """ @@ -194,22 +211,7 @@ async def close(self) -> None: """ try: self.logger.info("Closing client") - if self._recv_task: - self._recv_task.cancel() - try: - await self._recv_task - except asyncio.CancelledError: - self.logger.debug("recv_task cancelled") - if self._outgoing_task: - self._outgoing_task.cancel() - try: - await self._outgoing_task - except asyncio.CancelledError: - self.logger.debug("outgoing_task cancelled") - if self._ws: - await self._ws.close() - self.is_connected = False - self.logger.info("Client closed") + self._stop_event.set() except Exception: self.logger.exception("Error closing client") @@ -279,10 +281,9 @@ async def start(self) -> None: :return: None :rtype: None """ - - while True: + self.logger.info("Client starting") + while not self._stop_event.is_set(): try: - self.logger.info("Client starting") await self.connect(self.user_agent) if self.registration: @@ -297,29 +298,45 @@ async def start(self) -> None: await self._login() await self._sync(self.user_agent) - await self._post_login_tasks(sync=False) - await self._wait_forever() - self.logger.info("WebSocket closed (wait_forever exited)") + wait_task = asyncio.create_task(self._wait_forever()) + stop_task = asyncio.create_task(self._stop_event.wait()) + + done, pending = await asyncio.wait( + [wait_task, stop_task], return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + except asyncio.CancelledError: + self.logger.info("Client task cancelled, stopping") + break except Exception as e: self.logger.exception("Client start iteration failed") - raise e - finally: - self.logger.debug("Cleaning up background tasks and pending futures") - await self._cleanup_client() - if not self.reconnect: - self.logger.info("Reconnect disabled — exiting start()") - return + if not self.reconnect or self._stop_event.is_set(): + self.logger.info("Reconnect disabled or stop requested — exiting start()") + break self.logger.info("Reconnect enabled — restarting client") await asyncio.sleep(self.reconnect_delay) + self.logger.info("Client exited cleanly") + class SocketMaxClient(SocketMixin, MaxClient): + allowed_device_types = {"ANDROID", "IOS", "DESKTOP"} + + @staticmethod + def _default_headers() -> UserAgentPayload: + return UserAgentPayload(device_type="DESKTOP") + @override async def _wait_forever(self): if self._recv_task: diff --git a/src/pymax/files.py b/src/pymax/files.py index f8745d8..1b69c3d 100644 --- a/src/pymax/files.py +++ b/src/pymax/files.py @@ -9,7 +9,10 @@ class BaseFile(ABC): - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None + ) -> None: + self.raw = raw self.url = url self.path = path @@ -21,6 +24,9 @@ def __init__(self, url: str | None = None, path: str | None = None) -> None: @abstractmethod async def read(self) -> bytes: + if self.raw is not None: + return self.raw + if self.url: async with ( ClientSession() as session, @@ -45,13 +51,24 @@ class Photo(BaseFile): ".bmp", } # FIXME: костыль ✅ - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, + raw: bytes | None = None, + *, + url: str | None = None, + path: str | None = None, + name: str | None = None, + ) -> None: if path: self.file_name = Path(path).name elif url: self.file_name = Path(url).name + elif name: + self.file_name = name + else: + self.file_name = "" - super().__init__(url, path) + super().__init__(raw=raw, url=url, path=path) def validate_photo(self) -> tuple[str, str] | None: if self.path: @@ -83,7 +100,9 @@ async def read(self) -> bytes: class Video(BaseFile): - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None + ) -> None: self.file_name: str = "" if path: self.file_name = Path(path).name @@ -92,7 +111,7 @@ def __init__(self, url: str | None = None, path: str | None = None) -> None: if not self.file_name: raise ValueError("Either url or path must be provided.") - super().__init__(url, path) + super().__init__(raw=raw, url=url, path=path) @override async def read(self) -> bytes: @@ -100,7 +119,9 @@ async def read(self) -> bytes: class File(BaseFile): - def __init__(self, url: str | None = None, path: str | None = None) -> None: + def __init__( + self, raw: bytes | None = None, *, url: str | None = None, path: str | None = None + ) -> None: self.file_name: str = "" if path: self.file_name = Path(path).name @@ -110,7 +131,7 @@ def __init__(self, url: str | None = None, path: str | None = None) -> None: if not self.file_name: raise ValueError("Either url or path must be provided.") - super().__init__(url, path) + super().__init__(raw=raw, url=url, path=path) @override async def read(self) -> bytes: diff --git a/src/pymax/interfaces.py b/src/pymax/interfaces.py index 4927fea..3d8fde5 100644 --- a/src/pymax/interfaces.py +++ b/src/pymax/interfaces.py @@ -1,128 +1,35 @@ import asyncio import contextlib +import json import logging -import socket -import ssl +import time import traceback -from abc import ABC, abstractmethod +from abc import abstractmethod from collections.abc import Awaitable, Callable -from logging import Logger -from typing import TYPE_CHECKING, Any, Literal +from typing import Any from typing_extensions import Self from pymax.exceptions import WebSocketNotConnectedError +from pymax.filters import BaseFilter from pymax.formatter import ColoredFormatter - -from .payloads import UserAgentPayload -from .static.constant import DEFAULT_TIMEOUT -from .static.enum import Opcode -from .types import Channel, Chat, Dialog, Me, Message, User - -if TYPE_CHECKING: - from pathlib import Path - from uuid import UUID - - import websockets - - from pymax import AttachType - from pymax.types import ReactionInfo - - from .crud import Database - from .filters import BaseFilter - - -class ClientProtocol(ABC): - def __init__(self, logger: Logger) -> None: - super().__init__() - self.logger = logger - self._users: dict[int, User] = {} - self.chats: list[Chat] = [] - self._database: Database - self._device_id: UUID - self.uri: str - self.is_connected: bool = False - self.phone: str - self.dialogs: list[Dialog] = [] - self.channels: list[Channel] = [] - self.contacts: list[User] = [] - self.me: Me | None = None - self.host: str - self.port: int - self.proxy: str | Literal[True] | None - self.registration: bool - self.first_name: str - self.last_name: str | None - self._token: str | None - self._work_dir: str - self.reconnect: bool - self._database_path: Path - self._ws: websockets.ClientConnection | None = None - self._seq: int = 0 - 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.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._session_id: int - self._action_id: int = 0 - self._current_screen: str = "chats_list_tab" - self._on_message_handlers: list[ - tuple[Callable[[Message], Any], BaseFilter[Message] | None] - ] = [] - self._on_message_edit_handlers: list[ - tuple[Callable[[Message], Any], BaseFilter[Message] | None] - ] = [] - self._on_message_delete_handlers: list[ - tuple[Callable[[Message], Any], BaseFilter[Message] | None] - ] = [] - self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = [] - self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = [] - self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = [] - self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = [] - 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 - - @abstractmethod - async def _send_and_wait( - self, - opcode: Opcode, - payload: dict[str, Any], - cmd: int = 0, - timeout: float = DEFAULT_TIMEOUT, - ) -> dict[str, Any]: - pass - - @abstractmethod - async def _get_chat(self, chat_id: int) -> Chat | None: - pass - - @abstractmethod - async def _queue_message( - self, - opcode: int, - payload: dict[str, Any], - cmd: int = 0, - timeout: float = DEFAULT_TIMEOUT, - max_retries: int = 3, - ) -> Message | None: - pass - - @abstractmethod - def _create_safe_task( - self, coro: Awaitable[Any], name: str | None = None - ) -> asyncio.Task[Any]: - pass +from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload +from pymax.protocols import ClientProtocol +from pymax.static.constant import DEFAULT_PING_INTERVAL, DEFAULT_TIMEOUT +from pymax.static.enum import Opcode +from pymax.types import ( + Channel, + Chat, + ChatType, + Dialog, + Me, + Message, + MessageStatus, + ReactionCounter, + ReactionInfo, + User, +) +from pymax.utils import MixinsUtils class BaseClient(ClientProtocol): @@ -255,3 +162,391 @@ async def start(self) -> None: @abstractmethod async def close(self) -> None: pass + + +class BaseTransport(ClientProtocol): + @abstractmethod + async def connect( + self, user_agent: UserAgentPayload | None = None + ) -> dict[str, Any] | None: ... + + @abstractmethod + async def _send_and_wait( + self, + opcode: Opcode, + payload: dict[str, Any], + cmd: int = 0, + timeout: float = DEFAULT_TIMEOUT, + ) -> dict[str, Any]: ... + + @abstractmethod + async def _recv_loop(self) -> None: ... + + def _make_message( + self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0 + ) -> dict[str, Any]: + self._seq += 1 + + msg = BaseWebSocketMessage( + cmd=cmd, + seq=self._seq, + opcode=opcode.value, + payload=payload, + ).model_dump(by_alias=True) + + self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq) + return msg + + async def _send_interactive_ping(self) -> None: + while self.is_connected: + try: + await self._send_and_wait( + opcode=Opcode.PING, + payload={"interactive": True}, + cmd=0, + ) + self.logger.debug("Interactive ping sent successfully") + except Exception: + self.logger.warning("Interactive ping failed", exc_info=True) + await asyncio.sleep(DEFAULT_PING_INTERVAL) + + async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]: + self.logger.debug( + "Sending handshake with user_agent keys=%s", + user_agent.model_dump(by_alias=True).keys(), + ) + + user_agent_json = user_agent.model_dump(by_alias=True) + resp = await self._send_and_wait( + opcode=Opcode.SESSION_INIT, + payload={"deviceId": str(self._device_id), "userAgent": user_agent_json}, + ) + + if resp.get("payload", {}).get("error"): + MixinsUtils.handle_error(resp) + + self.logger.info("Handshake completed") + return resp + + async def _process_message_handler( + self, + handler: Callable[[Message], Any], + filter: BaseFilter[Message] | None, + message: Message, + ): + result = None + if filter: + if filter(message): + result = handler(message) + else: + return + else: + result = handler(message) + if asyncio.iscoroutine(result): + self._create_safe_task(result, name=f"handler-{handler.__name__}") + + def _parse_json(self, raw: Any) -> dict[str, Any] | None: + try: + return json.loads(raw) + except Exception: + self.logger.warning("JSON parse error", exc_info=True) + return None + + def _handle_pending(self, seq: int | None, data: dict) -> bool: + if isinstance(seq, int): + fut = self._pending.get(seq) + if fut and not fut.done(): + fut.set_result(data) + self.logger.debug("Matched response for pending seq=%s", seq) + return True + return False + + async def _handle_incoming_queue(self, data: dict[str, Any]) -> None: + if self._incoming: + try: + self._incoming.put_nowait(data) + except asyncio.QueueFull: + self.logger.warning( + "Incoming queue full; dropping message seq=%s", data.get("seq") + ) + + async def _handle_file_upload(self, data: dict[str, Any]) -> None: + if data.get("opcode") != Opcode.NOTIF_ATTACH: + return + payload = data.get("payload", {}) + for key in ("fileId", "videoId"): + id_ = payload.get(key) + if id_ is not None: + fut = self._file_upload_waiters.pop(id_, None) + if fut and not fut.done(): + fut.set_result(data) + self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_) + + async def _send_notification_response(self, chat_id: int, message_id: str) -> None: + await self._send_and_wait( + opcode=Opcode.NOTIF_MESSAGE, + payload={"chatId": chat_id, "messageId": message_id}, + cmd=0, + ) + self.logger.debug( + "Sent NOTIF_MESSAGE_RECEIVED for chat_id=%s message_id=%s", chat_id, message_id + ) + + async def _handle_message_notifications(self, data: dict) -> None: + if data.get("opcode") != Opcode.NOTIF_MESSAGE.value: + return + payload = data.get("payload", {}) + msg = Message.from_dict(payload) + if not msg: + return + + if msg.chat_id and msg.id: + await self._send_notification_response(msg.chat_id, str(msg.id)) + + handlers_map = { + MessageStatus.EDITED: self._on_message_edit_handlers, + MessageStatus.REMOVED: self._on_message_delete_handlers, + } + if msg.status and msg.status in handlers_map: + for handler, filter in handlers_map[msg.status]: + await self._process_message_handler(handler, filter, msg) + if msg.status is None: + for handler, filter in self._on_message_handlers: + await self._process_message_handler(handler, filter, msg) + + async def _handle_reactions(self, data: dict): + if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED: + return + + payload = data.get("payload", {}) + chat_id = payload.get("chatId") + message_id = payload.get("messageId") + + if not (chat_id and message_id): + return + + total_count = payload.get("totalCount") + your_reaction = payload.get("yourReaction") + counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])] + + reaction_info = ReactionInfo( + total_count=total_count, + your_reaction=your_reaction, + counters=counters, + ) + + for handler in self._on_reaction_change_handlers: + try: + result = handler(message_id, chat_id, reaction_info) + if asyncio.iscoroutine(result): + await result + except Exception as e: + self.logger.exception("Error in on_reaction_change_handler: %s", e) + + async def _handle_chat_updates(self, data: dict) -> None: + if data.get("opcode") != Opcode.NOTIF_CHAT: + return + + payload = data.get("payload", {}) + chat_data = payload.get("chat", {}) + chat = Chat.from_dict(chat_data) + if not chat: + return + + for handler in self._on_chat_update_handlers: + try: + result = handler(chat) + if asyncio.iscoroutine(result): + await result + except Exception as e: + self.logger.exception("Error in on_chat_update_handler: %s", e) + + async def _handle_raw_receive(self, data: dict[str, Any]) -> None: + for handler in self._on_raw_receive_handlers: + try: + result = handler(data) + if asyncio.iscoroutine(result): + await result + except Exception as e: + self.logger.exception("Error in on_raw_receive_handler: %s", e) + + async def _dispatch_incoming(self, data: dict[str, Any]) -> None: + await self._handle_raw_receive(data) + await self._handle_file_upload(data) + await self._handle_message_notifications(data) + await self._handle_reactions(data) + await self._handle_chat_updates(data) + + def _log_task_exception(self, fut: asyncio.Future[Any]) -> None: + try: + fut.result() + except asyncio.CancelledError: + pass + except Exception as e: + self.logger.exception("Error retrieving task exception: %s", e) + + async def _queue_message( + self, + opcode: int, + payload: dict[str, Any], + cmd: int = 0, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = 3, + ) -> None: + if self._outgoing is None: + self.logger.warning("Outgoing queue not initialized") + return + + message = { + "opcode": opcode, + "payload": payload, + "cmd": cmd, + "timeout": timeout, + "retry_count": 0, + "max_retries": max_retries, + } + + await self._outgoing.put(message) + self.logger.debug("Message queued for sending") + + async def _outgoing_loop(self) -> None: + while self.is_connected: + try: + 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 + self._error_count = 0 + self.logger.info("Circuit breaker reset") + else: + await asyncio.sleep(5) + continue + + 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", DEFAULT_TIMEOUT), + ) + self.logger.debug("Message sent successfully from queue") + 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: + self._circuit_breaker = True + self.logger.warning( + "Circuit breaker activated due to %d consecutive errors", + 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: %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", + max_retries, + ) + + except Exception: + self.logger.exception("Error in outgoing loop") + await asyncio.sleep(1) + + def _get_retry_delay(self, error: Exception, retry_count: int) -> float: + if isinstance(error, (ConnectionError, OSError)): + return 1.0 + elif isinstance(error, TimeoutError): + return 5.0 + elif isinstance(error, WebSocketNotConnectedError): + return 2.0 + else: + return float(2**retry_count) + + async def _sync(self, user_agent: UserAgentPayload | None = None) -> None: + self.logger.info("Starting initial sync") + + if user_agent is None: + user_agent = self.headers or UserAgentPayload() + + payload = SyncPayload( + interactive=True, + token=self._token, + chats_sync=0, + contacts_sync=0, + presence_sync=0, + drafts_sync=0, + chats_count=40, + user_agent=user_agent, + ).model_dump(by_alias=True) + try: + data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload) + raw_payload = data.get("payload", {}) + + if error := raw_payload.get("error"): + MixinsUtils.handle_error(data) + + for raw_chat in raw_payload.get("chats", []): + try: + if raw_chat.get("type") == ChatType.DIALOG.value: + self.dialogs.append(Dialog.from_dict(raw_chat)) + elif raw_chat.get("type") == ChatType.CHAT.value: + self.chats.append(Chat.from_dict(raw_chat)) + elif raw_chat.get("type") == ChatType.CHANNEL.value: + self.channels.append(Channel.from_dict(raw_chat)) + except Exception: + self.logger.exception("Error parsing chat entry") + + for raw_user in raw_payload.get("contacts", []): + try: + user = User.from_dict(raw_user) + if user: + self.contacts.append(user) + except Exception: + self.logger.exception("Error parsing contact entry") + + if raw_payload.get("profile", {}).get("contact"): + self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {})) + + self.logger.info( + "Sync completed: dialogs=%d chats=%d channels=%d", + len(self.dialogs), + len(self.chats), + len(self.channels), + ) + + except Exception as e: + self.logger.exception("Sync failed") + self.is_connected = False + if self._ws: + await self._ws.close() + self._ws = None + raise + + async def _get_chat(self, chat_id: int) -> Chat | None: + for chat in self.chats: + if chat.id == chat_id: + return chat + return None diff --git a/src/pymax/mixins/auth.py b/src/pymax/mixins/auth.py index ff02d8d..eaa1e1d 100644 --- a/src/pymax/mixins/auth.py +++ b/src/pymax/mixins/auth.py @@ -7,11 +7,11 @@ import qrcode from pymax.exceptions import Error -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload +from pymax.protocols import ClientProtocol from pymax.static.constant import PHONE_REGEX from pymax.static.enum import AuthType, DeviceType, Opcode +from pymax.utils import MixinsUtils class AuthMixin(ClientProtocol): diff --git a/src/pymax/mixins/channel.py b/src/pymax/mixins/channel.py index 7112bb9..d77f50d 100644 --- a/src/pymax/mixins/channel.py +++ b/src/pymax/mixins/channel.py @@ -1,18 +1,18 @@ from pymax.exceptions import Error, ResponseError, ResponseStructureError -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import ( GetGroupMembersPayload, JoinChatPayload, ResolveLinkPayload, SearchGroupMembersPayload, ) +from pymax.protocols import ClientProtocol from pymax.static.constant import ( DEFAULT_CHAT_MEMBERS_LIMIT, DEFAULT_MARKER_VALUE, ) from pymax.static.enum import Opcode from pymax.types import Channel, Member +from pymax.utils import MixinsUtils class ChannelMixin(ClientProtocol): @@ -113,9 +113,7 @@ async def load_members( payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count) return await self._query_members(payload) - async def find_members( - self, chat_id: int, query: str - ) -> tuple[list[Member], int | None]: + async def find_members(self, chat_id: int, query: str) -> tuple[list[Member], int | None]: """ Поиск участников канала по строке Внимание! веб-клиент всегда возвращает только определённое количество пользователей, diff --git a/src/pymax/mixins/group.py b/src/pymax/mixins/group.py index d700c00..27e1b6b 100644 --- a/src/pymax/mixins/group.py +++ b/src/pymax/mixins/group.py @@ -1,8 +1,6 @@ import time from pymax.exceptions import Error -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import ( ChangeGroupProfilePayload, ChangeGroupSettingsOptions, @@ -18,8 +16,10 @@ RemoveUsersPayload, ReworkInviteLinkPayload, ) +from pymax.protocols import ClientProtocol from pymax.static.enum import Opcode from pymax.types import Chat, Message +from pymax.utils import MixinsUtils class GroupMixin(ClientProtocol): diff --git a/src/pymax/mixins/handler.py b/src/pymax/mixins/handler.py index e3e3255..3c78aa4 100644 --- a/src/pymax/mixins/handler.py +++ b/src/pymax/mixins/handler.py @@ -2,7 +2,7 @@ from typing import Any from pymax.filters import BaseFilter -from pymax.interfaces import ClientProtocol +from pymax.protocols import ClientProtocol from pymax.types import Chat, Message, ReactionInfo @@ -62,9 +62,7 @@ def decorator( handler: Callable[[Any], Any | Awaitable[Any]], ) -> Callable[[Any], Any | Awaitable[Any]]: self._on_message_edit_handlers.append((handler, filter)) - self.logger.debug( - f"on_message_edit handler set: {handler}, filter: {filter}" - ) + self.logger.debug(f"on_message_edit handler set: {handler}, filter: {filter}") return handler return decorator @@ -88,9 +86,7 @@ def decorator( handler: Callable[[Any], Any | Awaitable[Any]], ) -> Callable[[Any], Any | Awaitable[Any]]: self._on_message_delete_handlers.append((handler, filter)) - self.logger.debug( - f"on_message_delete handler set: {handler}, filter: {filter}" - ) + self.logger.debug(f"on_message_delete handler set: {handler}, filter: {filter}") return handler return decorator @@ -179,9 +175,7 @@ async def task(): def decorator( handler: Callable[[], Any | Awaitable[Any]], ) -> Callable[[], Any | Awaitable[Any]]: - self._scheduled_tasks.append( - (handler, seconds + minutes * 60 + hours * 3600) - ) + self._scheduled_tasks.append((handler, seconds + minutes * 60 + hours * 3600)) self.logger.debug( f"task scheduled: {handler}, interval: {seconds + minutes * 60 + hours * 3600}s" ) diff --git a/src/pymax/mixins/message.py b/src/pymax/mixins/message.py index 912d30c..3e6192e 100644 --- a/src/pymax/mixins/message.py +++ b/src/pymax/mixins/message.py @@ -10,8 +10,6 @@ from pymax.exceptions import Error from pymax.files import File, Photo, Video from pymax.formatting import Formatting -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import ( AddReactionPayload, AttachFilePayload, @@ -25,6 +23,7 @@ MessageElement, PinMessagePayload, ReactionInfoPayload, + ReadMessagesPayload, RemoveReactionPayload, ReplyLink, SendMessagePayload, @@ -32,15 +31,18 @@ UploadPayload, VideoAttachPayload, ) +from pymax.protocols import ClientProtocol from pymax.static.constant import DEFAULT_TIMEOUT -from pymax.static.enum import AttachType, Opcode +from pymax.static.enum import AttachType, Opcode, ReadAction from pymax.types import ( Attach, FileRequest, Message, ReactionInfo, + ReadState, VideoRequest, ) +from pymax.utils import MixinsUtils class MessageMixin(ClientProtocol): @@ -68,15 +70,11 @@ async def _upload_file(self, file: File) -> None | Attach: if file.path: file_size = Path(file.path).stat().st_size - self.logger.info( - "File size from path: %.2f MB", file_size / (1024 * 1024) - ) + self.logger.info("File size from path: %.2f MB", file_size / (1024 * 1024)) else: file_bytes = await file.read() file_size = len(file_bytes) - self.logger.info( - "File size from URL: %.2f MB", file_size / (1024 * 1024) - ) + self.logger.info("File size from URL: %.2f MB", file_size / (1024 * 1024)) connector = TCPConnector(limit=0) timeout = aiohttp.ClientTimeout(total=None, sock_read=None, sock_connect=30) @@ -161,9 +159,7 @@ async def bytes_generator(b: bytes): ) try: await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT) - self.logger.info( - "File upload completed successfully (fileId=%s)", file_id - ) + self.logger.info("File upload completed successfully (fileId=%s)", file_id) return Attach(_type=AttachType.FILE, file_id=file_id) except asyncio.TimeoutError: self.logger.warning( @@ -190,9 +186,7 @@ async def _upload_video(self, video: Video) -> None | Attach: MixinsUtils.handle_error(data) url = data.get("payload", {}).get("info", [None])[0].get("url", None) - video_id = ( - data.get("payload", {}).get("info", [None])[0].get("videoId", None) - ) + video_id = data.get("payload", {}).get("info", [None])[0].get("videoId", None) if not url or not video_id: self.logger.error("No upload URL or video ID received") return None @@ -207,9 +201,7 @@ async def _upload_video(self, video: Video) -> None | Attach: # Настройки для ClientSession connector = TCPConnector(limit=0) - timeout = aiohttp.ClientTimeout( - total=900, sock_read=60 - ) # 15 минут на видео + timeout = aiohttp.ClientTimeout(total=900, sock_read=60) # 15 минут на видео headers = { "Content-Disposition": f"attachment; filename={video.file_name}", @@ -226,26 +218,20 @@ async def _upload_video(self, video: Video) -> None | Attach: self.logger.exception("Failed to register file upload waiter") try: - async with ClientSession( - connector=connector, timeout=timeout - ) as session: + async with ClientSession(connector=connector, timeout=timeout) as session: async with session.post( url=url, headers=headers, data=file_bytes, ) as response: if response.status != HTTPStatus.OK: - self.logger.error( - "Upload failed with status %s", response.status - ) + self.logger.error("Upload failed with status %s", response.status) self._file_upload_waiters.pop(int(video_id), None) return None try: await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT) - return Attach( - _type=AttachType.VIDEO, video_id=video_id, token=token - ) + return Attach(_type=AttachType.VIDEO, video_id=video_id, token=token) except asyncio.TimeoutError: self.logger.warning( "Timed out waiting for video processing notification for videoId=%s", @@ -337,9 +323,7 @@ async def _upload_attachment(self, attach: Photo | File | Video) -> dict | None: elif isinstance(attach, File): uploaded = await self._upload_file(attach) if uploaded and uploaded.file_id: - return AttachFilePayload(file_id=uploaded.file_id).model_dump( - by_alias=True - ) + return AttachFilePayload(file_id=uploaded.file_id).model_dump(by_alias=True) elif isinstance(attach, Video): uploaded = await self._upload_video(attach) if uploaded and uploaded.video_id and uploaded.token: @@ -391,9 +375,7 @@ async def send_message( self.logger.info("Uploading attachment for message") result = await self._upload_attachment(attachment) if not result: - raise Error( - "upload_failed", "Failed to upload attachment", "Upload Error" - ) + raise Error("upload_failed", "Failed to upload attachment", "Upload Error") attaches.append(result) elif attachments: @@ -403,14 +385,10 @@ async def send_message( if result: attaches.append(result) else: - raise Error( - "upload_failed", "Failed to upload attachment", "Upload Error" - ) + raise Error("upload_failed", "Failed to upload attachment", "Upload Error") if not attaches: - raise Error( - "upload_failed", "All attachments failed to upload", "Upload Error" - ) + raise Error("upload_failed", "All attachments failed to upload", "Upload Error") elements = [] clean_text = None @@ -418,8 +396,7 @@ async def send_message( if raw_elements: clean_text = parsed_text elements = [ - MessageElement(type=e.type, length=e.length, from_=e.from_) - for e in raw_elements + MessageElement(type=e.type, length=e.length, from_=e.from_) for e in raw_elements ] payload = SendMessagePayload( @@ -447,9 +424,7 @@ async def send_message( msg = Message.from_dict(data["payload"]) if data.get("payload") else None self.logger.debug("send_message result: %r", msg) if not msg: - raise Error( - "no_message", "Message data missing in response", "Message Error" - ) + raise Error("no_message", "Message data missing in response", "Message Error") return msg @@ -481,9 +456,7 @@ async def edit_message( :rtype: Message | None :raises Error: Если редактирование не удалось. """ - self.logger.info( - "Editing message chat_id=%s message_id=%s", chat_id, message_id - ) + self.logger.info("Editing message chat_id=%s message_id=%s", chat_id, message_id) if attachments and attachment: self.logger.warning("Both photo and photos provided; using photos") @@ -494,9 +467,7 @@ async def edit_message( self.logger.info("Uploading attachment for message") result = await self._upload_attachment(attachment) if not result: - raise Error( - "upload_failed", "Failed to upload attachment", "Upload Error" - ) + raise Error("upload_failed", "Failed to upload attachment", "Upload Error") attaches.append(result) elif attachments: @@ -506,14 +477,10 @@ async def edit_message( if result: attaches.append(result) else: - raise Error( - "upload_failed", "Failed to upload attachment", "Upload Error" - ) + raise Error("upload_failed", "Failed to upload attachment", "Upload Error") if not attaches: - raise Error( - "upload_failed", "All attachments failed to upload", "Upload Error" - ) + raise Error("upload_failed", "All attachments failed to upload", "Upload Error") elements = [] clean_text = None @@ -521,8 +488,7 @@ async def edit_message( if raw_elements: clean_text = Formatting.get_elements_from_markdown(text)[1] elements = [ - MessageElement(type=e.type, length=e.length, from_=e.from_) - for e in raw_elements + MessageElement(type=e.type, length=e.length, from_=e.from_) for e in raw_elements ] payload = EditMessagePayload( @@ -546,9 +512,7 @@ async def edit_message( msg = Message.from_dict(data["payload"]) if data.get("payload") else None self.logger.debug("edit_message result: %r", msg) if not msg: - raise Error( - "no_message", "Message data missing in response", "Message Error" - ) + raise Error("no_message", "Message data missing in response", "Message Error") return msg @@ -597,9 +561,7 @@ async def delete_message( self.logger.debug("delete_message success") return True - async def pin_message( - self, chat_id: int, message_id: int, notify_pin: bool - ) -> bool: + async def pin_message(self, chat_id: int, message_id: int, notify_pin: bool) -> bool: """ Закрепляет сообщение в чате. @@ -667,16 +629,12 @@ async def fetch_history( self.logger.debug("Payload dict keys: %s", list(payload.keys())) - data = await self._send_and_wait( - opcode=Opcode.CHAT_HISTORY, payload=payload, timeout=10 - ) + data = await self._send_and_wait(opcode=Opcode.CHAT_HISTORY, payload=payload, timeout=10) if data.get("payload", {}).get("error"): MixinsUtils.handle_error(data) - messages = [ - Message.from_dict(msg) for msg in data["payload"].get("messages", []) - ] + messages = [Message.from_dict(msg) for msg in data["payload"].get("messages", [])] self.logger.debug("History fetched: %d messages", len(messages)) return messages @@ -797,9 +755,7 @@ async def add_reaction( reaction=ReactionInfoPayload(id=reaction), ).model_dump(by_alias=True) - data = await self._send_and_wait( - opcode=Opcode.MSG_REACTION, payload=payload - ) + data = await self._send_and_wait(opcode=Opcode.MSG_REACTION, payload=payload) if data.get("payload", {}).get("error"): MixinsUtils.handle_error(data) @@ -833,21 +789,17 @@ async def get_reactions( message_ids, ) - payload = GetReactionsPayload( - chat_id=chat_id, message_ids=message_ids - ).model_dump(by_alias=True) - - data = await self._send_and_wait( - opcode=Opcode.MSG_GET_REACTIONS, payload=payload + payload = GetReactionsPayload(chat_id=chat_id, message_ids=message_ids).model_dump( + by_alias=True ) + data = await self._send_and_wait(opcode=Opcode.MSG_GET_REACTIONS, payload=payload) + if data.get("payload", {}).get("error"): MixinsUtils.handle_error(data) reactions = {} - for msg_id, reaction_data in ( - data.get("payload", {}).get("messagesReactions", {}).items() - ): + for msg_id, reaction_data in data.get("payload", {}).get("messagesReactions", {}).items(): reactions[msg_id] = ReactionInfo.from_dict(reaction_data) self.logger.debug("get_reactions success") @@ -879,18 +831,14 @@ async def remove_reaction( message_id=message_id, ).model_dump(by_alias=True) - data = await self._send_and_wait( - opcode=Opcode.MSG_CANCEL_REACTION, payload=payload - ) + data = await self._send_and_wait(opcode=Opcode.MSG_CANCEL_REACTION, payload=payload) if data.get("payload", {}).get("error"): MixinsUtils.handle_error(data) self.logger.debug("remove_reaction success") if not data.get("payload"): - raise Error( - "no_reaction", "Reaction data missing in response", "Reaction Error" - ) + raise Error("no_reaction", "Reaction data missing in response", "Reaction Error") reaction = ReactionInfo.from_dict(data["payload"]["reactionInfo"]) if not reaction: @@ -901,3 +849,31 @@ async def remove_reaction( ) return reaction + + async def read_message(self, message_id: int, chat_id: int) -> ReadState: + """ + Отмечает сообщение как прочитанное. + + :param message_id: ID сообщения + :type message_id: int + :param chat_id: ID чата + :type chat_id: int + :return: Объект ReadState + :rtype: ReadState + """ + self.logger.info("Marking message as read chat_id=%s message_id=%s", chat_id, message_id) + + payload = ReadMessagesPayload( + type=ReadAction.READ_MESSAGE, + chat_id=chat_id, + message_id=str(message_id), + mark=int(time.time() * 1000), + ).model_dump(by_alias=True) + + data = await self._send_and_wait(opcode=Opcode.CHAT_MARK, payload=payload) + + if data.get("payload", {}).get("error"): + MixinsUtils.handle_error(data) + + self.logger.debug("read_message success") + return ReadState.from_dict(data["payload"]) diff --git a/src/pymax/mixins/scheduler.py b/src/pymax/mixins/scheduler.py index b1bceae..9a40a33 100644 --- a/src/pymax/mixins/scheduler.py +++ b/src/pymax/mixins/scheduler.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from pymax.interfaces import ClientProtocol +from pymax.protocols import ClientProtocol class SchedulerMixin(ClientProtocol): diff --git a/src/pymax/mixins/self.py b/src/pymax/mixins/self.py index 98c6010..14a8668 100644 --- a/src/pymax/mixins/self.py +++ b/src/pymax/mixins/self.py @@ -8,8 +8,6 @@ from pymax.exceptions import Error from pymax.files import Photo -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import ( ChangeProfilePayload, CreateFolderPayload, @@ -18,8 +16,10 @@ UpdateFolderPayload, UploadPayload, ) +from pymax.protocols import ClientProtocol from pymax.static.enum import Opcode from pymax.types import Folder, FolderList, FolderUpdate, Me +from pymax.utils import MixinsUtils class SelfMixin(ClientProtocol): diff --git a/src/pymax/mixins/socket.py b/src/pymax/mixins/socket.py index f2b840b..bfeea0b 100644 --- a/src/pymax/mixins/socket.py +++ b/src/pymax/mixins/socket.py @@ -12,8 +12,9 @@ from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError from pymax.filters import BaseFilter -from pymax.interfaces import ClientProtocol +from pymax.interfaces import BaseTransport from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload +from pymax.protocols import ClientProtocol from pymax.static.constant import ( DEFAULT_PING_INTERVAL, DEFAULT_TIMEOUT, @@ -32,7 +33,7 @@ ) -class SocketMixin(ClientProtocol): +class SocketMixin(BaseTransport): @property def sock(self) -> socket.socket: if self._socket is None or not self.is_connected: @@ -129,25 +130,6 @@ async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, self.logger.info("Socket connected, starting handshake") return await self._handshake(user_agent) - async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]: - try: - self.logger.debug( - "Sending handshake with user_agent keys=%s", - user_agent.model_dump().keys(), - ) - resp = await self._send_and_wait( - opcode=Opcode.SESSION_INIT, - payload={ - "deviceId": str(self._device_id), - "userAgent": user_agent, - }, - ) - self.logger.info("Handshake completed") - return resp - except Exception as e: - self.logger.error("Handshake failed: %s", e, exc_info=True) - raise ConnectionError(f"Handshake failed: {e}") - def _recv_exactly(self, sock: socket.socket, n: int) -> bytes: buf = bytearray() while len(buf) < n: @@ -214,116 +196,6 @@ async def _recv_data( else [data] ) - def _handle_pending(self, seq: int | None, data: dict) -> bool: - if isinstance(seq, int): - fut = self._pending.get(seq) - if fut and not fut.done(): - fut.set_result(data) - self.logger.debug("Matched response for pending seq=%s", seq) - return True - return False - - async def _handle_incoming_queue(self, data: dict[str, Any]) -> None: - if self._incoming: - try: - self._incoming.put_nowait(data) - except asyncio.QueueFull: - self.logger.warning( - "Incoming queue full; dropping message seq=%s", data.get("seq") - ) - - async def _handle_file_upload(self, data: dict[str, Any]) -> None: - if data.get("opcode") != Opcode.NOTIF_ATTACH: - return - payload = data.get("payload", {}) - for key in ("fileId", "videoId"): - id_ = payload.get(key) - if id_ is not None: - fut = self._file_upload_waiters.pop(id_, None) - if fut and not fut.done(): - fut.set_result(data) - self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_) - - async def _handle_message_notifications(self, data: dict) -> None: - if data.get("opcode") != Opcode.NOTIF_MESSAGE.value: - return - payload = data.get("payload", {}) - msg = Message.from_dict(payload) - if not msg: - return - handlers_map = { - MessageStatus.EDITED: self._on_message_edit_handlers, - MessageStatus.REMOVED: self._on_message_delete_handlers, - } - if msg.status and msg.status in handlers_map: - for handler, filter in handlers_map[msg.status]: - await self._process_message_handler(handler, filter, msg) - for handler, filter in self._on_message_handlers: - await self._process_message_handler(handler, filter, msg) - - async def _handle_reactions(self, data: dict): - if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED: - return - - payload = data.get("payload", {}) - chat_id = payload.get("chatId") - message_id = payload.get("messageId") - - if not (chat_id and message_id): - return - - total_count = payload.get("totalCount") - your_reaction = payload.get("yourReaction") - counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])] - - reaction_info = ReactionInfo( - total_count=total_count, - your_reaction=your_reaction, - counters=counters, - ) - - for handler in self._on_reaction_change_handlers: - try: - result = handler(message_id, chat_id, reaction_info) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_reaction_change_handler: %s", e) - - async def _handle_chat_updates(self, data: dict) -> None: - if data.get("opcode") != Opcode.NOTIF_CHAT: - return - - payload = data.get("payload", {}) - chat_data = payload.get("chat", {}) - chat = Chat.from_dict(chat_data) - if not chat: - return - - for handler in self._on_chat_update_handlers: - try: - result = handler(chat) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_chat_update_handler: %s", e) - - async def _handle_raw_receive(self, data: dict[str, Any]) -> None: - for handler in self._on_raw_receive_handlers: - try: - result = handler(data) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_raw_receive_handler: %s", e) - - async def _dispatch_incoming(self, data: dict[str, Any]) -> None: - await self._handle_raw_receive(data) - await self._handle_file_upload(data) - await self._handle_message_notifications(data) - await self._handle_reactions(data) - await self._handle_chat_updates(data) - async def _recv_loop(self) -> None: if self._socket is None: self.logger.warning("Recv loop started without socket instance") @@ -362,57 +234,6 @@ async def _recv_loop(self) -> None: 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: - try: - fut.result() - except asyncio.CancelledError: - pass - except Exception as e: - self.logger.exception("Error getting task exception: %s", e) - pass - - async def _process_message_handler( - self, - handler: Callable[[Message], Any], - filter: BaseFilter[Message] | None, - message: Message, - ) -> None: - if filter is not None and not filter(message): - return - - result = handler(message) - if asyncio.iscoroutine(result): - task = asyncio.create_task(result) - task.add_done_callback(self._log_task_exception) - self._background_tasks.add(task) - - async def _send_interactive_ping(self) -> None: - while self.is_connected: - try: - await self._send_and_wait( - opcode=Opcode.PING, - payload={"interactive": True}, - cmd=0, - ) - self.logger.debug("Interactive ping sent successfully (socket)") - except Exception: - self.logger.warning("Interactive ping failed (socket)", exc_info=True) - await asyncio.sleep(DEFAULT_PING_INTERVAL) - - def _make_message( - self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0 - ) -> dict[str, Any]: - self._seq += 1 - msg = BaseWebSocketMessage( - ver=10, - cmd=cmd, - seq=self._seq, - opcode=opcode.value, - payload=payload, - ).model_dump(by_alias=True) - self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq) - return msg - @override async def _send_and_wait( self, @@ -468,160 +289,6 @@ async def _send_and_wait( finally: self._pending.pop(msg["seq"], None) - async def _outgoing_loop(self) -> None: - while self.is_connected: - try: - 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 - self._error_count = 0 - self.logger.info("Circuit breaker reset (socket)") - else: - await asyncio.sleep(5) - continue - - 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), - ) - 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 - self._circuit_breaker = True - 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, - ) - - 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, - ) - - 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 - if isinstance(error, (ConnectionError, OSError, ssl.SSLError)): - return 1.0 - elif isinstance(error, TimeoutError): - return 5.0 - elif isinstance(error, SocketNotConnectedError): - return 2.0 - else: - return 2**retry_count - - async def _queue_message( - self, - opcode: int, - payload: dict[str, Any], - cmd: int = 0, - timeout: float = 10.0, - max_retries: int = 3, - ) -> None: - if self._outgoing is None: - self.logger.warning("Outgoing queue not initialized (socket)") - return - - message = { - "opcode": opcode, - "payload": payload, - "cmd": cmd, - "timeout": timeout, - "retry_count": 0, - "max_retries": max_retries, - } - - await self._outgoing.put(message) - self.logger.debug("Message queued for sending (socket)") - - async def _sync(self) -> None: - self.logger.info("Starting initial sync (socket)") - payload = SyncPayload( - interactive=True, - token=self._token, - chats_sync=0, - contacts_sync=0, - presence_sync=0, - drafts_sync=0, - chats_count=40, - ).model_dump(by_alias=True) - data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload) - raw_payload = data.get("payload", {}) - if error := raw_payload.get("error"): - localized_message = raw_payload.get("localizedMessage") - title = raw_payload.get("title") - message = raw_payload.get("message") - raise Error( - error=error, - message=message, - title=title, - localized_message=localized_message, - ) - for raw_chat in raw_payload.get("chats", []): - try: - if raw_chat.get("type") == "DIALOG": - self.dialogs.append(Dialog.from_dict(raw_chat)) - elif raw_chat.get("type") == "CHAT": - self.chats.append(Chat.from_dict(raw_chat)) - elif raw_chat.get("type") == "CHANNEL": - self.channels.append(Channel.from_dict(raw_chat)) - except Exception: - self.logger.exception("Error parsing chat entry (socket)") - - for raw_user in raw_payload.get("contacts", []): - try: - user = User.from_dict(raw_user) - if user: - self.contacts.append(user) - except Exception: - self.logger.exception("Error parsing contact entry (socket)") - - if raw_payload.get("profile", {}).get("contact"): - self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {})) - self.logger.info( - "Sync completed: dialogs=%d chats=%d channels=%d", - len(self.dialogs), - len(self.chats), - len(self.channels), - ) - @override async def _get_chat(self, chat_id: int) -> Chat | None: for chat in self.chats: diff --git a/src/pymax/mixins/telemetry.py b/src/pymax/mixins/telemetry.py index 20903c0..feaab3a 100644 --- a/src/pymax/mixins/telemetry.py +++ b/src/pymax/mixins/telemetry.py @@ -3,20 +3,18 @@ import time from pymax.exceptions import Error -from pymax.interfaces import ClientProtocol from pymax.navigation import Navigation from pymax.payloads import ( NavigationEventParams, NavigationEventPayload, NavigationPayload, ) +from pymax.protocols import ClientProtocol from pymax.static.enum import Opcode class TelemetryMixin(ClientProtocol): - async def _send_navigation_event( - self, events: list[NavigationEventPayload] - ) -> None: + async def _send_navigation_event(self, events: list[NavigationEventPayload]) -> None: try: payload = NavigationPayload(events=events).model_dump(by_alias=True) data = await self._send_and_wait( diff --git a/src/pymax/mixins/user.py b/src/pymax/mixins/user.py index 160a7a7..1180284 100644 --- a/src/pymax/mixins/user.py +++ b/src/pymax/mixins/user.py @@ -1,15 +1,15 @@ from typing import Any, Literal from pymax.exceptions import Error, ResponseError, ResponseStructureError -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils from pymax.payloads import ( ContactActionPayload, FetchContactsPayload, SearchByPhonePayload, ) +from pymax.protocols import ClientProtocol from pymax.static.enum import ContactAction, Opcode from pymax.types import Contact, Session, User +from pymax.utils import MixinsUtils class UserMixin(ClientProtocol): @@ -122,9 +122,7 @@ async def search_by_phone(self, phone: str) -> User: payload = SearchByPhonePayload(phone=phone).model_dump(by_alias=True) - data = await self._send_and_wait( - opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload - ) + data = await self._send_and_wait(opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload) if data.get("payload", {}).get("error"): MixinsUtils.handle_error(data) diff --git a/src/pymax/mixins/utils.py b/src/pymax/mixins/utils.py deleted file mode 100644 index 18850bb..0000000 --- a/src/pymax/mixins/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any, NoReturn - -from pymax.exceptions import Error, RateLimitError - - -class MixinsUtils: - @staticmethod - def handle_error(data: dict[str, Any]) -> NoReturn: - error = data.get("payload", {}).get("error") - localized_message = data.get("payload", {}).get("localizedMessage") - title = data.get("payload", {}).get("title") - message = data.get("payload", {}).get("message") - - if error == "too.many.requests": # TODO: вынести в статик - raise RateLimitError( - error=error, - message=message, - title=title, - localized_message=localized_message, - ) - - raise Error( - error=error, - message=message, - title=title, - localized_message=localized_message, - ) diff --git a/src/pymax/mixins/websocket.py b/src/pymax/mixins/websocket.py index c74f579..698fa92 100644 --- a/src/pymax/mixins/websocket.py +++ b/src/pymax/mixins/websocket.py @@ -1,37 +1,25 @@ import asyncio import json -import time -from collections.abc import Callable from typing import Any import websockets from typing_extensions import override -from pymax.exceptions import LoginError, WebSocketNotConnectedError -from pymax.filters import BaseFilter -from pymax.interfaces import ClientProtocol -from pymax.mixins.utils import MixinsUtils -from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload +from pymax.exceptions import WebSocketNotConnectedError +from pymax.interfaces import BaseTransport +from pymax.payloads import UserAgentPayload from pymax.static.constant import ( - DEFAULT_PING_INTERVAL, DEFAULT_TIMEOUT, RECV_LOOP_BACKOFF_DELAY, WEBSOCKET_ORIGIN, ) -from pymax.static.enum import ChatType, MessageStatus, Opcode +from pymax.static.enum import Opcode from pymax.types import ( - Channel, Chat, - Dialog, - Me, - Message, - ReactionCounter, - ReactionInfo, - User, ) -class WebSocketMixin(ClientProtocol): +class WebSocketMixin(BaseTransport): @property def ws(self) -> websockets.ClientConnection: if self._ws is None or not self.is_connected: @@ -39,34 +27,6 @@ def ws(self) -> websockets.ClientConnection: raise WebSocketNotConnectedError return self._ws - def _make_message( - self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0 - ) -> dict[str, Any]: - self._seq += 1 - - msg = BaseWebSocketMessage( - cmd=cmd, - seq=self._seq, - opcode=opcode.value, - payload=payload, - ).model_dump(by_alias=True) - - self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq) - return msg - - async def _send_interactive_ping(self) -> None: - while self.is_connected: - try: - await self._send_and_wait( - opcode=Opcode.PING, - payload={"interactive": True}, - cmd=0, - ) - self.logger.debug("Interactive ping sent successfully") - except Exception: - self.logger.warning("Interactive ping failed", exc_info=True) - await asyncio.sleep(DEFAULT_PING_INTERVAL) - async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any] | None: """ Устанавливает соединение WebSocket с сервером и выполняет handshake. @@ -100,159 +60,6 @@ async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, self.logger.info("WebSocket connected, starting handshake") return await self._handshake(user_agent) - async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]: - self.logger.debug( - "Sending handshake with user_agent keys=%s", - user_agent.model_dump(by_alias=True).keys(), - ) - - user_agent_json = user_agent.model_dump(by_alias=True) - resp = await self._send_and_wait( - opcode=Opcode.SESSION_INIT, - payload={"deviceId": str(self._device_id), "userAgent": user_agent_json}, - ) - - if resp.get("payload", {}).get("error"): - MixinsUtils.handle_error(resp) - - self.logger.info("Handshake completed") - return resp - - async def _process_message_handler( - self, - handler: Callable[[Message], Any], - filter: BaseFilter[Message] | None, - message: Message, - ): - result = None - if filter: - if filter(message): - result = handler(message) - else: - return - else: - result = handler(message) - if asyncio.iscoroutine(result): - self._create_safe_task(result, name=f"handler-{handler.__name__}") - - def _parse_json(self, raw: Any) -> dict[str, Any] | None: - try: - return json.loads(raw) - except Exception: - self.logger.warning("JSON parse error", exc_info=True) - return None - - def _handle_pending(self, seq: int | None, data: dict) -> bool: - if isinstance(seq, int): - fut = self._pending.get(seq) - if fut and not fut.done(): - fut.set_result(data) - self.logger.debug("Matched response for pending seq=%s", seq) - return True - return False - - async def _handle_incoming_queue(self, data: dict[str, Any]) -> None: - if self._incoming: - try: - self._incoming.put_nowait(data) - except asyncio.QueueFull: - self.logger.warning( - "Incoming queue full; dropping message seq=%s", data.get("seq") - ) - - async def _handle_file_upload(self, data: dict[str, Any]) -> None: - if data.get("opcode") != Opcode.NOTIF_ATTACH: - return - payload = data.get("payload", {}) - for key in ("fileId", "videoId"): - id_ = payload.get(key) - if id_ is not None: - fut = self._file_upload_waiters.pop(id_, None) - if fut and not fut.done(): - fut.set_result(data) - self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_) - - async def _handle_message_notifications(self, data: dict) -> None: - if data.get("opcode") != Opcode.NOTIF_MESSAGE.value: - return - payload = data.get("payload", {}) - msg = Message.from_dict(payload) - if not msg: - return - handlers_map = { - MessageStatus.EDITED: self._on_message_edit_handlers, - MessageStatus.REMOVED: self._on_message_delete_handlers, - } - if msg.status and msg.status in handlers_map: - for handler, filter in handlers_map[msg.status]: - await self._process_message_handler(handler, filter, msg) - if msg.status is None: - for handler, filter in self._on_message_handlers: - await self._process_message_handler(handler, filter, msg) - - async def _handle_reactions(self, data: dict): - if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED: - return - - payload = data.get("payload", {}) - chat_id = payload.get("chatId") - message_id = payload.get("messageId") - - if not (chat_id and message_id): - return - - total_count = payload.get("totalCount") - your_reaction = payload.get("yourReaction") - counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])] - - reaction_info = ReactionInfo( - total_count=total_count, - your_reaction=your_reaction, - counters=counters, - ) - - for handler in self._on_reaction_change_handlers: - try: - result = handler(message_id, chat_id, reaction_info) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_reaction_change_handler: %s", e) - - async def _handle_chat_updates(self, data: dict) -> None: - if data.get("opcode") != Opcode.NOTIF_CHAT: - return - - payload = data.get("payload", {}) - chat_data = payload.get("chat", {}) - chat = Chat.from_dict(chat_data) - if not chat: - return - - for handler in self._on_chat_update_handlers: - try: - result = handler(chat) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_chat_update_handler: %s", e) - - async def _handle_raw_receive(self, data: dict[str, Any]) -> None: - for handler in self._on_raw_receive_handlers: - try: - result = handler(data) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.exception("Error in on_raw_receive_handler: %s", e) - - async def _dispatch_incoming(self, data: dict[str, Any]) -> None: - await self._handle_raw_receive(data) - await self._handle_file_upload(data) - await self._handle_message_notifications(data) - await self._handle_reactions(data) - await self._handle_chat_updates(data) - async def _recv_loop(self) -> None: if self._ws is None: self.logger.warning("Recv loop started without websocket instance") @@ -292,38 +99,6 @@ async def _recv_loop(self) -> None: 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: - try: - fut.result() - except asyncio.CancelledError: - pass - except Exception as e: - self.logger.exception("Error retrieving task exception: %s", e) - - async def _queue_message( - self, - opcode: int, - payload: dict[str, Any], - cmd: int = 0, - timeout: float = DEFAULT_TIMEOUT, - max_retries: int = 3, - ) -> None: - if self._outgoing is None: - self.logger.warning("Outgoing queue not initialized") - return - - message = { - "opcode": opcode, - "payload": payload, - "cmd": cmd, - "timeout": timeout, - "retry_count": 0, - "max_retries": max_retries, - } - - await self._outgoing.put(message) - self.logger.debug("Message queued for sending") - @override async def _send_and_wait( self, @@ -359,139 +134,6 @@ async def _send_and_wait( finally: self._pending.pop(msg["seq"], None) - async def _outgoing_loop(self) -> None: - while self.is_connected: - try: - 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 - self._error_count = 0 - self.logger.info("Circuit breaker reset") - else: - await asyncio.sleep(5) - continue - - 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", DEFAULT_TIMEOUT), - ) - self.logger.debug("Message sent successfully from queue") - 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: - self._circuit_breaker = True - self.logger.warning( - "Circuit breaker activated due to %d consecutive errors", - 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: %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", - max_retries, - ) - - except Exception: - self.logger.exception("Error in outgoing loop") - await asyncio.sleep(1) - - def _get_retry_delay(self, error: Exception, retry_count: int) -> float: - if isinstance(error, (ConnectionError, OSError)): - return 1.0 - elif isinstance(error, TimeoutError): - return 5.0 - elif isinstance(error, WebSocketNotConnectedError): - return 2.0 - else: - return float(2**retry_count) - - async def _sync(self, user_agent: UserAgentPayload) -> None: - self.logger.info("Starting initial sync") - - payload = SyncPayload( - interactive=True, - token=self._token, - chats_sync=0, - contacts_sync=0, - presence_sync=0, - drafts_sync=0, - chats_count=40, - user_agent=user_agent, - ).model_dump(by_alias=True) - try: - data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload) - raw_payload = data.get("payload", {}) - - if error := raw_payload.get("error"): - MixinsUtils.handle_error(data) - - for raw_chat in raw_payload.get("chats", []): - try: - if raw_chat.get("type") == ChatType.DIALOG.value: - self.dialogs.append(Dialog.from_dict(raw_chat)) - elif raw_chat.get("type") == ChatType.CHAT.value: - self.chats.append(Chat.from_dict(raw_chat)) - elif raw_chat.get("type") == ChatType.CHANNEL.value: - self.channels.append(Channel.from_dict(raw_chat)) - except Exception: - self.logger.exception("Error parsing chat entry") - - for raw_user in raw_payload.get("contacts", []): - try: - user = User.from_dict(raw_user) - if user: - self.contacts.append(user) - except Exception: - self.logger.exception("Error parsing contact entry") - - if raw_payload.get("profile", {}).get("contact"): - self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {})) - - self.logger.info( - "Sync completed: dialogs=%d chats=%d channels=%d", - len(self.dialogs), - len(self.chats), - len(self.channels), - ) - - except Exception as e: - self.logger.exception("Sync failed") - self.is_connected = False - if self._ws: - await self._ws.close() - self._ws = None - raise e - @override async def _get_chat(self, chat_id: int) -> Chat | None: for chat in self.chats: diff --git a/src/pymax/payloads.py b/src/pymax/payloads.py index 9a27eeb..d45327e 100644 --- a/src/pymax/payloads.py +++ b/src/pymax/payloads.py @@ -15,7 +15,7 @@ DEFAULT_TIMEZONE, DEFAULT_USER_AGENT, ) -from pymax.static.enum import AttachType, AuthType, ContactAction +from pymax.static.enum import AttachType, AuthType, ContactAction, ReadAction def to_camel(string: str) -> str: @@ -358,3 +358,10 @@ class LeaveChatPayload(CamelModel): class FetchChatsPayload(CamelModel): marker: int + + +class ReadMessagesPayload(CamelModel): + type: ReadAction + chat_id: int + message_id: str + mark: int diff --git a/src/pymax/protocols.py b/src/pymax/protocols.py new file mode 100644 index 0000000..cd20dbc --- /dev/null +++ b/src/pymax/protocols.py @@ -0,0 +1,123 @@ +import asyncio +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from logging import Logger +from typing import TYPE_CHECKING, Any, Literal + +from pymax.payloads import UserAgentPayload +from pymax.static.constant import DEFAULT_TIMEOUT +from pymax.static.enum import Opcode +from pymax.types import ( + Channel, + Chat, + Dialog, + Me, + Message, + ReactionInfo, + User, +) + +if TYPE_CHECKING: + import socket + import ssl + from pathlib import Path + from uuid import UUID + + import websockets + + from pymax.crud import Database + from pymax.filters import BaseFilter + + +class ClientProtocol(ABC): + def __init__(self, logger: Logger) -> None: + super().__init__() + self.logger = logger + self._users: dict[int, User] = {} + self.chats: list[Chat] = [] + self._database: Database + self._device_id: UUID + self.uri: str + self.is_connected: bool = False + self.phone: str + self.dialogs: list[Dialog] = [] + self.channels: list[Channel] = [] + self.contacts: list[User] = [] + self.me: Me | None = None + self.host: str + self.port: int + self.proxy: str | Literal[True] | None + self.registration: bool + self.first_name: str + self.last_name: str | None + self._token: str | None + self._work_dir: str + self.reconnect: bool + self.headers: UserAgentPayload + self._database_path: Path + self._ws: websockets.ClientConnection | None = None + self._seq: int = 0 + 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.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._session_id: int + self._action_id: int = 0 + self._current_screen: str = "chats_list_tab" + self._on_message_handlers: list[ + tuple[Callable[[Message], Any], BaseFilter[Message] | None] + ] = [] + self._on_message_edit_handlers: list[ + tuple[Callable[[Message], Any], BaseFilter[Message] | None] + ] = [] + self._on_message_delete_handlers: list[ + tuple[Callable[[Message], Any], BaseFilter[Message] | None] + ] = [] + self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = [] + self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = [] + self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = [] + self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = [] + 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 + + @abstractmethod + async def _send_and_wait( + self, + opcode: Opcode, + payload: dict[str, Any], + cmd: int = 0, + timeout: float = DEFAULT_TIMEOUT, + ) -> dict[str, Any]: + pass + + @abstractmethod + async def _get_chat(self, chat_id: int) -> Chat | None: + pass + + @abstractmethod + async def _queue_message( + self, + opcode: int, + payload: dict[str, Any], + cmd: int = 0, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = 3, + ) -> Message | None: + pass + + @abstractmethod + def _create_safe_task( + self, coro: Awaitable[Any], name: str | None = None + ) -> asyncio.Task[Any]: + pass diff --git a/src/pymax/static/constant.py b/src/pymax/static/constant.py index 3729827..e5fc6d5 100644 --- a/src/pymax/static/constant.py +++ b/src/pymax/static/constant.py @@ -1,10 +1,73 @@ +from random import choice, randint from re import Pattern, compile from typing import Final +import ua_generator from websockets.typing import Origin +from pymax.utils import MixinsUtils + +DEVICE_NAMES: Final[list[str]] = [ + "Chrome", + "Firefox", + "Edge", + "Safari", + "Opera", + "Vivaldi", + "Brave", + "Chromium", + # os + "Windows 10", + "Windows 11", + "macOS Big Sur", + "macOS Monterey", + "macOS Ventura", + "Ubuntu 20.04", + "Ubuntu 22.04", + "Fedora 35", + "Fedora 36", + "Debian 11", +] +SCREEN_SIZES: Final[list[str]] = [ + "1920x1080 1.0x", + "1366x768 1.0x", + "1440x900 1.0x", + "1536x864 1.0x", + "1280x720 1.0x", + "1600x900 1.0x", + "1680x1050 1.0x", + "2560x1440 1.0x", + "3840x2160 1.0x", +] +OS_VERSIONS: Final[list[str]] = [ + "Windows 10", + "Windows 11", + "macOS Big Sur", + "macOS Monterey", + "macOS Ventura", + "Ubuntu 20.04", + "Ubuntu 22.04", + "Fedora 35", + "Fedora 36", + "Debian 11", +] +TIMEZONES: Final[list[str]] = [ + "Europe/Moscow", + "Europe/Kaliningrad", + "Europe/Samara", + "Asia/Yekaterinburg", + "Asia/Omsk", + "Asia/Krasnoyarsk", + "Asia/Irkutsk", + "Asia/Yakutsk", + "Asia/Vladivostok", + "Asia/Kamchatka", +] + + PHONE_REGEX: Final[Pattern[str]] = compile(r"^\+?\d{10,15}$") WEBSOCKET_URI: Final[str] = "wss://ws-api.oneme.ru/websocket" +SESSION_STORAGE_DB = "session.db" WEBSOCKET_ORIGIN: Final[Origin] = Origin("https://web.max.ru") HOST: Final[str] = "api.oneme.ru" PORT: Final[int] = 443 @@ -12,16 +75,14 @@ DEFAULT_DEVICE_TYPE: Final[str] = "DESKTOP" DEFAULT_LOCALE: Final[str] = "ru" DEFAULT_DEVICE_LOCALE: Final[str] = "ru" -DEFAULT_DEVICE_NAME: Final[str] = "Chrome" -DEFAULT_APP_VERSION: Final[str] = "25.12.13" +DEFAULT_DEVICE_NAME: Final[str] = choice(DEVICE_NAMES) +DEFAULT_APP_VERSION: Final[str] = "25.12.14" DEFAULT_SCREEN: Final[str] = "1080x1920 1.0x" -DEFAULT_OS_VERSION: Final[str] = "Linux" -DEFAULT_USER_AGENT: Final[str] = ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" -) +DEFAULT_OS_VERSION: Final[str] = choice(OS_VERSIONS) +DEFAULT_USER_AGENT: Final[str] = ua_generator.generate().text DEFAULT_BUILD_NUMBER: Final[int] = 0x97CB -DEFAULT_CLIENT_SESSION_ID: Final[int] = 14 -DEFAULT_TIMEZONE: Final[str] = "Europe/Moscow" +DEFAULT_CLIENT_SESSION_ID: Final[int] = randint(1, 15) +DEFAULT_TIMEZONE: Final[str] = choice(TIMEZONES) DEFAULT_CHAT_MEMBERS_LIMIT: Final[int] = 50 DEFAULT_MARKER_VALUE: Final[int] = 0 DEFAULT_PING_INTERVAL: Final[float] = 30.0 diff --git a/src/pymax/static/enum.py b/src/pymax/static/enum.py index 37bc741..29ca8a7 100644 --- a/src/pymax/static/enum.py +++ b/src/pymax/static/enum.py @@ -216,3 +216,8 @@ class MarkupType(str, Enum): class ContactAction(str, Enum): ADD = "ADD" REMOVE = "REMOVE" + + +class ReadAction(str, Enum): + READ_MESSAGE = "READ_MESSAGE" + READ_REACTION = "READ_REACTION" diff --git a/src/pymax/types.py b/src/pymax/types.py index 6b37fa1..6c8cf1d 100644 --- a/src/pymax/types.py +++ b/src/pymax/types.py @@ -1193,3 +1193,28 @@ def __repr__(self) -> str: @override def __str__(self) -> str: return f"FolderList: {len(self.folders)} folders" + + +class ReadState: + def __init__( + self, + unread: int, + mark: int, + ) -> None: + self.unread = unread + self.mark = mark + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + return cls( + unread=data["unread"], + mark=data["mark"], + ) + + @override + def __repr__(self) -> str: + return f"ReadState(unread={self.unread!r}, mark={self.mark!r})" + + @override + def __str__(self) -> str: + return f"ReadState: unread={self.unread}, mark={self.mark}" diff --git a/src/pymax/utils.py b/src/pymax/utils.py new file mode 100644 index 0000000..c760bc7 --- /dev/null +++ b/src/pymax/utils.py @@ -0,0 +1,90 @@ +import re +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, NoReturn + +import requests + +from pymax.exceptions import Error, RateLimitError + + +class MixinsUtils: + @staticmethod + def handle_error(data: dict[str, Any]) -> NoReturn: + error = data.get("payload", {}).get("error") + localized_message = data.get("payload", {}).get("localizedMessage") + title = data.get("payload", {}).get("title") + message = data.get("payload", {}).get("message") + + if error == "too.many.requests": # TODO: вынести в статик + raise RateLimitError( + error=error, + message=message, + title=title, + localized_message=localized_message, + ) + + raise Error( + error=error, + message=message, + title=title, + localized_message=localized_message, + ) + + @staticmethod + def _fetch_and_extract(url: str, session: requests.Session) -> str | None: + try: + js_code = session.get(url, timeout=10).text + except requests.RequestException: + return None + return MixinsUtils._extract_version(js_code) + + @staticmethod + def _extract_version(js_code: str) -> str | None: + ws_anchor = "wss://ws-api.oneme.ru/websocket" + pos = js_code.find(ws_anchor) + if pos == -1: + return None + + snippet = js_code[pos : pos + 2000] + + match = re.search(r'[:=]\s*"(\d{1,2}\.\d{1,2}\.\d{1,2})"', snippet) + if match: + version = match.group(1) + return version + + return None + + @staticmethod + def get_current_web_version() -> str | None: + try: + html = requests.get("https://web.max.ru/", timeout=10).text + except requests.RequestException: + return None + + main_chunk_import = html.split("import(")[2].split(")")[0].strip("\"'") + main_chunk_url = f"https://web.max.ru{main_chunk_import}" + try: + main_chunk_code = requests.get(main_chunk_url, timeout=10).text + except requests.exceptions.RequestException as e: + return None + + arr = main_chunk_code.split("\n")[0].split("[")[1].split("]")[0].split(",") + urls = [] + for i in arr: + if "/chunks/" in i: + url = "https://web.max.ru/_app/immutable" + i[3 : len(i) - 1] + urls.append(url) + + session = requests.Session() + session.headers["User-Agent"] = "Mozilla/5.0" + if urls: + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [ + pool.submit(MixinsUtils._fetch_and_extract, url, session) for url in urls + ] + for f in as_completed(futures): + ver = f.result() + if ver: + return ver + return None