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