diff --git a/docs/account.rst b/docs/account.rst index 83d6df6..9a1e8da 100644 --- a/docs/account.rst +++ b/docs/account.rst @@ -53,6 +53,16 @@ Account photo_token="PHOTO_TOKEN", ) +Статус присутствия +------------------ + +``set_presence()`` меняет статус, который будет использован при следующем +login или ping: + +.. code-block:: python + + client.set_presence(online=True) + Папки чатов ----------- diff --git a/docs/api/files.rst b/docs/api/files.rst index ab54f95..fe9cf6e 100644 --- a/docs/api/files.rst +++ b/docs/api/files.rst @@ -12,3 +12,11 @@ Files API .. autoclass:: pymax.Video :members: :show-inheritance: + +.. autoclass:: pymax.VideoNote + :members: + :show-inheritance: + +.. autoclass:: pymax.Voice + :members: + :show-inheritance: diff --git a/docs/chats.rst b/docs/chats.rst index 1f1d59b..26e353d 100644 --- a/docs/chats.rst +++ b/docs/chats.rst @@ -90,6 +90,40 @@ login/sync, а также методы для загрузки, создания ``invite()`` работает только для групп и каналов. Для личного диалога тип чата не подходит, и метод завершится ошибкой. +Участники и администраторы канала +--------------------------------- + +``get_chat_members()`` возвращает одну страницу участников и маркер следующей: + +.. code-block:: python + + members, marker = await client.get_chat_members(chat_id=123456, count=50) + for member in members: + print(member.contact.id) + + if marker: + next_members, marker = await client.get_chat_members( + chat_id=123456, + marker=marker, + count=50, + ) + +Назначить администратора канала можно с явным набором прав: + +.. code-block:: python + + from pymax.api.chats import ChannelPermissions + + await client.add_admin( + chat_id=123456, + user_id=111, + permissions=[ + ChannelPermissions.POST_MESSAGE, + ChannelPermissions.EDIT_MESSAGE, + ChannelPermissions.DELETE_MESSAGE, + ], + ) + Настройки и профиль группы -------------------------- diff --git a/docs/client.rst b/docs/client.rst index be51009..0fa4e3e 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -33,6 +33,18 @@ Client 6. Вызывается ``on_start``. 7. Клиент слушает события до закрытия соединения или отмены задачи. +Если ``start()`` запущен отдельной задачей, ``stop()`` штатно закрывает +соединение и завершает эту задачу без ``CancelledError``: + +.. code-block:: python + + import asyncio + + task = asyncio.create_task(client.start()) + # ... работа приложения ... + await client.stop() + await task + Данные после login ------------------ @@ -54,6 +66,15 @@ Client Для актуализации используйте явные методы: ``fetch_chats()``, ``get_chat()``, ``get_users()``, ``fetch_users()`` и ``fetch_history()``. +После handshake можно проверить, требует ли Max обновления версии приложения: + +.. code-block:: python + + @client.on_start() + async def on_start(client: Client) -> None: + if client.is_update_available(): + print("Для выбранной версии приложения доступно обновление") + Создание клиента ---------------- @@ -300,13 +321,14 @@ Debug-логи показывают handshake, login, входящие собы Клиент собирает несколько API-направлений: Сообщения - ``send_message()``, ``forward_message()``, ``fetch_history()``, + ``send_message()``, ``forward_message()``, ``fetch_history()``, ``vote_poll()``, ``delete_message()``, ``pin_message()``, ``read_message()``, реакции и получение URL для входящих файлов/видео. Чаты ``get_chat()``, ``fetch_chats()``, создание групп, invite-ссылки, - участники, настройки групп, удаление чатов и выход из групп/каналов. + участники, назначение администраторов, настройки групп, удаление чатов и + выход из групп/каналов. Пользователи ``get_user()``, ``get_users()``, ``fetch_users()``, ``search_by_phone()``, diff --git a/docs/files.rst b/docs/files.rst index 351afcc..989f5cb 100644 --- a/docs/files.rst +++ b/docs/files.rst @@ -4,7 +4,7 @@ Files Что это ------- -Для отправки вложений PyMax использует три класса: +Для отправки вложений PyMax использует пять основных классов: ``Photo`` Фото. Проверяет расширение и MIME-тип. @@ -15,6 +15,14 @@ Files ``File`` Обычный файл. Тоже загружается чанками и ждет событие готовности. +``Voice`` + Голосовое сообщение. Поддерживается только формат OGG; PyMax не + конвертирует другие аудиоформаты. + +``VideoNote`` + Круглое видеосообщение. Можно передать длительность вручную или установить + extra ``video`` для автоматического определения. + Как отправить файл ------------------ @@ -22,7 +30,7 @@ Files import asyncio - from pymax import Client, File, Photo, Video + from pymax import Client, File, Photo, Video, VideoNote, Voice client = Client(phone="+79990000000", work_dir="cache") @@ -46,6 +54,11 @@ Files attachments=[Video(path="clip.mp4")], ) + await chat.answer(attachments=[Voice(path="voice.ogg")]) + await chat.answer( + attachments=[VideoNote(path="circle.mp4", duration=4200)] + ) + asyncio.run(client.start()) @@ -59,9 +72,48 @@ Files Photo(path="image.jpg") File(url="https://example.com/report.pdf") Video(raw=b"...", name="clip.mp4") + Voice(path="voice.ogg") + VideoNote(path="circle.mp4", duration=4200) + +Для ``raw`` обязательно указывайте ``name``. Для ``File``, ``Video``, +``Voice`` и ``VideoNote`` имя берется из ``path`` или ``url``, если не +передано явно. + +Если длительность ``VideoNote`` не передана, установите дополнительную +зависимость: + +.. code-block:: console -Для ``raw`` обязательно указывайте ``name``. Для ``File`` и ``Video`` имя -берется из ``path`` или ``url``, если не передано явно. + uv add "maxapi-python[video]" + +Формат Voice и VideoNote +------------------------ + +Для ``Voice`` используйте готовый OGG-файл. Простого переименования MP3, WAV +или другого аудиофайла в ``.ogg`` недостаточно: PyMax загружает исходные байты +без перекодирования. + +``VideoNote`` также не перекодирует видео. Для совместимости с официальным +клиентом 26.21.1 рекомендуется следующий формат: + +* контейнер MP4; +* видео H.264/AVC, 480x480, 30 FPS и bitrate около 1 024 000 bit/s; +* pixel format ``yuv420p`` при подготовке через FFmpeg; +* ключевой кадр примерно раз в секунду, то есть GOP около 30 кадров; +* аудио AAC в том же MP4-контейнере; +* длительность до 60 секунд. + +Официальный клиент задает квадратное разрешение, фиксированные 30 FPS и +максимальную длительность через server config. Нижняя граница в одну секунду +не является подтвержденным ограничением upload API, поэтому PyMax ее не +проверяет. + +Поворот лучше физически применить при перекодировании и убрать rotation +metadata: так файл меньше зависит от того, как конкретный клиент обработает +orientation hint. H.264 profile и level специально фиксировать не требуется. +``faststart`` официальный recorder явно не включает; при самостоятельной +подготовке файла его можно использовать, но для PyMax это не обязательное +условие. Как работает upload ------------------- @@ -69,7 +121,8 @@ Files 1. PyMax запрашивает у Max временный upload URL. 2. Читает файл из ``path``, ``url`` или ``raw``. 3. Загружает данные HTTP-запросом. -4. Для ``File`` и ``Video`` ждет служебное событие готовности до 60 секунд. +4. Для ``File``, ``Video``, ``Voice`` и ``VideoNote`` ждет служебное событие + готовности до 60 секунд. 5. Подставляет token/file_id/video_id в отправляемое сообщение. Фото проходит проще: после HTTP-upload PyMax сразу достает token из ответа. @@ -115,3 +168,7 @@ Files Upload-сервис не получил нужный ответ от Max. Включите ``DEBUG``-логи: часто причина в недоступном URL, неверном размере файла, timeout или в том, что событие готовности файла не пришло за 60 секунд. + +``Automatic video duration detection requires the 'video' extra`` + Передайте ``duration`` в миллисекундах или установите + ``maxapi-python[video]``. diff --git a/docs/index.rst b/docs/index.rst index 955401e..a9885ba 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он :maxdepth: 1 :caption: Новости + release-2-4-0 release-2-3-1 release-2-3-0 release-2-2-0 diff --git a/docs/messages.rst b/docs/messages.rst index ff84f88..0c56f47 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -83,6 +83,57 @@ Messages source_chat_id=123456, ) +Опросы +------ + +Опрос можно отправить без текста. Настройки объединяются оператором ``|``: + +.. code-block:: python + + from pymax.types import Poll, PollAnswer, PollFlags + + await client.send_message( + chat_id=123456, + attachments=[ + Poll( + title="Какой вариант выбрать?", + answers=[ + PollAnswer(text="Первый"), + PollAnswer(text="Второй"), + ], + settings=( + PollFlags.FLAG_SETTINGS_ANONYMOUS + | PollFlags.FLAG_SETTINGS_REVOTE + ), + ) + ], + ) + +Для голосования нужны ID сообщения, опроса и вариантов из входящего +``PollAttachment``: + +.. code-block:: python + + from pymax import Message + from pymax.types import PollAttachment + + @client.on_message() + async def vote(message: Message, client: Client) -> None: + if message.chat_id is None: + return + + for attach in message.attaches: + if isinstance(attach, PollAttachment): + answer_id = attach.answers[0].answer_id + if answer_id is not None: + state = await client.vote_poll( + chat_id=message.chat_id, + message_id=message.id, + poll_id=attach.poll_id, + answer_ids=[answer_id], + ) + print(state.total) + Ответ, реакции, удаление и прочтение ---------------------------------------- @@ -147,7 +198,7 @@ Messages .. code-block:: python history = await client.fetch_history(chat_id=123456, backward=50) - for message in history or []: + for message in history: print(message.id, message.text) ``fetch_history()`` принимает ``item_type``. По умолчанию используются обычные @@ -178,8 +229,8 @@ Max присылает разные формы событий. Некоторы -------- Входящие вложения лежат в ``message.attaches``. Тип вложения определяется по -полю ``type``: фото, видео, файл, стикер, аудио, контакт, звонок, share или -inline-клавиатура. +полю ``type``: фото, видео, файл, стикер, аудио, опрос, контакт, звонок, share +или inline-клавиатура. .. code-block:: python diff --git a/docs/release-2-4-0.rst b/docs/release-2-4-0.rst new file mode 100644 index 0000000..6055d8d --- /dev/null +++ b/docs/release-2-4-0.rst @@ -0,0 +1,210 @@ +PyMax 2.4.0 +=========== + +Версия 2.4.0 добавляет опросы, голосовые сообщения, кружки, настройки +приватности и новые методы управления чатами. Одновременно уточнены контракты +существующих методов и исправлены проблемы с lifecycle клиента, повторной +авторизацией и WebSocket. + +Ниже перечислены изменения относительно ``2.3.1``, которые важны при +использовании библиотеки и обновлении существующего кода. + +Новый публичный API +------------------- + +Опросы +~~~~~~ + +Добавлены модели ``Poll``, ``PollAnswer`` и ``PollFlags`` для создания +опросов. Опрос отправляется обычным ``send_message()`` и может быть +единственным вложением без текста: + +.. code-block:: python + + from pymax.types import Poll, PollAnswer, PollFlags + + message = await client.send_message( + chat_id=123456, + attachments=[ + Poll( + title="Выберите вариант", + answers=[ + PollAnswer(text="Первый"), + PollAnswer(text="Второй"), + ], + settings=PollFlags.FLAG_SETTINGS_REVOTE, + ) + ], + ) + +Для голосования добавлен метод: + +``await client.vote_poll(chat_id, message_id, poll_id, answer_ids) -> PollState`` + +Входящий опрос представлен моделью ``PollAttachment``. Она содержит +``poll_id``, варианты ответа и текущее состояние ``PollState`` с +типизированными результатами и голосами. + +Голосовые сообщения и кружки +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Добавлены два типа отправляемых вложений: + +``Voice`` + Голосовое сообщение. Принимает только готовый OGG-файл. PyMax не + перекодирует MP3, WAV и другие форматы. + +``VideoNote`` + Круглое видеосообщение. Рекомендуемый формат — MP4 с H.264-видео 480x480, + 30 FPS и AAC-аудио. Длительность передается в миллисекундах через + ``duration``. + +Оба класса экспортируются из ``pymax`` и передаются в ``attachments``: + +.. code-block:: python + + from pymax import VideoNote, Voice + + await client.send_message( + chat_id=123456, + attachments=[Voice(path="voice.ogg")], + ) + await client.send_message( + chat_id=123456, + attachments=[VideoNote(path="circle.mp4", duration=4200)], + ) + +Если ``duration`` для ``VideoNote`` не указан, PyMax может определить его +автоматически с необязательной зависимостью ``video``:: + + uv add "maxapi-python[video]" + +Чаты и состояние аккаунта +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: Новые методы + :header-rows: 1 + :widths: 35 40 25 + + * - Метод + - Назначение + - Результат + * - ``get_chat_members(chat_id, marker=None, count=50)`` + - Получить страницу участников чата. Ненулевой ``marker`` передается в + следующий вызов. + - ``tuple[list[Member], int]`` + * - ``add_admin(chat_id, user_id, permissions)`` + - Назначить администратора канала с явным списком + ``ChannelPermissions``. + - ``None`` + * - ``set_presence(online=...)`` + - Изменить статус, используемый при следующем login или ping. + - ``None`` + * - ``is_update_available()`` + - Проверить флаг обновления, полученный при handshake. + - ``bool`` + +Настройки приватности +~~~~~~~~~~~~~~~~~~~~~ + +Добавлен метод +``await client.change_profile_settings(settings) -> bool``. Он принимает +``PrivacySettingsUpdate`` и изменяет только заполненные поля: + +.. code-block:: python + + from pymax import PrivacyAccess, PrivacySettingsUpdate + + await client.change_profile_settings( + PrivacySettingsUpdate( + search_by_phone=PrivacyAccess.CONTACTS, + incoming_calls=PrivacyAccess.ALL, + chat_invites=PrivacyAccess.NOBODY, + phone_number_visibility=PrivacyAccess.CONTACTS, + hide_online_status=True, + safe_content_only=True, + ) + ) + +Для настроек доступа используются ``PrivacyAccess.ALL``, +``PrivacyAccess.CONTACTS`` и ``PrivacyAccess.NOBODY``. После успешного запроса +PyMax сохраняет новый ``config_hash`` в текущей сессии, поэтому следующая +авторизация продолжает синхронизацию с актуального состояния. + +Изменения существующего API +--------------------------- + +.. list-table:: Изменившиеся контракты + :header-rows: 1 + :widths: 30 30 40 + + * - API + - Было в 2.3.1 + - Стало в 2.4.0 + * - ``send_message()`` и ``forward_message()`` + - Возвращаемый тип допускал ``Message | None``. + - Возвращают ``Message``. Если сервер не прислал обязательный объект + сообщения, вызов завершается ошибкой. + * - ``Message.reply()``, ``Message.answer()``, ``Message.forward()`` и + ``Chat.answer()`` + - Bound-методы также имели необязательный возвращаемый тип. + - Возвращают ``Message`` без лишней проверки на ``None``. + * - ``send_message()``, ``edit_message()``, ``Message.reply()``, + ``Message.answer()`` и ``Chat.answer()`` + - Для type checker требовался текст. + - ``text`` может быть ``None``, если переданы вложения. Если нет ни + текста, ни вложений, выбрасывается ``ValueError``. + * - ``fetch_history()`` + - При отсутствии сообщений мог вернуть ``None``. + - Всегда возвращает ``list[Message]``; пустая история — ``[]``. + * - ``get_bot_init_data()`` + - ``chat_id`` был обязательным. + - ``chat_id`` необязателен, поэтому метод можно вызвать вне чата. + * - ``change_profile()`` + - ``photo`` был типизирован как ``Any``. + - ``photo`` принимает ``Photo | None``. + +Также исправлены типы частичных моделей протокола. В частности, +``StickerAttachment.set_id`` теперь имеет тип ``int | None``, а поля неполных +событий реакций больше не считаются безусловно заполненными. + +Breaking changes +---------------- + +Методы и классы из 2.3.1 не удалялись и не переименовывались. Проверить перед +обновлением нужно два наблюдаемых изменения поведения: + +* Код, который отличал ``None`` от пустого списка после ``fetch_history()``, + должен проверять пустоту списка: ``if not history``. +* Попытка отправить или отредактировать сообщение без текста и без вложений + теперь сразу завершается ``ValueError``. Сообщения только с вложением + поддерживаются штатно. + +Изменения ``Message | None`` на ``Message`` у методов отправки и пересылки — +уточнение публичного контракта, а не новое успешное значение в runtime. +Пользователи mypy или Pyright могут удалить лишние проверки результата на +``None``. При работе со ``StickerAttachment.set_id`` проверка на ``None``, +наоборот, теперь требуется. + +Исправления стабильности и совместимости +---------------------------------------- + +* Если текущую сессию отозвали с другого устройства, ответы + ``FAIL_LOGIN_TOKEN`` и ``FAIL_LOGOUT_ALL`` больше не оставляют клиент в + цикле переподключений. PyMax удаляет недействительный локальный token и + запускает авторизацию заново. Закрытие TLS-соединения ограничено timeout, + поэтому зависший shutdown не блокирует клиент бесконечно. +* ``stop()`` штатно завершает ожидающий ``start()``. Внешняя отмена задачи при + этом не поглощается и продолжает распространяться вызывающему коду. +* WebSocket-клиент использует актуальный endpoint и корректно обрабатывает + бинарные protocol frames. +* Mobile login поддерживает двухэтапный LOGIN/LOGIN2. Fingerprint устройства + выбирается автоматически из данных Android-версий от 26.9.1 до 26.25.0. +* Handshake без обязательного ``callsSeed`` завершается контролируемой ошибкой, + а не оставляет клиент в частично инициализированном состоянии. +* Исправлены очистка ожиданий при ошибках upload, выбор MP4 URL по качеству и + разбор частичных событий реакций и голосов в опросах. + +Базовый набор зависимостей не изменился. Extra ``video`` нужен только для +автоматического определения длительности ``VideoNote``; при явном +``duration`` он не требуется. diff --git a/docs/types/enums.rst b/docs/types/enums.rst index 967cba2..03b480c 100644 --- a/docs/types/enums.rst +++ b/docs/types/enums.rst @@ -19,6 +19,15 @@ Domain enums .. autoclass:: pymax.types.domain.attachments.enums.TranscriptionStatus :members: +.. autoclass:: pymax.types.domain.attachments.enums.PollFlags + :members: + +Chat API enums +-------------- + +.. autoclass:: pymax.api.chats.ChannelPermissions + :members: + Client config enums ------------------- diff --git a/docs/types/index.rst b/docs/types/index.rst index 07d4c8b..b82f594 100644 --- a/docs/types/index.rst +++ b/docs/types/index.rst @@ -40,7 +40,7 @@ Types ``ContactInfo`` Контакт телефонной книги для ``import_contacts()``. -``PhotoAttachment``, ``VideoAttachment``, ``FileAttachment`` и другие +``PhotoAttachment``, ``VideoAttachment``, ``FileAttachment``, ``PollAttachment`` и другие Входящие вложения в ``message.attaches``. ``SyncState`` и ``SyncOverrides`` @@ -112,6 +112,8 @@ API reference control_attachment file_attachment photo_attachment + poll + poll_attachment share_attachment sticker_attachment video_attachment diff --git a/docs/types/poll.rst b/docs/types/poll.rst new file mode 100644 index 0000000..fc74bb1 --- /dev/null +++ b/docs/types/poll.rst @@ -0,0 +1,22 @@ +Poll +==== + +.. autoclass:: pymax.types.domain.attachments.poll.Poll + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollAnswer + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollState + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollResult + :members: + :show-inheritance: + +.. autoclass:: pymax.types.domain.attachments.poll.PollVote + :members: + :show-inheritance: diff --git a/docs/types/poll_attachment.rst b/docs/types/poll_attachment.rst new file mode 100644 index 0000000..e1f1f8f --- /dev/null +++ b/docs/types/poll_attachment.rst @@ -0,0 +1,6 @@ +PollAttachment +============== + +.. autoclass:: pymax.types.domain.attachments.poll.PollAttachment + :members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 7cffd59..3fec737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "2.3.1" +version = "2.4.0" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" @@ -84,6 +84,11 @@ dev = [ "ruff>=0.8.0", ] +[project.optional-dependencies] +video = [ + "tinytag>=2.2.1", +] + [tool.uv] package = true diff --git a/src/pymax/__init__.py b/src/pymax/__init__.py index 182048c..bebc9b8 100644 --- a/src/pymax/__init__.py +++ b/src/pymax/__init__.py @@ -1,6 +1,7 @@ -__version__ = "2.3.1" +__version__ = "2.4.0" +from .api.self import PrivacyAccess, PrivacySettingsUpdate from .auth import ( AuthFlow, ConsolePasswordProvider, @@ -17,7 +18,7 @@ from .config import ExtraConfig, RegistrationConfig from .dispatch import EventType, Router from .exceptions import ApiError, PyMaxError, UploadError -from .files import File, Photo, Video +from .files import File, Photo, Video, VideoNote, Voice from .logging import configure_logging from .routers import ClientRouter, WebRouter from .types import ( @@ -52,6 +53,8 @@ "Photo", "PresenceEvent", "Profile", + "PrivacyAccess", + "PrivacySettingsUpdate", "PyMaxError", "QrAuthFlow", "QrHandler", @@ -66,6 +69,8 @@ "UploadError", "User", "Video", + "VideoNote", + "Voice", "WebClient", "WebRouter", "__version__", diff --git a/src/pymax/_data/apk_fingerprints.json b/src/pymax/_data/apk_fingerprints.json new file mode 100644 index 0000000..8456b7b --- /dev/null +++ b/src/pymax/_data/apk_fingerprints.json @@ -0,0 +1,682 @@ +{ + "26.9.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "6ce0d31a723db1a03d0523236b37b7a2000428ce60bd0ff34e45d082dd7c1590", + "so_meta_sha256_arm64_v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", + "so_meta_sha256": { + "arm64-v8a": "10be2bbf7629dfed4a50b8c1114f8386c271df3a3fe1bee433b7846483ed183e", + "armeabi-v7a": "6fe430d8d622243d8d7aba3394f40c4df59474b6291e9ed78c023fc2f7dc5281", + "x86": "0dc31a603ecbd77cb0067440a99283444613053755a3f823ef1a9e249e25da4c", + "x86_64": "7bf4bc1e18412814ccc62dedce0419f1444504d28c634cb394cb13d381e39820" + }, + "build_number": 6643 + }, + "26.10.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "40ce6cc7fdd85631210bc2096727f20a12a01da416cfb52b3eb0c3f2585a7367", + "so_meta_sha256_arm64_v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "so_meta_sha256": { + "arm64-v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "armeabi-v7a": "93c05715a6d63eeec9485f02e38c55b53d5ec5e1ddcbbd987e211bb308322fcc", + "x86": "7677e418a5a2558d1ed8b5fb19f6f7099289ba15b93cf732be098882c06b83e8", + "x86_64": "cbfec115b7a7c19b6a9fd36ec1f066cac829e783f8ceb0ba3314cd8abeb8159d" + }, + "build_number": 6648 + }, + "26.10.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "8e36caf5dbfc4988a8d4a0e758651b40c50b7fdf3a5accb9f3d31ec36bad57a6", + "so_meta_sha256_arm64_v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "so_meta_sha256": { + "arm64-v8a": "657cd4630bc7435e9430f34e968cc7134b8e6d5c9b35f66360025aedd70cc548", + "armeabi-v7a": "93c05715a6d63eeec9485f02e38c55b53d5ec5e1ddcbbd987e211bb308322fcc", + "x86": "7677e418a5a2558d1ed8b5fb19f6f7099289ba15b93cf732be098882c06b83e8", + "x86_64": "cbfec115b7a7c19b6a9fd36ec1f066cac829e783f8ceb0ba3314cd8abeb8159d" + }, + "build_number": 6653 + }, + "26.11.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "17e5ebaa23b199c6b8efae0d8d95e162d7c682d9e80f68959af36a7e3fc1bd35", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6665 + }, + "26.11.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "67fe33fefaed5e8395bb3238a75f5d62f8601676d9edad18fd8b8f52844f14a6", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6669 + }, + "26.11.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "63fcf3e2e6469b071ab968da239d34628277e990c57f3f83ace078dfe44baf52", + "so_meta_sha256_arm64_v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "so_meta_sha256": { + "arm64-v8a": "f6d815f5a30e99c08186bb3faef1386bfc2843c475b6d85c31fbe737d3b76a5b", + "armeabi-v7a": "ddbb7480ad20d047e74223a6f35b5a163fc46e2ae3ca4e1bb0a16f8603e1f4ba", + "x86": "efe3e92e01275e2c081c8fb4e6bed48282bf8df69426b8baf25a917aac3be9c0", + "x86_64": "f89f125e8b391c5fb6deaedeee9644e96b28e8697375e4de2519c0d7a9e21cd6" + }, + "build_number": 6670 + }, + "26.12.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "f04895bdcfb986071501d5723fa90d52f2c7e50a41c49b47796843307003e46a", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6676 + }, + "26.12.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "c9e5bd819ad44fedab72ffb096303a8ca106a26734eb8f9012384ec4b3ed8828", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6679 + }, + "26.12.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "bc2d8fb636db050a79751d4894c57ccb9ac72d2d98777aabd7af39b43e4150da", + "so_meta_sha256_arm64_v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "so_meta_sha256": { + "arm64-v8a": "1f053dc348faade18fe555eb9fd088c2e24d30abed16c60a119b436dd0b983f9", + "armeabi-v7a": "7b717c679d9f0b1fdf7db084e7b3747abe3ca23fb60b5430fb63fea3a58d4ed2", + "x86": "8c4eea1363d19fbdcc7c98ac2f60ba36537744138908fc2eed6331ffa6c211b7", + "x86_64": "b926cd7622706846e17b8c8fa96885b3e6414281e3dced45bc55043e5c2f6c09" + }, + "build_number": 6681 + }, + "26.13.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d07bf9979006300cc81a1e8db07138c9f67dd485e95da8a1793844586dec47f9", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6683 + }, + "26.14.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "ac3a88bd9a3f8a0c5448d425ac71e55c7c3605f9e6d318ef59ce83680c911973", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6685 + }, + "26.14.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "19ef26ac2d0e081ad7f75a8b71a7f1bb8211c0ff7fedca1e029b883039c09523", + "so_meta_sha256_arm64_v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "so_meta_sha256": { + "arm64-v8a": "f053a624bb24c7e0db16b90ce8073817452bbee30f3b69b6b2c1f194bc9eab21", + "armeabi-v7a": "6879c186279c006715ede67c5f86b7d2dbbf83b92a73a15d64f2ecf2e7298707", + "x86": "0ba0f8ac702ebe4a67e94999e43585bfe002a6e8dbbc914aa7c1e699d2a317c6", + "x86_64": "376cd6f29722614928e86b8969cb46da3950dc927e2b82bd382973a542e65057" + }, + "build_number": 6686 + }, + "26.15.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d731008f3040c26f57a3712551ad802860df8c9b58d40d7dd5a932e0e3becc83", + "so_meta_sha256_arm64_v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "so_meta_sha256": { + "arm64-v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "armeabi-v7a": "91ac4177f3c199c18037bb7f41ac9d5e38b07f4bdbbcaec3e75f09e2eb10424e", + "x86": "b5cccece8fa721d810d5eeec01c54ad1c9e53d9f08004aa4fed7143a1a81be51", + "x86_64": "62e7d25a401d85190e3f0be6880e22c89c7c5cbd9d54c3c12ff02762225c354b" + }, + "build_number": 6689 + }, + "26.15.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "2554fc75f0071c664b26ccc00d3dbe0da5a4e88cfa60b6489ddecda6ab7f9546", + "so_meta_sha256_arm64_v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "so_meta_sha256": { + "arm64-v8a": "8c0f653a776f3b2dfd88b96a2881c4a07491f34ac3e7bb9654de680ab216e497", + "armeabi-v7a": "91ac4177f3c199c18037bb7f41ac9d5e38b07f4bdbbcaec3e75f09e2eb10424e", + "x86": "b5cccece8fa721d810d5eeec01c54ad1c9e53d9f08004aa4fed7143a1a81be51", + "x86_64": "62e7d25a401d85190e3f0be6880e22c89c7c5cbd9d54c3c12ff02762225c354b" + }, + "build_number": 6690 + }, + "26.15.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "54fc7e9d3c650a71959b3f33a15f1fe5b1e400554a52eb69f5ad11bc728a8131", + "so_meta_sha256_arm64_v8a": "50587953906a4f339caa16efe6e7899ee7793867b1acbe2c070a6af8b82003d8", + "so_meta_sha256": { + "arm64-v8a": "50587953906a4f339caa16efe6e7899ee7793867b1acbe2c070a6af8b82003d8", + "armeabi-v7a": "e04e120ccf068113d17a74b257c6c99b74fb2b27c353dfb680788918e4d1dae3", + "x86": "c80ad2ca78112f4de0a433ac00f9d649a9ea54d19e708afd383bf3aafb6b738c", + "x86_64": "6ff5f113c2e1629066a6f0c711d03421c30f9fc9f21460925bdc9784dc35cc35" + }, + "build_number": 6695 + }, + "26.16.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "83087537a8f09362f12f3349cd7d706071cf6ab05936bf16ea1feb25137c2b87", + "so_meta_sha256_arm64_v8a": "516c645f72b401bfd7f87cd569f729829d693994aa42e48e3a3577b956d1a7cf", + "so_meta_sha256": { + "arm64-v8a": "516c645f72b401bfd7f87cd569f729829d693994aa42e48e3a3577b956d1a7cf", + "armeabi-v7a": "444c24284fbdc8b30c76db93701c4bed66228a988076004dbd2c28c1b5d7d518", + "x86": "8a1311f525d0bfe194c2fb035fa5a31a13938eee336c5b10f9b2471db9b6a7a7", + "x86_64": "6743a193eed8f1fa278088e30ac05e5a7904f7b9085dd7863fecd4516551912d" + }, + "build_number": 6698 + }, + "26.16.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "488def68d46ec1fd2619e32f660b3952a2e66d20b83ea278f329aa9f0d26ede4", + "so_meta_sha256_arm64_v8a": "523ab358b5dbc6a2da414628e3db3c2d58b8609b16d3bd58bd85d302735fd11d", + "so_meta_sha256": { + "arm64-v8a": "523ab358b5dbc6a2da414628e3db3c2d58b8609b16d3bd58bd85d302735fd11d", + "armeabi-v7a": "b557e40be72405eaced69a68ac1933d116e03e0566a0680a8fe63fb80befb453", + "x86": "7719bedd257bcb7a11086e6a5103331fb4b09f0ec31362e6629b941b136cde22", + "x86_64": "2e70f23f1647d9eb2102eda444f16af19f2c8a2ddb31d2d83ce5a5d796136069" + }, + "build_number": 6700 + }, + "26.16.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "6a10808cad3df516fbcc9104489aeec66ec44f97adfe738e4b8850c3b726ce65", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6701 + }, + "26.16.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "4ffe56a6ce29fd969d0914f82dbb0d6c27f0ddc562ef260a366009dc291b8522", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6702 + }, + "26.16.4": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "70efe2238d390938c35b37f9308403a5f9c80eb567b621f813114b99cc6af51e", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6704 + }, + "26.17.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6712 + }, + "26.18.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "f83628293b3a049e8d1d52e7c4d5ae1f9d82657fe79b41c6ee825383b7380c75", + "so_meta_sha256_arm64_v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "so_meta_sha256": { + "arm64-v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "armeabi-v7a": "3de2820ce703cca5cf3bf84cbcc54a68335f0613e6f2dffa869516f8ddb0be27", + "x86": "b5285b7d54cdadc30b9ebaf5c03e2be837961dbad306235ec845be4c949ed658", + "x86_64": "8cdb44f669a2a85894b9efa15d06532e11f630fce0e8a8ca7de9f45deba3f983" + }, + "build_number": 6715 + }, + "26.18.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "5cf3b57eaf67a647fc3276cc889f931e8e4e2a8a31a8f021649a071878dd55ba", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6716 + }, + "26.18.2": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "35fea4a39ea9ac47ec55232b6fcd365edb3755465f260c0b3f70fe18e63a401a", + "so_meta_sha256_arm64_v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "so_meta_sha256": { + "arm64-v8a": "69fa450e849973f271df1d47717fd7b727e8952054fd5c5538f691fa94a99867", + "armeabi-v7a": "3de2820ce703cca5cf3bf84cbcc54a68335f0613e6f2dffa869516f8ddb0be27", + "x86": "b5285b7d54cdadc30b9ebaf5c03e2be837961dbad306235ec845be4c949ed658", + "x86_64": "8cdb44f669a2a85894b9efa15d06532e11f630fce0e8a8ca7de9f45deba3f983" + }, + "build_number": 6720 + }, + "26.18.4": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "bf5c685810d20e9dde60d142169329f1fafbdc0d4b64de853bf1b45f4e36250c", + "so_meta_sha256_arm64_v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "so_meta_sha256": { + "arm64-v8a": "c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111", + "armeabi-v7a": "ee528ac9a4c0a5f511e31c060932def891c006b4c70489537c7dc6616169a0f3", + "x86": "b168be77610669943c6ce012e4c5f565e5b550aa89db36db4fddaa3ef042bf40", + "x86_64": "776af571f4589d4a8a343f2b65506dea78cbf58ccdfeb52536ccabfeb281ca73" + }, + "build_number": 6724 + }, + "26.19.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "e9e3e7435850b0081d8200bb29e4d755bbd4b22e713048b2ebf952092220c1da", + "so_meta_sha256_arm64_v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "so_meta_sha256": { + "arm64-v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "armeabi-v7a": "0aae3ded3151f83dfd59bca3058bcb155798cf72895a52ca629d3dd4646183d5", + "x86": "34d2583be4b2161709ab0511a6834e9d9503093a7b43e84c42334e53308576b6", + "x86_64": "3b55db2a5780e6808092fe5ac228933e1a4eadc3102018ec8dea1575f4748a8e" + }, + "build_number": 6727 + }, + "26.19.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "0fefb0ece6b4d59f0b7a25ccbc403dfa1492e73b13b3f94d80f585a2d21f7de4", + "so_meta_sha256_arm64_v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "so_meta_sha256": { + "arm64-v8a": "88ba23d1352a2c4c0ec92d6e96c41b3494e7346a1409c97158e494256d0ebbdb", + "armeabi-v7a": "0aae3ded3151f83dfd59bca3058bcb155798cf72895a52ca629d3dd4646183d5", + "x86": "34d2583be4b2161709ab0511a6834e9d9503093a7b43e84c42334e53308576b6", + "x86_64": "3b55db2a5780e6808092fe5ac228933e1a4eadc3102018ec8dea1575f4748a8e" + }, + "build_number": 6729 + }, + "26.19.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "d590910db09464e19553e22c184c135ace8c6cb6d4407920f949d561724fb8fe", + "so_meta_sha256_arm64_v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "so_meta_sha256": { + "arm64-v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "armeabi-v7a": "0a7747106ea8b797fb0b8093b1701b08cebb5573c1a952f42f684cd818abf3e5", + "x86": "d4ee5fde610e036dbedfe51da6d900c4fd348f291658a95350d59d6e1f8e2ff5", + "x86_64": "e12ac08d99994274ade0d7f7ce2ec39c73c11ef8e8bb51e4961a26fe8f8fb8ad" + }, + "build_number": 6732 + }, + "26.19.3": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "853ab9946ed47dedc407018d8934ae977f39b960cd41e2b3773fc0b841deed1b", + "so_meta_sha256_arm64_v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "so_meta_sha256": { + "arm64-v8a": "ec3f447f41e161b0dec7ce6d5ce9d52428895da54e0d9d036d93913b45c7a3c1", + "armeabi-v7a": "0a7747106ea8b797fb0b8093b1701b08cebb5573c1a952f42f684cd818abf3e5", + "x86": "d4ee5fde610e036dbedfe51da6d900c4fd348f291658a95350d59d6e1f8e2ff5", + "x86_64": "e12ac08d99994274ade0d7f7ce2ec39c73c11ef8e8bb51e4961a26fe8f8fb8ad" + }, + "build_number": 6734 + }, + "26.20.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "ff5014aa2cf21e07616a7a72d89712bb9a2c06219f95b5526757dcdb2ce3609a", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6740 + }, + "26.20.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "0a6265f6e5d8231b9cba641f8c40475e6f3baeb06ed41b804b9bf7307aa4214e", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6758 + }, + "26.21.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "722162bea4e63c7fe39da201a8c7be94e53967f97c50fc397e7b16f3043467a2", + "so_meta_sha256_arm64_v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "so_meta_sha256": { + "arm64-v8a": "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d", + "armeabi-v7a": "a62e2dfc3dcad88f866b5fcbba4c6d7bf1640118db98740ae22d647474bcce44", + "x86": "5723795fef7c3dc2c1f769be4a6b69c7568e3eb8f3279a6911718e902d38005e", + "x86_64": "bb097419b05e41eba460d4d1041ec660cc5baa28cbb0e287deb05bf27549e8ca" + }, + "build_number": 6763 + }, + "26.22.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "2cb7a73826370a9d19687d2c431335a4f512336fe5878db018746def973b2501", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6770 + }, + "26.22.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "4f043b856af584c701a42ffd28e6b6f8a78fdb6d266e907a12d45de2d9596fdd", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6772 + }, + "26.22.2": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "91e5fa68c7b36ac910e51b77d7d96fb82d16fcab38f41adbafd3e0926797f178", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6773 + }, + "26.23.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "49f6cba7368ce7be0179278429c658de043ee94a347d83bb4a0f0163e4a10bff", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6777 + }, + "26.23.1": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "53f15791a9ff7ac75a45b9df132c910eb2e3081c66f06fb97f0d7c1c976fab78", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6778 + }, + "26.23.2": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "38cff46f392dc1734c308be011c2f0d8da152a390b41063dbb2c913e3032f4b3", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6779 + }, + "26.24.0": { + "signature_scheme": "v2", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "73e8434d6524c3b6d7a90b63598befcf21f2d3b304e9ef3da153ac5bcbebdd99", + "so_meta_sha256_arm64_v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "so_meta_sha256": { + "arm64-v8a": "e7871f948c8507284b09642372c40324498acdf2180a55643eca1bd66755375f", + "armeabi-v7a": "b4818fc95cd46d9bf0700adcd97dd49db67a4fcfed88d2ff181c16fd69f5d3db", + "x86": "b3c699608d9aadc3bf5360b99048ad41507c2ba23d5bfad6d8cfb19ef435efd3", + "x86_64": "8c6bad639bd1db5814200d75bbdf508bb0bb577b60dce5b4ca2961b63544b608" + }, + "build_number": 6784 + }, + "26.25.0": { + "signature_scheme": "v3", + "certificate_count": 1, + "certificate_meta_sha256": "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93", + "certificate_sha256": [ + "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93" + ], + "dex_meta_sha256": "8db68fcc0e85e8f041fe4a875c0a9bcfe542a8f679603728c651ed81b64dd684", + "so_meta_sha256_arm64_v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "so_meta_sha256": { + "arm64-v8a": "634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8", + "armeabi-v7a": "042220bdd481a280d2c1f4f6827f0e4fab7bca61e5af0f6035a0d191aed1350c", + "x86": "deffe34d2a9d83584e02cbb3f22ba5a6dbe1b065dbc8a8ea8ca908dae865c5f6", + "x86_64": "251b88c27a1c055f27adc110e44a75a1c60408b0d5e20e3844f816aa227212a3" + }, + "build_number": 6790 + } +} diff --git a/src/pymax/api/auth/enums.py b/src/pymax/api/auth/enums.py index 52e473b..bb9750d 100644 --- a/src/pymax/api/auth/enums.py +++ b/src/pymax/api/auth/enums.py @@ -6,6 +6,7 @@ class AuthType(str, Enum): CHECK_CODE = "CHECK_CODE" REGISTER = "REGISTER" RESEND = "RESEND" + LOGIN = "LOGIN" class ProfileOptions(int, Enum): diff --git a/src/pymax/api/auth/payloads.py b/src/pymax/api/auth/payloads.py index d17f644..d9f6d31 100644 --- a/src/pymax/api/auth/payloads.py +++ b/src/pymax/api/auth/payloads.py @@ -10,12 +10,13 @@ class RequestCodePayload(CamelModel): phone: str type: AuthType = AuthType.START_AUTH - language: str = "ru" + mode: bytes | None class SendCodePayload(CamelModel): token: str verify_code: str + auth_token_type: AuthType = AuthType.CHECK_CODE @@ -42,19 +43,21 @@ def from_sync_state( cls, token: str, sync: SyncState, + interactive: bool = True, ) -> "WebSyncPayload": return cls( token=token, chats_sync=sync.chats_sync, contacts_sync=sync.contacts_sync, drafts_sync=sync.drafts_sync, + interactive=interactive, ) class SyncPayload(CamelModel): user_agent: MobileUserAgentPayload token: str - chat_hash_fingerprint: str | None = None + chat_cache_fingerprint: bytes | None = None chats_count: int | None = None chats_sync: int = -1 contacts_sync: int = -1 @@ -70,15 +73,19 @@ def from_sync_state( user_agent: MobileUserAgentPayload, token: str, sync: SyncState, + chat_cache_fingerprint: bytes | None = None, + interactive: bool = True, ) -> "SyncPayload": return cls( user_agent=user_agent, token=token, + chat_cache_fingerprint=chat_cache_fingerprint, chats_sync=sync.chats_sync, contacts_sync=sync.contacts_sync, drafts_sync=sync.drafts_sync, presence_sync=sync.presence_sync, config_hash=sync.config_hash, + interactive=interactive, ) @@ -138,3 +145,22 @@ class ConfirmRegistrationPayload(CamelModel): last_name: str | None = None token: str token_type: AuthType = AuthType.REGISTER + + +class Login2Payload(CamelModel): + need_profile: bool + contacts_sync: int + config_hash: ConfigHash + + @classmethod + def from_sync_state( + cls, + sync: SyncState, + profile_enabled: bool, + contact_enabled: bool, + ) -> "Login2Payload": + return cls( + need_profile=profile_enabled, + contacts_sync=sync.contacts_sync if contact_enabled else -1, + config_hash=sync.config_hash, + ) diff --git a/src/pymax/api/auth/service.py b/src/pymax/api/auth/service.py index af92e24..0ca8504 100644 --- a/src/pymax/api/auth/service.py +++ b/src/pymax/api/auth/service.py @@ -13,6 +13,7 @@ from pymax.auth.providers import ConsoleEmailCodeProvider from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types.domain import Login2Flags, Login2Response from pymax.types.domain.auth import ( CheckCodeResponse, CheckPasswordResponse, @@ -31,6 +32,7 @@ ConfirmQrPayload, ConfirmRegistrationPayload, CreateAuthTrackPayload, + Login2Payload, MobileUserAgentPayload, RemoveTwoFactorPayload, RequestCodePayload, @@ -58,7 +60,32 @@ def __init__(self, app: App) -> None: async def request_code(self, phone: str) -> StartAuthResponse: logger.info("requesting sms code phone_set=%s", bool(phone)) - frame = RequestCodePayload(phone=phone) + + if not self.app.handshake_response: + logger.error("request_code requested without handshake response") + raise RuntimeError("No handshake response available for request_code") + + device_id = ( + self.app.session.device_id if self.app.session else self.app.config.device.device_id + ) + + if self.app.config.device.user_agent.device_type != DeviceType.WEB: + if self.app.handshake_response.calls_seed is None: + raise ValueError( + "Unexpected internal state: handshake_response.calls_seed is missing " + + "in AuthService.request_code. Please report this issue to the developer." + ) + + mode = self.app.fingerprint_generator.generate_fingerprint( + version=self.app.config.device.user_agent.app_version, + device_id=device_id, + calls_seed=self.app.handshake_response.calls_seed, + arch=self.app.config.device.user_agent.arch or "arm64-v8a", + ) + else: + mode = None + + frame = RequestCodePayload(phone=phone, mode=mode) response = await self.app.invoke(Opcode.AUTH_REQUEST, frame.to_payload()) logger.debug( "sms code request accepted payload_keys=%s", @@ -72,7 +99,11 @@ async def send_code(self, token: str, verify_code: str) -> CheckCodeResponse: bool(token), bool(verify_code), ) - frame = SendCodePayload(token=token, verify_code=verify_code) + + frame = SendCodePayload( + token=token, + verify_code=verify_code, + ) response = await self.app.invoke(Opcode.AUTH, frame.to_payload()) logger.debug( "sms code response payload_keys=%s", @@ -117,11 +148,35 @@ async def mobile_login(self) -> LoginResponse: raise RuntimeError("No session available for login") logger.info("logging in") + + if not self.app.handshake_response: + logger.error("login requested without handshake response") + raise RuntimeError("No handshake response available for login") + + device_id = ( + self.app.session.device_id if self.app.session else self.app.config.device.device_id + ) + + if self.app.handshake_response.calls_seed is None: + raise ValueError( + "Unexpected internal state: handshake_response.calls_seed is missing " + + "in AuthService.mobile_login. Please report this issue to the developer." + ) + + ccf = self.app.fingerprint_generator.generate_fingerprint( + version=self.app.config.device.user_agent.app_version, + device_id=device_id, + calls_seed=self.app.handshake_response.calls_seed, + arch=self.app.config.device.user_agent.arch or "arm64-v8a", + ) + sync = self.app.config.sync.resolve(session.sync) frame = SyncPayload.from_sync_state( user_agent=self.app.config.device.user_agent, token=session.token, sync=sync, + chat_cache_fingerprint=ccf, + interactive=self.app.config.interactive, ) response = await self.app.invoke(Opcode.LOGIN, frame.to_payload()) @@ -134,6 +189,30 @@ async def mobile_login(self) -> LoginResponse: await self._update_session(login_response) return login_response + async def mobile_login2(self, flags: Login2Flags) -> Login2Response: + session = self.app.session + if session is None: + logger.error("login2 requested without session") + raise RuntimeError("No session available for login2") + + sync = self.app.config.sync.resolve(session.sync) + + frame = Login2Payload.from_sync_state( + sync, + profile_enabled=flags.profile_enabled, + contact_enabled=flags.contact_enabled, + ) + + response = await self.app.invoke(Opcode.LOGIN2, frame.to_payload()) + logger.debug("login2 response payload_keys=%s", payload_keys(response)) + + login2_response = bind_api_model( + self.app, + require_payload_model(response, Login2Response), + ) + await self._update_session(login2_response) + return login2_response + async def web_login(self) -> LoginResponse: session = self.app.session if session is None: @@ -146,6 +225,7 @@ async def web_login(self) -> LoginResponse: frame = WebSyncPayload.from_sync_state( token=session.token, sync=sync, + interactive=self.app.config.interactive, ) response = await self.app.invoke(Opcode.LOGIN, frame.to_payload()) @@ -177,7 +257,7 @@ async def confirm_qr(self, track_id: str) -> CheckCodeResponse: return require_payload_model(response, CheckCodeResponse) - async def _update_session(self, response: LoginResponse) -> None: + async def _update_session(self, response: LoginResponse | Login2Response) -> None: session = self.app.session if session is None: return @@ -377,3 +457,8 @@ async def confirm_registration( response = await self.app.invoke(Opcode.AUTH_CONFIRM, frame.to_payload()) return require_payload_model(response, ConfirmRegistrationResponse) + + def is_update_available(self) -> bool: + return bool( + self.app.handshake_response.app_update_type if self.app.handshake_response else False + ) diff --git a/src/pymax/api/chats/__init__.py b/src/pymax/api/chats/__init__.py index 24ce6cd..9c4f51b 100644 --- a/src/pymax/api/chats/__init__.py +++ b/src/pymax/api/chats/__init__.py @@ -1,4 +1,5 @@ from .enums import ( + ChannelPermissions, ChatLinkPrefix, ChatMemberOperation, ChatOption, diff --git a/src/pymax/api/chats/enums.py b/src/pymax/api/chats/enums.py index 4f61325..d924529 100644 --- a/src/pymax/api/chats/enums.py +++ b/src/pymax/api/chats/enums.py @@ -22,7 +22,22 @@ class ChatPayloadKey(str, Enum): CHAT = "chat" CHATS = "chats" MEMBERS = "members" + MARKER = "marker" class ChatLinkPrefix(str, Enum): JOIN = "join/" + + +class ChannelPermissions(int, Enum): + ADD_REMOVE_MEMBER = 2 + ADD_ADMIN = 4 + CHANGE_CHAT_INFO = 8 + PIN_MESSAGE = 16 + POST_MESSAGE = 256 + EDIT_MESSAGE = 512 + DELETE_MESSAGE = 1024 + + +class PermType(str, Enum): + ADMIN = "ADMIN" diff --git a/src/pymax/api/chats/payloads.py b/src/pymax/api/chats/payloads.py index 1adc8be..03de78a 100644 --- a/src/pymax/api/chats/payloads.py +++ b/src/pymax/api/chats/payloads.py @@ -6,7 +6,7 @@ from pymax.types.domain.attachments.enums import AttachmentType from pymax.types.domain.enums import ChatType -from .enums import ChatMemberOperation, ChatOption, ControlEvent +from .enums import ChatMemberOperation, ChatOption, ControlEvent, PermType class CreateGroupAttach(CamelModel): @@ -95,6 +95,13 @@ class GetChatInfoPayload(CamelModel): chat_ids: list[int] +class GetChatMembersPayload(CamelModel): + type: str = "MEMBER" # TODO: ENUMM!!! + chat_id: int + marker: int + count: int = 50 + + class LeaveChatPayload(CamelModel): chat_id: int @@ -121,3 +128,11 @@ class DeleteChatPayload(CamelModel): chat_id: int last_event_time: int for_all: bool = True + + +class AddAdminPayload(CamelModel): + chat_id: int + user_ids: list[int] + type: PermType = PermType.ADMIN + operation: str = "add" + permissions: int diff --git a/src/pymax/api/chats/service.py b/src/pymax/api/chats/service.py index 83c5469..8d8b452 100644 --- a/src/pymax/api/chats/service.py +++ b/src/pymax/api/chats/service.py @@ -1,12 +1,15 @@ from __future__ import annotations import time +from functools import reduce +from operator import or_ from typing import TYPE_CHECKING from pymax.api.binding import bind_api_model from pymax.api.response import ( parse_payload_item_model, parse_payload_list, + payload_item, require_payload_item_model, require_payload_model, ) @@ -15,8 +18,9 @@ from pymax.protocol import Opcode from pymax.types.domain import Chat, Member, Message -from .enums import ChatLinkPrefix, ChatMemberOperation, ChatPayloadKey +from .enums import ChannelPermissions, ChatLinkPrefix, ChatMemberOperation, ChatPayloadKey from .payloads import ( + AddAdminPayload, ChangeGroupProfilePayload, ChangeGroupSettingsOptions, ChangeGroupSettingsPayload, @@ -27,6 +31,7 @@ FetchChatsPayload, FetchJoinRequests, GetChatInfoPayload, + GetChatMembersPayload, InviteUsersPayload, JoinChatPayload, JoinRequestActionPayload, @@ -266,6 +271,22 @@ async def get_chats(self, chat_ids: list[int]) -> list[Chat]: return [cached[chat_id] for chat_id in chat_ids if chat_id in cached] + async def get_chat_members( + self, + chat_id: int, + marker: int | None = None, + count: int = 50, + ) -> tuple[list[Member], int]: + frame = GetChatMembersPayload(chat_id=chat_id, marker=marker or 0, count=count) + response = await self.app.invoke(Opcode.CHAT_MEMBERS, frame.to_payload()) + + members = bind_api_model( + self.app, + parse_payload_list(response, ChatPayloadKey.MEMBERS, Member), + ) + next_marker = payload_item(response, ChatPayloadKey.MARKER, int) or 0 + return members, next_marker + async def get_chat(self, chat_id: int) -> Chat: chats = await self.get_chats([chat_id]) if not chats: @@ -380,3 +401,17 @@ async def delete_chat( await self.app.invoke(Opcode.CHAT_DELETE, frame.to_payload()) self._remove_cached_chat(chat_id) + + async def add_admin( + self, + chat_id: int, + user_id: int, + permissions: list[ChannelPermissions], + ) -> None: + frame = AddAdminPayload( + chat_id=chat_id, + user_ids=[user_id], + permissions=reduce(or_, permissions), + ) + + await self.app.invoke(Opcode.CHAT_MEMBERS_UPDATE, frame.to_payload()) diff --git a/src/pymax/api/messages/payloads.py b/src/pymax/api/messages/payloads.py index 0302007..840857d 100644 --- a/src/pymax/api/messages/payloads.py +++ b/src/pymax/api/messages/payloads.py @@ -8,6 +8,7 @@ AttachPhotoPayload, VideoAttachPayload, ) +from pymax.types.domain import Poll from .enums import ItemType, ReadAction @@ -20,9 +21,9 @@ class GetMessagesPayload(CamelModel): class EditMessagePayload(CamelModel): chat_id: int message_id: int - text: str + text: str | None = None elements: list[Any] - attachments: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = Field( + attachments: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = Field( default_factory=list ) @@ -33,10 +34,10 @@ class ReplyLink(CamelModel): class SendMessagePayloadMessage(CamelModel): - text: str + text: str | None = None cid: int elements: list[Any] - attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] + attaches: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] link: ReplyLink | None = None @@ -129,3 +130,10 @@ class ReadMessagesPayload(CamelModel): chat_id: int message_id: str | int # Сокет просит int а вс str mark: int + + +class VotePollPayload(CamelModel): + chat_id: int + message_id: int + poll_id: int + answers_ids: list[int] diff --git a/src/pymax/api/messages/service.py b/src/pymax/api/messages/service.py index 602c350..da6c23d 100644 --- a/src/pymax/api/messages/service.py +++ b/src/pymax/api/messages/service.py @@ -18,13 +18,15 @@ VideoAttachPayload, ) from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.formatting.markdown import Formatter from pymax.logging import get_logger from pymax.protocol import Opcode from pymax.types.domain import ( FileRequest, Message, + Poll, + PollState, ReactionInfo, ReadState, VideoRequest, @@ -50,12 +52,13 @@ ReplyLink, SendMessagePayload, SendMessagePayloadMessage, + VotePollPayload, ) if TYPE_CHECKING: from pymax.app import App -SendAttachment: TypeAlias = Photo | File | Video +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None logger = get_logger(__name__) @@ -76,13 +79,21 @@ def _next_cid(self) -> int: async def _upload_attachments( self, attachments: SendAttachments - ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload]: - result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload] = [] + ) -> list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll]: + result: list[AttachPhotoPayload | VideoAttachPayload | AttachFilePayload | Poll] = [] if not attachments: return result for attachment in attachments: - if isinstance(attachment, Photo): + if isinstance(attachment, Voice): + upload_result = await self.app.api.uploads.upload_voice(attachment) + if not upload_result: + logger.error("Voice uploading failed") + raise UploadError("Voice uploading failed") + + result.append(upload_result) + + elif isinstance(attachment, Photo): upload_result = await self.app.api.uploads.upload_photo(attachment) if not upload_result: logger.error("Photo uploading failed") @@ -106,20 +117,30 @@ async def _upload_attachments( result.append(upload_result) + elif isinstance(attachment, Poll): + result.append(attachment) + return result async def send_message( self, chat_id: int, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: - logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text)) + ) -> Message: + logger.info("sending message chat_id=%s text_len=%s", chat_id, len(text) if text else 0) + + if not text and not attachments: + logger.error("send_message failed: no text or attachments provided") + raise ValueError("Either text or attachments must be provided") - clean_text, elements = Formatter.format_markdown(text) + if text: + clean_text, elements = Formatter.format_markdown(text) + else: + clean_text, elements = None, [] frame = SendMessagePayload( chat_id=chat_id, @@ -149,7 +170,7 @@ async def forward_message( source_chat_id: int | None = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: source_chat_id = chat_id if source_chat_id is None else source_chat_id logger.info( "forwarding message source_chat_id=%s chat_id=%s message_id=%s", @@ -208,10 +229,18 @@ async def edit_message( self, chat_id: int, message_id: int, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: - clean_text, elements = Formatter.format_markdown(text) + if not text and not attachments: + logger.error("edit_message failed: no text or attachments provided") + raise ValueError("Either text or attachments must be provided") + + if text: + clean_text, elements = Formatter.format_markdown(text) + else: + clean_text, elements = None, [] + frame = EditMessagePayload( chat_id=chat_id, message_id=message_id, @@ -243,7 +272,7 @@ async def fetch_history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: frame = ChatHistoryPayload( chat_id=chat_id, forward=forward, @@ -265,7 +294,7 @@ async def fetch_history( self.app, parse_payload_list(response, MessagePayloadKey.MESSAGES, Message), ) - return messages or None + return messages async def delete_message( self, @@ -443,3 +472,17 @@ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: response = await self.app.invoke(Opcode.CHAT_MARK, frame.to_payload()) return require_payload_model(response, ReadState) + + async def vote_poll( + self, + chat_id: int, + message_id: int, + poll_id: int, + answer_ids: list[int], + ) -> PollState: + frame = VotePollPayload( + chat_id=chat_id, message_id=message_id, poll_id=poll_id, answers_ids=answer_ids + ) + response = await self.app.invoke(Opcode.SEND_VOTE, frame.to_payload()) + + return require_payload_item_model(response, "state", PollState) diff --git a/src/pymax/api/self/__init__.py b/src/pymax/api/self/__init__.py index e35e06a..e63e6a6 100644 --- a/src/pymax/api/self/__init__.py +++ b/src/pymax/api/self/__init__.py @@ -1,2 +1,3 @@ -from .enums import AvatarType, SelfPayloadKey +from .enums import AvatarType, PrivacyAccess, SelfPayloadKey +from .payloads import PrivacySettingsUpdate from .service import SelfService diff --git a/src/pymax/api/self/enums.py b/src/pymax/api/self/enums.py index 165aa1a..46cbb98 100644 --- a/src/pymax/api/self/enums.py +++ b/src/pymax/api/self/enums.py @@ -9,3 +9,12 @@ class SelfPayloadKey(str, Enum): PROFILE = "profile" URL = "url" TOKEN = "token" + HASH = "hash" + + +class PrivacyAccess(str, Enum): + """Уровень доступа к данным и действиям аккаунта.""" + + ALL = "ALL" + CONTACTS = "CONTACTS" + NOBODY = "_NONE_" diff --git a/src/pymax/api/self/payloads.py b/src/pymax/api/self/payloads.py index 47d3c89..f52904c 100644 --- a/src/pymax/api/self/payloads.py +++ b/src/pymax/api/self/payloads.py @@ -1,8 +1,10 @@ from typing import Any +from pydantic import BaseModel, Field + from pymax.api.models import CamelModel -from .enums import AvatarType +from .enums import AvatarType, PrivacyAccess class UploadPayload(CamelModel): @@ -39,3 +41,40 @@ class UpdateFolderPayload(CamelModel): class DeleteFolderPayload(CamelModel): folder_ids: list[str] + + +class PrivacySettingsUpdate(BaseModel): + """Изменяемые настройки приватности аккаунта. + + Все поля необязательны: в запрос попадают только переданные настройки. + + Args: + search_by_phone: Кто может найти аккаунт по номеру телефона. + incoming_calls: Кто может звонить аккаунту. + chat_invites: Кто может добавлять аккаунт в чаты. + phone_number_visibility: Кто может видеть номер телефона. + hide_online_status: Скрывать ли статус присутствия. + safe_content_only: Показывать только безопасный контент. + """ + + search_by_phone: PrivacyAccess | None = Field( + default=None, serialization_alias="SEARCH_BY_PHONE" + ) + incoming_calls: PrivacyAccess | None = Field(default=None, serialization_alias="INCOMING_CALL") + chat_invites: PrivacyAccess | None = Field(default=None, serialization_alias="CHATS_INVITE") + phone_number_visibility: PrivacyAccess | None = Field( + default=None, serialization_alias="PHONE_NUMBER_PRIVACY" + ) + + hide_online_status: bool | None = Field(default=None, serialization_alias="HIDDEN") + safe_content_only: bool | None = Field( + default=None, serialization_alias="CONTENT_LEVEL_ACCESS" + ) + + +class ChangeProfileSettings(CamelModel): + user: PrivacySettingsUpdate + + +class ChangeProfileSettingsPayload(CamelModel): + settings: ChangeProfileSettings diff --git a/src/pymax/api/self/service.py b/src/pymax/api/self/service.py index 3dc2164..8b9ce77 100644 --- a/src/pymax/api/self/service.py +++ b/src/pymax/api/self/service.py @@ -18,9 +18,12 @@ from .enums import SelfPayloadKey from .payloads import ( ChangeProfilePayload, + ChangeProfileSettings, + ChangeProfileSettingsPayload, CreateFolderPayload, DeleteFolderPayload, GetFolderPayload, + PrivacySettingsUpdate, UpdateFolderPayload, UploadPayload, ) @@ -150,3 +153,37 @@ async def logout(self) -> bool: logger.info("logging out") await self.app.invoke(Opcode.LOGOUT, {}) return True + + def set_presence(self, online: bool) -> None: + logger.info("setting presence to %s", "online" if online else "offline") + self.app.config.interactive = online + + async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool: + logger.info("changing profile settings") + + frame = ChangeProfileSettingsPayload(settings=ChangeProfileSettings(user=settings)) + + response = await self.app.invoke( + Opcode.CONFIG, + frame.to_payload(), + ) + session = self.app.session + + if not session: + logger.warning("no session found, skipping sync update") + return True + + sync = session.sync.model_copy( + update={"config_hash": require_payload_item(response, SelfPayloadKey.HASH)} + ) + + updated = session.model_copy( + update={ + "mt_instance_id": self.app.config.device.mt_instance_id, + "sync": sync, + }, + ) + self.app.session = updated + await self.app.store.save_session(updated) + + return True diff --git a/src/pymax/api/session/service.py b/src/pymax/api/session/service.py index 626dbff..74e7c9f 100644 --- a/src/pymax/api/session/service.py +++ b/src/pymax/api/session/service.py @@ -2,8 +2,10 @@ from typing import TYPE_CHECKING +from pymax.api.response import require_payload_model from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types.domain import HandshakeResponse from .enums import DeviceType from .payloads import ( @@ -28,19 +30,18 @@ async def handshake( mt_instance_id: str, user_agent: MobileUserAgentPayload, device_id: str, - ) -> None: + ) -> HandshakeResponse: if user_agent.device_type == DeviceType.WEB: - await self.web_handshake(user_agent, device_id) - return + return await self.web_handshake(user_agent, device_id) - await self.mobile_handshake(mt_instance_id, user_agent, device_id) + return await self.mobile_handshake(mt_instance_id, user_agent, device_id) async def mobile_handshake( self, mt_instance_id: str, user_agent: MobileUserAgentPayload, device_id: str, - ) -> None: + ) -> HandshakeResponse: logger.debug( "mobile handshake mt_instance_id_set=%s device_id=%s app_version=%s", bool(mt_instance_id), @@ -52,10 +53,17 @@ async def mobile_handshake( user_agent=user_agent, device_id=device_id, ) - await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) + response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) logger.info("mobile handshake completed") - async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str) -> None: + return require_payload_model( + response, + HandshakeResponse, + ) + + async def web_handshake( + self, user_agent: MobileUserAgentPayload, device_id: str + ) -> HandshakeResponse: logger.debug( "web handshake device_id=%s app_version=%s browser=%s", device_id, @@ -66,5 +74,10 @@ async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str user_agent=user_agent, device_id=device_id, ) - await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) + response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload()) logger.info("web handshake completed") + + return require_payload_model( + response, + HandshakeResponse, + ) diff --git a/src/pymax/api/uploads/payloads.py b/src/pymax/api/uploads/payloads.py index 5dc7794..0448431 100644 --- a/src/pymax/api/uploads/payloads.py +++ b/src/pymax/api/uploads/payloads.py @@ -13,6 +13,10 @@ class VideoAttachPayload(CamelModel): type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type") video_id: int token: str + video_type: int = 0 + thumbhash: bytes | None = None + duration: int | None = None + wave: bytes | None = None class AttachFilePayload(CamelModel): @@ -22,4 +26,6 @@ class AttachFilePayload(CamelModel): class UploadPayload(CamelModel): count: int = 1 + type: int = 0 + uploader_type: int = 0 profile: bool = False diff --git a/src/pymax/api/uploads/service.py b/src/pymax/api/uploads/service.py index 9ae3751..e79d504 100644 --- a/src/pymax/api/uploads/service.py +++ b/src/pymax/api/uploads/service.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 from http import HTTPStatus from typing import TYPE_CHECKING from urllib.parse import parse_qs, quote, urlparse @@ -11,9 +12,10 @@ from pymax.api.response import payload_item from pymax.dispatch.enums import EventType from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.logging import get_logger from pymax.protocol import Opcode +from pymax.types import AttachmentType, AudioUploadSignal from .models import ( FileUploadResponse, @@ -40,14 +42,16 @@ def __init__(self, app: App) -> None: self.app = app self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {} self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {} + self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {} self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach) self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach) + self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach) async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload: logger.info("Uploading photo") logger.debug("Preparing photo upload payload") - payload = UploadPayload(profile=profile).model_dump() + payload = UploadPayload(profile=profile).to_payload() try: data = await self.app.invoke( @@ -178,11 +182,146 @@ async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhoto logger.debug("Photo upload complete photo_id=%s", photo_id) return AttachPhotoPayload(photo_token=token) - async def upload_video(self, video: Video) -> VideoAttachPayload: + async def upload_voice(self, voice: Voice) -> VideoAttachPayload: + logger.info("Uploading voice") + + payload = UploadPayload( + type=2, + uploader_type=1, + ).to_payload() + + try: + data = await self.app.invoke( + Opcode.VIDEO_UPLOAD, + payload=payload, + ) + except Exception as e: + logger.exception("Failed to request voice upload URL") + raise UploadError("Failed to request voice upload URL") from e + + try: + response = VideoUploadResponse.model_validate(data.payload) + except ValidationError as e: + logger.exception("Invalid voice upload response model") + logger.debug("Invalid voice upload payload=%r", data.payload) + raise UploadError("Invalid voice upload response model") from e + except Exception as e: + logger.exception("Failed to parse voice upload response") + logger.debug("Invalid voice upload payload=%r", data.payload) + raise UploadError("Failed to parse voice upload response") from e + + try: + upload_info = response.info[0] + except IndexError as e: + logger.error("voice upload response info is empty") + logger.debug("voice upload response=%r", response) + raise UploadError("voice upload response info is empty") from e + except Exception as e: + logger.exception("Failed to get voice upload info") + logger.debug("voice upload response=%r", response) + raise UploadError("Failed to get voice upload info") from e + + try: + file_size = await voice.size() + except Exception as e: + logger.exception("Failed to get voice size") + raise UploadError("Failed to get voice size") from e + + headers = { + "Content-Disposition": f"attachment; filename={quote(voice.name)}", + "Content-Range": f"0-{file_size - 1}/{file_size}", + "Content-Length": str(file_size), + "Connection": "keep-alive", + "Content-Type": "application/octet-stream", + } + + logger.debug( + "Voice upload headers prepared content_range=%s", + headers["Content-Range"], + ) + + loop = asyncio.get_running_loop() + future: asyncio.Future[AudioUploadSignal] = loop.create_future() + + timeout = aiohttp.ClientTimeout(total=900, sock_read=60) + + video_id = upload_info.video_id + token = upload_info.token + + self.voice_upload_waiters[video_id] = future + logger.debug("Voice upload waiter registered voice_id=%s", video_id) + + try: + async with aiohttp.ClientSession( + timeout=timeout, proxy=self.app.config.proxy + ) as session: + logger.debug("Starting voice upload HTTP request voice_id=%s", video_id) + + async with session.post( + url=upload_info.url, + headers=headers, + data=voice.iter_chunks(1024 * 1024), + ) as response: + logger.debug( + "Voice upload HTTP response status=%s voice_id=%s", + response.status, + video_id, + ) + + if response.status != HTTPStatus.OK: + logger.error( + "Voice upload failed with status %s video_id=%s", + response.status, + video_id, + ) + raise UploadError( + "Voice upload failed with status " + f"{response.status} voice_id={video_id}" + ) + + try: + logger.debug( + "Waiting for voice processing notification voice_id=%s", + video_id, + ) + await asyncio.wait_for(future, 60) + except asyncio.TimeoutError: + logger.warning( + "Timed out waiting for voice processing notification voice_id=%s", + video_id, + ) + raise UploadError( + f"Timed out waiting for voice processing voice_id={video_id}" + ) + + logger.debug("Voice upload complete voice_id=%s", video_id) + return VideoAttachPayload( + type=AttachmentType.AUDIO, video_id=video_id, token=token + ) + + except UploadError: + raise + except aiohttp.ClientError as e: + logger.exception("HTTP error during voice upload voice_id=%s", video_id) + raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e + except asyncio.TimeoutError as e: + logger.exception("Timed out during voice upload voice_id=%s", video_id) + raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e + except Exception as e: + logger.exception("Unexpected error during voice upload voice_id=%s", video_id) + raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e + finally: + self.voice_upload_waiters.pop(video_id, None) + logger.debug("Voice upload waiter removed voice_id=%s", video_id) + + async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload: logger.info("Uploading video") logger.debug("Preparing video upload payload") - payload = UploadPayload().model_dump() + if isinstance(uploadable_video, VideoNote): + payload = UploadPayload(type=1, uploader_type=1).to_payload() + else: + payload = UploadPayload().to_payload() try: data = await self.app.invoke( @@ -216,7 +355,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: raise UploadError("Failed to get video upload info") from e try: - file_size = await video.size() + file_size = await uploadable_video.size() except Exception as e: logger.exception("Failed to get video size") raise UploadError("Failed to get video size") from e @@ -230,8 +369,8 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: timeout = aiohttp.ClientTimeout(total=900, sock_read=60) headers = { - "Content-Disposition": f"attachment; filename={quote(video.name)}", - "Content-Range": f"0-{file_size - 1}/{file_size}", + "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}", + "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", "Content-Length": str(file_size), "Connection": "keep-alive", } @@ -240,15 +379,16 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: "Video upload headers prepared content_range=%s", headers["Content-Range"], ) - - loop = asyncio.get_running_loop() - future: asyncio.Future[VideoUploadSignal] = loop.create_future() - - video_id = upload_info.video_id token = upload_info.token + video_id = upload_info.video_id + + future = None + if not isinstance(uploadable_video, VideoNote): + loop = asyncio.get_running_loop() + future = loop.create_future() + self.video_upload_waiters[video_id] = future - self.video_upload_waiters[video_id] = future - logger.debug("Video upload waiter registered video_id=%s", video_id) + logger.debug("Video upload waiter registered video_id=%s", video_id) try: async with aiohttp.ClientSession( @@ -259,7 +399,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: async with session.post( url=upload_info.url, headers=headers, - data=video.iter_chunks(1024 * 1024), + data=uploadable_video.iter_chunks(1024 * 1024), ) as response: logger.debug( "Video upload HTTP response status=%s video_id=%s", @@ -279,11 +419,33 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: ) try: - logger.debug( - "Waiting for video processing notification video_id=%s", - video_id, - ) - await asyncio.wait_for(future, 60) + if isinstance(uploadable_video, VideoNote): + data = await response.json(content_type=None) + + thumbhash = data.get("thumbhash") + if thumbhash: + thumbhash += "=" * (-len(thumbhash) % 4) + thumbhash = base64.b64decode(thumbhash) + + return VideoAttachPayload( + video_id=video_id, + token=token, + video_type=1, + thumbhash=thumbhash, + duration=await uploadable_video.get_duration(), + ) + else: + if future is None: + raise ValueError( + "Unexpected internal state: " + + "future is missing in UploadService.upload_video." + + f" video type = {type(uploadable_video)}" + ) + logger.debug( + "Waiting for video processing notification video_id=%s", + video_id, + ) + await asyncio.wait_for(future, 60) except asyncio.TimeoutError: logger.warning( "Timed out waiting for video processing notification video_id=%s", @@ -294,6 +456,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: ) logger.debug("Video upload complete video_id=%s", video_id) + return VideoAttachPayload(video_id=video_id, token=token) except UploadError: @@ -314,7 +477,7 @@ async def upload_video(self, video: Video) -> VideoAttachPayload: async def upload_file(self, file: File) -> AttachFilePayload: logger.info("Uploading file") - payload = UploadPayload().model_dump() + payload = UploadPayload().to_payload() try: data = await self.app.invoke( @@ -430,6 +593,20 @@ async def upload_file(self, file: File) -> AttachFilePayload: self.file_upload_waiters.pop(file_id, None) logger.debug("File upload waiter removed file=%s", file_id) + async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None: + future = self.voice_upload_waiters.pop(attach.audio_id, None) + + if not future: + logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id) + return + + if future.done(): + logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id) + return + + future.set_result(attach) + logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id) + async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None: logger.debug("Received attach event video_id=%s", attach.video_id) diff --git a/src/pymax/app.py b/src/pymax/app.py index a968e7e..f7ccefa 100644 --- a/src/pymax/app.py +++ b/src/pymax/app.py @@ -8,6 +8,7 @@ from pymax.dispatch import Dispatcher from pymax.dispatch.router import EventType, Router from pymax.exceptions import ApiError +from pymax.fingerprint import FingerprintGenerator from pymax.logging import get_logger from pymax.protocol import Command, InboundFrame, OutboundFrame from pymax.protocol.enums import Opcode @@ -15,7 +16,8 @@ from pymax.session.models import SessionInfo from pymax.telemetry import TelemetryService from pymax.types import MaxApiError, Message -from pymax.types.domain import Chat, Profile, User +from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User +from pymax.types.domain.login import LoginResponse if TYPE_CHECKING: from pymax.base import BaseClient @@ -38,6 +40,7 @@ def __init__( self.config = config self.store = self.config.store or SessionStore(config.work_dir, config.session_name) self.auth_flow = auth_flow + self.fingerprint_generator = FingerprintGenerator() self.me: Profile | None = None self.chats: list[Chat] | None = None @@ -46,6 +49,7 @@ def __init__( self.messages: dict[int, list[Message]] = {} self.session: SessionInfo | None = None + self.handshake_response: HandshakeResponse | None = None self.started = False self._ping_task: asyncio.Task[None] | None = None @@ -81,12 +85,14 @@ async def start(self) -> None: session_data.device_id if session_data else self.config.device.device_id ) logger.debug("running handshake") - await self.handshake(handshake_device_id) + handshake_response = await self.handshake(handshake_device_id) except (ConnectionError, EOFError, OSError, TimeoutError) as e: logger.exception("failed to connect or handshake") await self.connection.close() raise ConnectionError(f"Failed to connect and handshake: {e}") from e + self.handshake_response = handshake_response + self._ping_task = asyncio.create_task(self._ping_loop()) if not session_data: @@ -129,10 +135,26 @@ async def start(self) -> None: logger.debug("logging in") try: - response = await self.api.auth.login( - self.config.device.user_agent, - ) + login_response, login2_response = await self.login() + + if ( + login2_response + and login_response.login2_flags + and login_response.login2_flags.profile_enabled + and login2_response.profile + ): + self.me = login2_response.profile + elif login_response.profile: + self.me = login_response.profile + elif login2_response and login2_response.profile: + self.me = login2_response.profile + else: + logger.error("Unexpected internal state: login response does not contain profile") + raise RuntimeError("Login response does not contain profile") except Exception as e: + if self._is_invalid_login_token_error(e): + raise + handled = False if self.dispatcher.client is not None: handled = await self.dispatcher.emit_error( @@ -148,15 +170,23 @@ async def start(self) -> None: await self.close() return - if response.token is not None and response.token != self.session.token: - await self.store.update_token(self.session.token, response.token) - self.session.token = response.token + if login_response.token is not None and login_response.token != self.session.token: + await self.store.update_token(self.session.token, login_response.token) + self.session.token = login_response.token - self.me = response.profile - self.chats = response.chats + self.chats = login_response.chats self.users[self.me.contact.id] = self.me.contact - self.contacts = response.contacts - self.messages = response.messages + + if ( + login2_response + and login_response.login2_flags + and login_response.login2_flags.contact_enabled + ): + self.contacts = login2_response.contacts + else: + self.contacts = login_response.contacts + + self.messages = login_response.messages self.started = True logger.info( @@ -168,13 +198,37 @@ async def start(self) -> None: if self._telemetry: self._telemetry.start() - async def handshake(self, device_id: str) -> None: - await self.api.session.handshake( + @staticmethod + def _is_invalid_login_token_error(exc: Exception) -> bool: + return ( + isinstance(exc, ApiError) + and exc.opcode == Opcode.LOGIN + and any( + err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL") + ) + ) + + async def login(self) -> tuple[LoginResponse, Login2Response | None]: + login_response = await self.api.auth.login( + self.config.device.user_agent, + ) + + if login_response.login2_flags and login_response.login2_flags.enabled: + logger.debug("login2 required; proceeding with login2") + login2_response = await self.api.auth.mobile_login2(login_response.login2_flags) + + return login_response, login2_response + + return login_response, None + + async def handshake(self, device_id: str) -> HandshakeResponse: + response = await self.api.session.handshake( self.config.device.mt_instance_id, self.config.device.user_agent, device_id, ) logger.debug("handshake completed device_id=%s", device_id) + return response async def close(self) -> None: if self._telemetry: @@ -246,7 +300,7 @@ async def _ping_loop(self) -> None: while True: await self.invoke( opcode=Opcode.PING, - payload={"interactive": True}, + payload={"interactive": self.config.interactive}, timeout=self.config.request_timeout, ) await asyncio.sleep(30) diff --git a/src/pymax/base.py b/src/pymax/base.py index 4cf5dde..64bfe11 100644 --- a/src/pymax/base.py +++ b/src/pymax/base.py @@ -7,6 +7,7 @@ from pymax.dispatch import ErrorScope, Router from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator +from pymax.exceptions import ApiError from pymax.infra import BaseMixin from pymax.logging import get_logger @@ -138,6 +139,13 @@ async def start(self: ClientT) -> None: # noqa: PYI019 except asyncio.CancelledError: await self.close() raise + except ApiError as e: + if not self._app._is_invalid_login_token_error(e): + await self.close() + raise + + logger.warning("login token was revoked; starting authentication again") + await self.relogin(start=False) except ( # noqa: PERF203 ConnectionError, EOFError, diff --git a/src/pymax/client_web.py b/src/pymax/client_web.py index dfe9c3e..77cc61e 100644 --- a/src/pymax/client_web.py +++ b/src/pymax/client_web.py @@ -6,7 +6,7 @@ from pymax.connection import ConnectionManager from pymax.connection.readers import WSReader from pymax.logging import configure_logging, get_logger -from pymax.protocol.ws import WsProtocol +from pymax.protocol.tcp import TcpProtocol from pymax.transport.websocket import WebSocketTransport from .base import BaseClient @@ -80,5 +80,5 @@ def _build_connection(self) -> ConnectionManager: return ConnectionManager( reader=reader, transport=transport, - protocol=WsProtocol(), + protocol=TcpProtocol(), ) diff --git a/src/pymax/config.py b/src/pymax/config.py index f4bab88..6af6ed6 100644 --- a/src/pymax/config.py +++ b/src/pymax/config.py @@ -1,4 +1,4 @@ -from random import choice, randint +from random import choice, randint, random from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field @@ -11,17 +11,48 @@ from pymax.session import StoreProtocol from pymax.types.domain.sync import SyncOverrides +MIN_PREFERRED_BUILD = 6712 APP_VERSIONS: tuple[tuple[str, int], ...] = ( + ("26.25.0", 6790), + ("26.24.0", 6784), + ("26.23.2", 6779), + ("26.23.1", 6778), + ("26.23.0", 6777), + ("26.22.2", 6773), + ("26.22.1", 6772), + ("26.22.0", 6770), + ("26.21.1", 6763), + ("26.20.2", 6758), + ("26.20.1", 6740), + ("26.19.3", 6734), + ("26.19.2", 6732), + ("26.19.1", 6729), + ("26.19.0", 6727), + ("26.18.4", 6724), + ("26.18.2", 6720), + ("26.18.1", 6716), + ("26.18.0", 6715), + ("26.17.1", 6712), + ("26.16.4", 6704), + ("26.16.3", 6702), + ("26.16.2", 6701), + ("26.16.1", 6700), + ("26.16.0", 6698), + ("26.15.3", 6695), + ("26.15.1", 6690), + ("26.15.0", 6689), ("26.14.1", 6686), ("26.14.0", 6685), ("26.13.0", 6683), ("26.12.2", 6681), ("26.12.1", 6679), - ("26.12.0", 6678), - ("26.11.3", 6680), + ("26.12.0", 6676), + ("26.11.3", 6670), ("26.11.2", 6669), ("26.11.1", 6665), - ("26.11.0", 6661), + ("26.10.1", 6653), + ("26.10.0", 6648), + ("26.9.1", 6643), ) ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = ( ("Samsung SM-A525F", "Android 13", "405dpi 405dpi 1080x2400", "arm64-v8a"), @@ -73,9 +104,12 @@ ("ru", "Asia/Yakutsk"), ("ru", "Asia/Vladivostok"), ) -WEB_APP_VERSION = "26.5.5" +WEB_APP_VERSION = "26.7.15" WEB_SCREEN = "1080x1920 1.0x" +PREFERRED_VERSION = [version for version in APP_VERSIONS if version[1] >= MIN_PREFERRED_BUILD] +LEGACY_VERSIONS = [version for version in APP_VERSIONS if version[1] < MIN_PREFERRED_BUILD] + class DeviceConfig(BaseModel): mt_instance_id: str @@ -119,6 +153,8 @@ class ClientConfig(BaseModel): log_level: str = "INFO" telemetry: bool = False + interactive: bool = True + store: StoreProtocol | None = None sync: SyncOverrides = Field(default_factory=SyncOverrides) @@ -176,7 +212,7 @@ class ExtraConfig(BaseModel): host: str = "api.oneme.ru" port: int = 443 - url: str = "wss://ws-api.oneme.ru/websocket" + url: str = "wss://api.oneme.ru/websocket" use_ssl: bool = True proxy: str | None = None reconnect: bool = True @@ -201,7 +237,8 @@ def generate_user_agent(self) -> MobileUserAgentPayload: Returns: Случайная, но правдоподобная конфигурация Android-клиента Max. """ - app_version, build_number = choice(APP_VERSIONS) + versions = PREFERRED_VERSION if random() < 0.9 else LEGACY_VERSIONS + app_version, build_number = choice(versions) device_name, os_version, screen, arch = choice(ANDROID_DEVICES) locale, timezone = choice(LOCALE_TIMEZONES) diff --git a/src/pymax/connection/connection.py b/src/pymax/connection/connection.py index f194d6a..2ad91e9 100644 --- a/src/pymax/connection/connection.py +++ b/src/pymax/connection/connection.py @@ -37,6 +37,8 @@ def __init__( self._recv_task: asyncio.Task[None] | None = None self._event_tasks: set[asyncio.Task[None]] = set() + self._closed_event = asyncio.Event() + self._closed_event.set() async def open(self) -> None: if self._is_open: @@ -48,6 +50,7 @@ async def open(self) -> None: self._is_open = True self._connection_lost = False self._close_reported = False + self._closed_event.clear() self._recv_task = asyncio.create_task(self._recv_loop()) logger.debug("receive loop started") @@ -58,25 +61,28 @@ async def close(self) -> None: return logger.info("closing connection") - if self._recv_task: - if not self._recv_task.done(): - self._recv_task.cancel() - with suppress(asyncio.CancelledError, Exception): - await self._recv_task - logger.debug("receive loop stopped") - self._recv_task = None - - for task in tuple(self._event_tasks): - if not task.done(): - task.cancel() - for task in tuple(self._event_tasks): - with suppress(asyncio.CancelledError, Exception): - await task - self._event_tasks.clear() - - self.requests.cancel_all() - await self.transport.close() self._is_open = False + try: + if self._recv_task: + if not self._recv_task.done(): + self._recv_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._recv_task + logger.debug("receive loop stopped") + self._recv_task = None + + for task in tuple(self._event_tasks): + if not task.done(): + task.cancel() + for task in tuple(self._event_tasks): + with suppress(asyncio.CancelledError, Exception): + await task + self._event_tasks.clear() + + self.requests.cancel_all() + await self.transport.close() + finally: + self._closed_event.set() logger.info("connection closed") async def fail(self, exc: Exception | None = None) -> None: @@ -147,6 +153,11 @@ async def wait_closed(self) -> None: try: await self._recv_task + except asyncio.CancelledError: + if self._is_open: + raise + await self._closed_event.wait() + return except Exception as e: if self._connection_lost: raise ConnectionError("Connection lost") from e diff --git a/src/pymax/dispatch/enums.py b/src/pymax/dispatch/enums.py index 631c1fb..da7dc96 100644 --- a/src/pymax/dispatch/enums.py +++ b/src/pymax/dispatch/enums.py @@ -13,5 +13,6 @@ class EventType(str, Enum): USER_UPDATE = "user_update" VIDEO_READY = "video_ready" FILE_READY = "file_ready" + VOICE_READY = "voice_ready" RAW = "raw" ON_START = "on_start" diff --git a/src/pymax/dispatch/mapping.py b/src/pymax/dispatch/mapping.py index e814948..bdbb3fe 100644 --- a/src/pymax/dispatch/mapping.py +++ b/src/pymax/dispatch/mapping.py @@ -6,7 +6,7 @@ from pymax.api.binding import bind_api_model from pymax.protocol import InboundFrame, Opcode from pymax.protocol.enums import Command -from pymax.types import Chat, MessageDeleteEvent +from pymax.types import AudioUploadSignal, Chat, MessageDeleteEvent from pymax.types.domain import Message from pymax.types.events import ( FileUploadSignal, @@ -100,4 +100,6 @@ def map(self, event_type: EventType, frame: InboundFrame): return VideoUploadSignal.model_validate(frame.payload) elif event_type == EventType.FILE_READY: return FileUploadSignal.model_validate(frame.payload) + elif event_type == EventType.VOICE_READY: + return AudioUploadSignal.model_validate(frame.payload) return frame diff --git a/src/pymax/dispatch/resolvers.py b/src/pymax/dispatch/resolvers.py index 25d72d8..bf4fb70 100644 --- a/src/pymax/dispatch/resolvers.py +++ b/src/pymax/dispatch/resolvers.py @@ -3,7 +3,7 @@ from pymax.logging import get_logger from pymax.protocol import InboundFrame from pymax.protocol.enums import Opcode -from pymax.types import Message +from pymax.types import AudioUploadSignal, Message from pymax.types.domain.enums import MessageStatus from pymax.types.events import FileUploadSignal, VideoUploadSignal @@ -49,6 +49,12 @@ def resolve_attach(frame: InboundFrame) -> EventType | None: except ValidationError: logger.debug("attach event is not a video upload signal") + try: + AudioUploadSignal.model_validate(frame.payload) + return EventType.VOICE_READY + except ValidationError: + logger.debug("attach event is not a voice upload signal") + return None diff --git a/src/pymax/files/__init__.py b/src/pymax/files/__init__.py index eca51f9..b5f842d 100644 --- a/src/pymax/files/__init__.py +++ b/src/pymax/files/__init__.py @@ -1,9 +1,12 @@ from .file import File from .photo import Photo -from .video import Video +from .video import Video, VideoNote +from .voice import Voice __all__ = ( "File", "Photo", "Video", + "VideoNote", + "Voice", ) diff --git a/src/pymax/files/video.py b/src/pymax/files/video.py index 79fd8a9..5474658 100644 --- a/src/pymax/files/video.py +++ b/src/pymax/files/video.py @@ -1,8 +1,14 @@ from collections.abc import AsyncGenerator +from io import BytesIO from pathlib import Path from .base import BaseFile +try: + from tinytag import TinyTag +except ImportError: + TinyTag = None + class Video(BaseFile): """Видео для отправки в сообщение. @@ -72,3 +78,55 @@ def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: Async generator с байтами. """ return super().iter_chunks(size) + + +class VideoNote(Video): + """Круглое видеосообщение для отправки. + + Принимает те же источники, что и ``Video``. Длительность задается в + миллисекундах. Если она не передана, требуется extra ``video`` для + автоматического определения длительности. + """ + + def __init__( + self, + raw: bytes | None = None, + *, + url: str | None = None, + path: str | None = None, + name: str | None = None, + duration: int | None = None, + ) -> None: + self.duration = duration + super().__init__(raw, url=url, path=path, name=name) + + async def get_duration(self) -> int: + if self.duration is not None: + return self.duration + + if not TinyTag: + raise RuntimeError( + "Automatic video duration detection requires the 'video' extra. " + "Install it with `uv add 'maxapi-python[video]'` " + "or pass duration manually." + ) + + if self.raw: + tag = TinyTag.get( + filename=self.name, + file_obj=BytesIO(self.raw), + tags=False, + duration=True, + ) + else: + tag = TinyTag.get( + filename=self.name, + file_obj=BytesIO(await self.read()), + tags=False, + duration=True, + ) + + if tag.duration is None: + raise ValueError("Failed to determine video duration.") + + return round(tag.duration * 1000) diff --git a/src/pymax/files/voice.py b/src/pymax/files/voice.py new file mode 100644 index 0000000..2196b39 --- /dev/null +++ b/src/pymax/files/voice.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncGenerator +from pathlib import Path + +from .base import BaseFile + + +class Voice(BaseFile): + """Голосовое сообщение в формате OGG для отправки. + + Принимает ``path``, ``url`` или ``raw``. Для ``raw`` необходимо явно + передать имя файла. + """ + + def __init__( + self, + raw: bytes | None = None, + *, + path: str | None = None, + url: str | None = None, + name: str | None = None, + ) -> None: + self.name: str = name or "" + if not self.name and path: + self.name = Path(path).name + elif not self.name and url: + self.name = Path(url).name + + if not self.name: + raise ValueError("Either name, url or path must be provided.") + super().__init__(raw=raw, url=url, path=path, name=self.name) + + async def read(self) -> bytes: + return await super().read() + + async def size(self) -> int: + return await super().size() + + def iter_chunks(self, size: int) -> AsyncGenerator[bytes, None]: + return super().iter_chunks(size) diff --git a/src/pymax/fingerprint/__init__.py b/src/pymax/fingerprint/__init__.py new file mode 100644 index 0000000..1f97240 --- /dev/null +++ b/src/pymax/fingerprint/__init__.py @@ -0,0 +1 @@ +from .fingerprint import FingerprintGenerator diff --git a/src/pymax/fingerprint/fingerprint.py b/src/pymax/fingerprint/fingerprint.py new file mode 100644 index 0000000..a277693 --- /dev/null +++ b/src/pymax/fingerprint/fingerprint.py @@ -0,0 +1,47 @@ +import hashlib +import json +import struct +from importlib import resources +from typing import Any + +from .models import ApkBuildFingerprint + + +class FingerprintGenerator: + def __init__( + self, + ) -> None: + self.path = resources.files("pymax._data") / "apk_fingerprints.json" + self.data = self.load_fingerprints() + + def load_fingerprints(self) -> Any: + with self.path.open("r", encoding="utf-8") as f: + return json.load(f) + + def generate_fingerprint( + self, + version: str, + device_id: str, + calls_seed: int, + arch: str = "arm64-v8a", + ) -> bytes | None: + data = self.data.get(version) + if not data: + return None + + model = ApkBuildFingerprint.model_validate(data) + + seed_bytes = struct.pack(">q", calls_seed) + device_bytes = device_id.encode("utf-8") + + h1 = hashlib.sha256( + bytes.fromhex(model.certificate_meta_sha256) + seed_bytes + device_bytes + ).digest() + h2 = hashlib.sha256( + bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes + ).digest() + h3 = hashlib.sha256( + bytes.fromhex(model.so_meta_sha256[arch]) + seed_bytes + device_bytes + ).digest() + + return h1 + h2 + h3 diff --git a/src/pymax/fingerprint/models.py b/src/pymax/fingerprint/models.py new file mode 100644 index 0000000..9bcb2da --- /dev/null +++ b/src/pymax/fingerprint/models.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ApkBuildFingerprint(BaseModel): + signature_scheme: str + certificate_count: int + certificate_meta_sha256: str + certificate_sha256: list[str] + dex_meta_sha256: str + so_meta_sha256: dict[str, str] + build_number: int diff --git a/src/pymax/infra/auth.py b/src/pymax/infra/auth.py index 932034b..bdc6e87 100644 --- a/src/pymax/infra/auth.py +++ b/src/pymax/infra/auth.py @@ -95,3 +95,7 @@ async def check_2fa(self) -> bool: """ return await self._app.api.auth.check_2fa() + + def is_update_available(self) -> bool: + """Возвращает признак доступного обновления приложения по handshake.""" + return self._app.api.auth.is_update_available() diff --git a/src/pymax/infra/bots.py b/src/pymax/infra/bots.py index d64551f..8688acb 100644 --- a/src/pymax/infra/bots.py +++ b/src/pymax/infra/bots.py @@ -9,14 +9,14 @@ class BotsMixin(IClientProtocol): async def get_bot_init_data( self, bot_id: int, - chat_id: int, + chat_id: int | None = None, start_param: str | None = None, ) -> InitData: - """Получает начальные данные для бота в контексте конкретного чата. + """Получает начальные данные для запуска бота. Args: bot_id: Идентификатор бота. - chat_id: Идентификатор чата, в котором бот будет использоваться. + chat_id: Необязательный ID чата, в котором бот будет использоваться. start_param: Необязательный параметр, передаваемый при запуске бота. diff --git a/src/pymax/infra/chat.py b/src/pymax/infra/chat.py index 7e13218..3d11b93 100644 --- a/src/pymax/infra/chat.py +++ b/src/pymax/infra/chat.py @@ -1,3 +1,4 @@ +from pymax.api.chats import ChannelPermissions from pymax.types import Chat, Member, Message from .protocol import IClientProtocol @@ -211,6 +212,28 @@ async def get_chat(self, chat_id: int) -> Chat: """ return await self._app.api.chats.get_chat(chat_id) + async def get_chat_members( + self, + chat_id: int, + marker: int | None = None, + count: int = 50, + ) -> tuple[list[Member], int]: + """Возвращает страницу участников чата по ID. + + Args: + chat_id: ID чата. + marker: Маркер страницы. Если ``None``, запрашивается первая + страница. + count: Максимальное количество участников в ответе. + + Returns: + ``(members, next_marker)``. Если участников больше, чем + уместилось в ответ, ``next_marker`` ненулевой — передайте его в + следующий вызов, чтобы получить следующую страницу. ``0`` + означает, что дальше страниц нет. + """ + return await self._app.api.chats.get_chat_members(chat_id, marker, count) + async def leave_group(self, chat_id: int) -> None: """Выходит из группы. @@ -372,3 +395,18 @@ async def join_channel(self, link: str) -> Chat: Канал, в который вступил клиент. """ return await self._app.api.chats.join_channel(link=link) + + async def add_admin( + self, + chat_id: int, + user_id: int, + permissions: list[ChannelPermissions], + ) -> None: + """Назначает пользователя администратором канала. + + Args: + chat_id: ID канала. + user_id: ID пользователя. + permissions: Непустой список прав администратора. + """ + return await self._app.api.chats.add_admin(chat_id, user_id, permissions) diff --git a/src/pymax/infra/message.py b/src/pymax/infra/message.py index ea1d3f1..92bd614 100644 --- a/src/pymax/infra/message.py +++ b/src/pymax/infra/message.py @@ -3,6 +3,7 @@ from pymax.types import ( FileRequest, Message, + PollState, ReactionInfo, ReadState, VideoRequest, @@ -17,23 +18,26 @@ class MessageMixin(IClientProtocol): async def send_message( self, chat_id: int, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в чат. Args: chat_id: ID чата. - text: Текст сообщения. + text: Текст сообщения. Можно не передавать при наличии вложений. reply_to: ID сообщения для ответа. - attachments: Файлы, фотографии или видео для отправки. + attachments: Файлы, фотографии, видео или опросы для отправки. notify: Отправить ли получателям push-уведомление. Returns: - Отправленное сообщение или ``None``, если сервер не вернул его. + Отправленное сообщение. + + Raises: + ValueError: Если не переданы ни текст, ни вложения. """ return await self._app.api.messages.send_message( chat_id, @@ -69,7 +73,7 @@ async def forward_message( source_chat_id: int | None = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Пересылает существующее сообщение в чат. Args: @@ -80,7 +84,7 @@ async def forward_message( notify: Отправить ли получателям push-уведомление. Returns: - Пересланное сообщение или ``None``, если сервер не вернул его. + Пересланное сообщение. """ return await self._app.api.messages.forward_message( chat_id=chat_id, @@ -112,7 +116,7 @@ async def edit_message( self, chat_id: int, message_id: int, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения сообщения. @@ -121,7 +125,7 @@ async def edit_message( chat_id: ID чата. message_id: ID сообщения. text: Новый текст сообщения с поддержкой markdown. - attachments: Новые файлы, фотографии или видео для сообщения. + attachments: Новые файлы, фотографии, видео или опросы для сообщения. Returns: Отредактированное сообщение. @@ -145,7 +149,7 @@ async def fetch_history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: """Загружает историю сообщений чата. Args: @@ -162,7 +166,7 @@ async def fetch_history( interactive: Пометить запрос как интерактивный. Returns: - Сообщения или ``None``, если сервер не вернул список. + Список сообщений. Если сервер не вернул сообщения, список пуст. """ return await self._app.api.messages.fetch_history( chat_id=chat_id, @@ -344,3 +348,28 @@ async def read_message(self, message_id: int | str, chat_id: int) -> ReadState: message_id=message_id, chat_id=chat_id, ) + + async def vote_poll( + self, + chat_id: int, + message_id: int, + poll_id: int, + answer_ids: list[int], + ) -> PollState: + """Отправляет выбранные ответы в опросе. + + Args: + chat_id: ID чата с опросом. + message_id: ID сообщения с опросом. + poll_id: ID опроса из ``PollAttachment.poll_id``. + answer_ids: ID выбранных вариантов ответа. + + Returns: + Обновленное состояние опроса. + """ + return await self._app.api.messages.vote_poll( + chat_id=chat_id, + message_id=message_id, + poll_id=poll_id, + answer_ids=answer_ids, + ) diff --git a/src/pymax/infra/self.py b/src/pymax/infra/self.py index 361b055..903a41c 100644 --- a/src/pymax/infra/self.py +++ b/src/pymax/infra/self.py @@ -1,5 +1,7 @@ from typing import Any +from pymax.api.self import PrivacySettingsUpdate +from pymax.files import Photo from pymax.types import FolderList, FolderUpdate from .protocol import IClientProtocol @@ -19,7 +21,7 @@ async def change_profile( first_name: str, last_name: str | None = None, description: str | None = None, - photo: Any | None = None, + photo: Photo | None = None, *, photo_token: str | None = None, ) -> bool: @@ -133,3 +135,25 @@ async def logout(self) -> bool: ``True``, если сервер принял запрос на выход. """ return await self._app.api.account.logout() + + def set_presence(self, *, online: bool) -> None: + """Устанавливает статус присутствия текущего аккаунта. + + Статус применяется не мгновенно, а при следующем запросе login/ping. + + Args: + online: ``True``, если нужно установить статус "в сети", иначе + ``False``. + """ + self._app.api.account.set_presence(online) + + async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool: + """Обновляет настройки приватности текущего аккаунта. + + Args: + settings: Объект с новыми настройками приватности. + + Returns: + ``True``, если сервер принял запрос. + """ + return await self._app.api.account.change_profile_settings(settings=settings) diff --git a/src/pymax/protocol/enums.py b/src/pymax/protocol/enums.py index 8d10717..ac46316 100644 --- a/src/pymax/protocol/enums.py +++ b/src/pymax/protocol/enums.py @@ -18,6 +18,7 @@ class Opcode(int, Enum): RECONNECT = 3 LOG = 5 SESSION_INIT = 6 + LOGIN2 = 8 PROFILE = 16 AUTH_REQUEST = 17 AUTH = 18 @@ -152,6 +153,18 @@ class Opcode(int, Enum): PROFILE_DELETE = 199 PROFILE_DELETE_TIME = 200 TRANSCRIBE_MEDIA = 202 + STORIES_LIST = 208 + STORIES_LIST_BY_OWNER_ID = 209 + STORIES_GET_BY_OWNER_ID = 210 + STORIES_GET_STATS = 211 + STORIES_GET_DETAILED_STATS = 212 + STORIES_REACT = 213 + STORIES_MARK = 214 + STORIES_SEND = 215 + NOTIF_STORIES_UPDATE = 216 + STORIES_EDIT = 217 + STORIES_DELETE = 218 + STORIES_GET_BY_STORY_ID = 220 ORG_INFO = 256 CHAT_REACTIONS_SETTINGS_SET = 257 REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258 diff --git a/src/pymax/protocol/tcp/payload.py b/src/pymax/protocol/tcp/payload.py index 3d94012..175d1aa 100644 --- a/src/pymax/protocol/tcp/payload.py +++ b/src/pymax/protocol/tcp/payload.py @@ -11,6 +11,8 @@ class MsgpackPayloadCodec: + WRAPPED_VALUE_EXT_CODE = 1 + def _to_msgpack_value(self, value: Any) -> Any: if isinstance(value, Enum): return value.value @@ -30,10 +32,25 @@ def encode(self, payload: object) -> bytes: def _unpack_stream( self, payload_bytes: bytes, *, raw: bool ) -> list[Any]: # TODO: deprecate? idk - unpacker = msgpack.Unpacker(raw=raw, strict_map_key=False) + unpacker = msgpack.Unpacker( + raw=raw, + strict_map_key=False, + ext_hook=self._decode_ext, + ) unpacker.feed(payload_bytes) return list(unpacker) + def _decode_ext(self, code: int, data: bytes) -> Any: + if code != self.WRAPPED_VALUE_EXT_CODE: + return msgpack.ExtType(code, data) + + return msgpack.unpackb( + data, + raw=False, + strict_map_key=False, + ext_hook=self._decode_ext, + ) + def decode(self, payload_bytes: bytes) -> Any: if not payload_bytes: return {} @@ -43,6 +60,7 @@ def decode(self, payload_bytes: bytes) -> Any: payload_bytes, raw=False, strict_map_key=False, + ext_hook=self._decode_ext, ) except msgpack.exceptions.ExtraData as e: @@ -81,6 +99,8 @@ def _normalize_keys(self, obj: Any) -> Any: return {self._normalize_key(k): self._normalize_keys(v) for k, v in obj.items()} if isinstance(obj, list): return [self._normalize_keys(item) for item in obj] + if isinstance(obj, msgpack.ExtType): + return obj if isinstance(obj, tuple): return tuple(self._normalize_keys(item) for item in obj) return obj diff --git a/src/pymax/transport/tcp.py b/src/pymax/transport/tcp.py index a0eb24b..309766c 100644 --- a/src/pymax/transport/tcp.py +++ b/src/pymax/transport/tcp.py @@ -7,6 +7,7 @@ from .base import Transport logger = get_logger(__name__) +_CLOSE_TIMEOUT = 5.0 class TCPTransport(Transport): @@ -66,7 +67,11 @@ async def close(self) -> None: if writer: logger.debug("tcp close") writer.close() - await writer.wait_closed() + try: + await asyncio.wait_for(writer.wait_closed(), _CLOSE_TIMEOUT) + except (OSError, TimeoutError) as e: + logger.warning("tcp close did not finish cleanly: %s", e) + writer.transport.abort() logger.debug("tcp closed") async def send(self, data: bytes | str) -> None: diff --git a/src/pymax/transport/websocket.py b/src/pymax/transport/websocket.py index cab0f29..7e0baff 100644 --- a/src/pymax/transport/websocket.py +++ b/src/pymax/transport/websocket.py @@ -17,11 +17,14 @@ def __init__(self, url: str, proxy: str | None) -> None: async def connect(self) -> None: if self.proxy: self.ws = await client.connect( - self.url, origin=Origin("https://web.max.ru"), proxy=self.proxy + self.url, + origin=Origin("https://web.max.ru"), + proxy=self.proxy, + max_size=1024 * 1024 * 10, # 10 MB ) else: self.ws = await client.connect( - self.url, origin=Origin("https://web.max.ru") + self.url, origin=Origin("https://web.max.ru"), max_size=1024 * 1024 * 10 ) # TODO: origin should be configurable async def close(self) -> None: @@ -41,7 +44,7 @@ async def recv(self, n: int | None = None) -> bytes | str: if self.ws is None or not self.connected: raise ConnectionError("Not connected to the server") - return await self.ws.recv(decode=True) + return await self.ws.recv(decode=False) @property def connected(self) -> bool: diff --git a/src/pymax/types/domain/__init__.py b/src/pymax/types/domain/__init__.py index e118c45..7e1d0c4 100644 --- a/src/pymax/types/domain/__init__.py +++ b/src/pymax/types/domain/__init__.py @@ -3,7 +3,8 @@ from .chat import Chat from .error import MaxApiError from .folder import Folder, FolderList, FolderUpdate -from .login import LoginResponse +from .handshake import HandshakeResponse +from .login import Login2Flags, Login2Response, LoginResponse from .member import Member from .message import Message, ReactionCounter, ReactionInfo, ReadState from .name import Name diff --git a/src/pymax/types/domain/attachments/__init__.py b/src/pymax/types/domain/attachments/__init__.py index bd9e402..0aac71c 100644 --- a/src/pymax/types/domain/attachments/__init__.py +++ b/src/pymax/types/domain/attachments/__init__.py @@ -2,10 +2,11 @@ from .call import CallAttachment from .contact import ContactAttachment from .control import ControlAttachment -from .enums import AttachmentType +from .enums import AttachmentType, PollFlags from .file import FileAttachment, FileRequest from .keyboards import InlineKeyboardAttachment from .photo import PhotoAttachment +from .poll import Poll, PollAnswer, PollAttachment, PollState from .share import ShareAttachment from .sticker import StickerAttachment from .unknown import UnknownAttachment diff --git a/src/pymax/types/domain/attachments/enums.py b/src/pymax/types/domain/attachments/enums.py index 65d23ec..583f821 100644 --- a/src/pymax/types/domain/attachments/enums.py +++ b/src/pymax/types/domain/attachments/enums.py @@ -1,4 +1,4 @@ -from enum import Enum +from enum import Enum, IntFlag class AttachmentType(str, Enum): @@ -14,6 +14,7 @@ class AttachmentType(str, Enum): CALL = "CALL" SHARE = "SHARE" INLINE_KEYBOARD = "INLINE_KEYBOARD" + POLL = "POLL" UNKNOWN = "UNKNOWN" @@ -26,3 +27,18 @@ class TranscriptionStatus(str, Enum): PROCESSING = "PROCESSING" SUCCESS = "SUCCESS" UNKNOWN = "UNKNOWN" + + +class PollFlags(IntFlag): + """Настройки опроса, представленные битовой маской. + + Несколько настроек объединяются оператором ``|``. Числовые значения, + полученные от Max, автоматически преобразуются в ``PollFlags``. + """ + + FLAG_SETTINGS_ANONYMOUS = 1 + FLAG_SETTINGS_MULTISELECT = 2 + FLAG_SETTINGS_REVOTE = 4 + FLAG_SETTINGS_CLOSED = 8 + FLAG_SETTINGS_QUIZ = 16 + FLAG_SETTINGS_CAN_FORWARD = 32 diff --git a/src/pymax/types/domain/attachments/poll.py b/src/pymax/types/domain/attachments/poll.py new file mode 100644 index 0000000..f1e4ff1 --- /dev/null +++ b/src/pymax/types/domain/attachments/poll.py @@ -0,0 +1,111 @@ +from typing import Literal + +from pydantic import Field + +from pymax.types.domain.base import CamelModel + +from .enums import AttachmentType, PollFlags + + +class PollAnswer(CamelModel): + """Вариант ответа в опросе. + + :ivar text: Текст варианта ответа. + :vartype text: str + :ivar answer_id: ID варианта, назначенный Max. + :vartype answer_id: int | None + """ + + text: str + answer_id: int | None = None + + +class PollVote(CamelModel): + """Голос пользователя за один вариант ответа. + + :ivar timestamp: Время голосования в формате Unix time. + :vartype timestamp: int + :ivar user_id: ID проголосовавшего пользователя. + :vartype user_id: int + """ + + timestamp: int + user_id: int + + +class PollResult(CamelModel): + """Результат голосования по одному варианту ответа. + + :ivar answer_id: ID варианта ответа. + :vartype answer_id: int + :ivar vote_count: Количество голосов. + :vartype vote_count: int + :ivar votes: Голоса пользователей, доступные текущему аккаунту. + :vartype votes: list[PollVote] + :ivar rate: Доля голосов в формате, возвращаемом Max. + :vartype rate: int + :ivar options: Дополнительные параметры результата от Max. + :vartype options: int + """ + + answer_id: int + vote_count: int + votes: list[PollVote] + rate: int + options: int + + +class PollState(CamelModel): + """Текущее состояние голосования. + + :ivar total: Общее количество голосов. + :vartype total: int + :ivar result: Результаты по вариантам ответа. + :vartype result: list[PollResult] | None + :ivar voter_preview_ids: ID пользователей для предпросмотра списка + проголосовавших. + :vartype voter_preview_ids: list[int] + """ + + total: int = 0 + result: list[PollResult] | None = None + voter_preview_ids: list[int] + + +class Poll(CamelModel): + """Опрос для отправки в сообщении. + + :ivar title: Вопрос или заголовок опроса. + :vartype title: str + :ivar answers: Варианты ответа. + :vartype answers: list[PollAnswer] + :ivar settings: Настройки опроса. Несколько ``PollFlags`` объединяются + оператором ``|``. + :vartype settings: PollFlags + :ivar type: Тип вложения. + :vartype type: Literal[AttachmentType.POLL] + """ + + title: str + answers: list[PollAnswer] + settings: PollFlags + type: Literal[AttachmentType.POLL] = Field(alias="_type", default=AttachmentType.POLL) + + +class PollAttachment(Poll): + """Опрос, полученный как вложение сообщения. + + Помимо параметров опроса содержит назначенный Max ID, версию и текущее + состояние голосования. + + :ivar poll_id: ID опроса. + :vartype poll_id: int + :ivar version: Версия данных опроса. + :vartype version: int + :ivar state: Текущее состояние голосования. + :vartype state: PollState + """ + + poll_id: int + version: int + state: PollState diff --git a/src/pymax/types/domain/attachments/sticker.py b/src/pymax/types/domain/attachments/sticker.py index 6ba4bbc..924fa6a 100644 --- a/src/pymax/types/domain/attachments/sticker.py +++ b/src/pymax/types/domain/attachments/sticker.py @@ -42,7 +42,7 @@ class StickerAttachment(CamelModel): sticker_id: int tags: list[str] | None = None width: int - set_id: int + set_id: int | None = None time: int sticker_type: str audio: bool diff --git a/src/pymax/types/domain/attachments/video.py b/src/pymax/types/domain/attachments/video.py index f1c33a7..f1f583a 100644 --- a/src/pymax/types/domain/attachments/video.py +++ b/src/pymax/types/domain/attachments/video.py @@ -62,18 +62,18 @@ class VideoRequest(CamelModel): :vartype external: str | bool | None :ivar cache: Использовать ли кеш. :vartype cache: bool - :ivar url: URL видео. - :vartype url: str + :ivar url: Прямой URL видео или ``None`` для внешнего видео. + :vartype url: str | None """ external: str | bool | None = Field(default=None, alias="EXTERNAL") - cache: bool - url: str + cache: bool = False # TODO: idk maybe | None = None better + url: str | None = None @model_validator(mode="before") @classmethod - def unwrap_dynamic_url(cls, value: Any) -> Any: - """Нормализует динамический ключ URL в поле ``url``. + def select_video_url(cls, value: Any) -> Any: + """Выбирает прямой URL с максимальным доступным MP4-качеством. :param value: Значение, переданное в валидатор модели. :type value: Any @@ -83,8 +83,29 @@ def unwrap_dynamic_url(cls, value: Any) -> Any: if not isinstance(value, dict) or "url" in value: return value + mp4_urls: list[tuple[int, str]] = [] for key, url in value.items(): - if key not in ("EXTERNAL", "cache"): - return {**value, "url": url} + if not isinstance(key, str) or not isinstance(url, str): + continue + + normalized_key = key.upper() + if not normalized_key.startswith("MP4_"): + continue + + try: + quality = int(normalized_key.removeprefix("MP4_")) + except ValueError: + continue + + if quality > 0: + mp4_urls.append((quality, url)) + + if mp4_urls: + _, url = max(mp4_urls, key=lambda item: item[0]) + return {**value, "url": url} + + legacy_url = value.get("dynamicUrl", value.get("dynamic_url")) + if isinstance(legacy_url, str): + return {**value, "url": legacy_url} return value diff --git a/src/pymax/types/domain/chat.py b/src/pymax/types/domain/chat.py index fed0e86..4805a74 100644 --- a/src/pymax/types/domain/chat.py +++ b/src/pymax/types/domain/chat.py @@ -156,25 +156,24 @@ def bind( async def answer( self, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в этот чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param reply_to: ID сообщения для ответа. :type reply_to: int | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если чат не привязан к клиенту. """ actions, _ = self._bound() @@ -198,7 +197,7 @@ async def history( get_chat: bool = False, get_messages: bool = True, interactive: bool = False, - ) -> list[Message] | None: + ) -> list[Message]: """Загружает историю сообщений этого чата. ``from_time``, ``backward_time`` и ``forward_time`` передаются в @@ -224,8 +223,9 @@ async def history( :type get_messages: bool :param interactive: Пометить запрос как интерактивный. :type interactive: bool - :returns: Сообщения или ``None``, если сервер не вернул список. - :rtype: list[Message] | None + :returns: Список сообщений. Если сервер не вернул сообщения, список + пуст. + :rtype: list[Message] :raises RuntimeError: Если чат не привязан к клиенту. """ actions, _ = self._bound() diff --git a/src/pymax/types/domain/handshake.py b/src/pymax/types/domain/handshake.py new file mode 100644 index 0000000..4de92d2 --- /dev/null +++ b/src/pymax/types/domain/handshake.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from .base import CamelModel + + +class HandshakeResponse(CamelModel): + """Результат рукопожатия. + + :ivar calls_seed: Сид для генерации хэшей вызовов. + :vartype calls_seed: int + """ + + calls_seed: int | None = None + app_update_type: int | None = Field( + alias="app-update-type", default=None + ) # very bizarre casing diff --git a/src/pymax/types/domain/login.py b/src/pymax/types/domain/login.py index 91ef2bf..e623619 100644 --- a/src/pymax/types/domain/login.py +++ b/src/pymax/types/domain/login.py @@ -13,14 +13,26 @@ class LoginConfig(CamelModel): hash: ConfigHash | None = None +class Login2Flags(CamelModel): + config_enabled: bool = False + contact_enabled: bool = False + profile_enabled: bool = False + + @property + def enabled(self) -> bool: + return self.config_enabled or self.contact_enabled or self.profile_enabled + + class LoginResponse(CamelModel): chats: list[Chat] = Field(default_factory=list) - profile: Profile + profile: Profile | None = None messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message] contacts: list[User | None] = Field(default_factory=list) token: str | None = None time: int | None = None config: LoginConfig | None = None + updates: int | None = None + login2_flags: Login2Flags | None = None def update_sync_state(self, current: SyncState) -> SyncState: sync_time = self.time @@ -33,3 +45,20 @@ def update_sync_state(self, current: SyncState) -> SyncState: presence_sync=(sync_time if sync_time is not None else current.presence_sync), config_hash=(config_hash if config_hash is not None else current.config_hash), ) + + +class Login2Response(CamelModel): + profile: Profile | None = None + contacts: list[User | None] = Field(default_factory=list, alias="contactInfos") + config: LoginConfig | None = None + + def update_sync_state(self, current: SyncState) -> SyncState: + config_hash = self.config.hash if self.config is not None else None + + return SyncState( + chats_sync=current.chats_sync, + contacts_sync=current.contacts_sync, + drafts_sync=current.drafts_sync, + presence_sync=current.presence_sync, + config_hash=(config_hash if config_hash is not None else current.config_hash), + ) diff --git a/src/pymax/types/domain/message.py b/src/pymax/types/domain/message.py index ec4fc84..ca636fc 100644 --- a/src/pymax/types/domain/message.py +++ b/src/pymax/types/domain/message.py @@ -5,7 +5,7 @@ from pydantic import Field, PrivateAttr, model_validator -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, VideoNote, Voice from pymax.types.domain import ( AudioAttachment, CallAttachment, @@ -14,6 +14,8 @@ FileAttachment, InlineKeyboardAttachment, PhotoAttachment, + Poll, + PollAttachment, ShareAttachment, StickerAttachment, UnknownAttachment, @@ -38,11 +40,12 @@ | ControlAttachment | InlineKeyboardAttachment | ShareAttachment - | CallAttachment, + | CallAttachment + | PollAttachment, Field(discriminator="type"), ] Attachment: TypeAlias = KnownAttachment | UnknownAttachment -SendAttachment: TypeAlias = Photo | File | Video +SendAttachment: TypeAlias = Photo | File | Video | Poll | Voice | VideoNote SendAttachments: TypeAlias = Sequence[SendAttachment] | None @@ -182,22 +185,21 @@ def bind(self, actions: MessageService) -> Message: async def reply( self, - text: str, + text: str | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет ответ на это сообщение в тот же чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ @@ -213,25 +215,24 @@ async def reply( async def answer( self, - text: str, + text: str | None = None, reply_to: int | None = None, attachments: SendAttachments = None, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Отправляет сообщение в тот же чат. :param text: Текст сообщения. - :type text: str + :type text: str | None :param reply_to: ID сообщения для ответа. :type reply_to: int | None :param attachments: Файлы, фотографии или видео для отправки. :type attachments: SendAttachments :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Отправленное сообщение или ``None``, если сервер не вернул - его. - :rtype: Message | None + :returns: Отправленное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ @@ -250,15 +251,15 @@ async def forward( chat_id: int, *, notify: bool = True, - ) -> Message | None: + ) -> Message: """Пересылает это сообщение в другой чат. :param chat_id: ID целевого чата. :type chat_id: int :param notify: Отправить ли получателям push-уведомление. :type notify: bool - :returns: Пересланное сообщение или ``None``, если сервер его не вернул. - :rtype: Message | None + :returns: Пересланное сообщение. + :rtype: Message :raises RuntimeError: Если сообщение не привязано к сервису или не содержит ``chat_id``. """ @@ -291,13 +292,13 @@ async def pin(self, notify_pin: bool = True) -> bool: async def edit( self, - text: str, + text: str | None = None, attachments: SendAttachments = None, ) -> Message: """Редактирует текст и вложения этого сообщения. :param text: Новый текст сообщения с поддержкой markdown. - :type text: str + :type text: str | None :param attachments: Новые файлы, фотографии или видео для сообщения. :type attachments: SendAttachments :returns: Отредактированное сообщение. diff --git a/src/pymax/types/domain/user.py b/src/pymax/types/domain/user.py index 2c04b60..0641faf 100644 --- a/src/pymax/types/domain/user.py +++ b/src/pymax/types/domain/user.py @@ -71,10 +71,8 @@ class User(CamelModel): phone: int | None = None status: str | None = None description: str | None = None - # Bots may send ``gender`` as a numeric code and ``web_app`` as a URL - # string instead of an object; accept these so profile parsing won't fail. gender: str | int | None = None - link: str | None = None + link: str | int | None = None web_app: dict[str, Any] | str | None = None menu_button: dict[str, Any] | None = None diff --git a/src/pymax/types/events/__init__.py b/src/pymax/types/events/__init__.py index 1667633..dc7a56a 100644 --- a/src/pymax/types/events/__init__.py +++ b/src/pymax/types/events/__init__.py @@ -5,3 +5,4 @@ from .reaction import ReactionUpdateEvent from .typing import TypingEvent from .video import VideoUploadSignal +from .voice import AudioUploadSignal diff --git a/src/pymax/types/events/reaction.py b/src/pymax/types/events/reaction.py index 964729a..2693f15 100644 --- a/src/pymax/types/events/reaction.py +++ b/src/pymax/types/events/reaction.py @@ -17,5 +17,5 @@ class ReactionUpdateEvent(CamelModel): message_id: str chat_id: int - counters: list[ReactionCounter] - total_count: int + counters: list[ReactionCounter] | None = None + total_count: int = 0 diff --git a/src/pymax/types/events/voice.py b/src/pymax/types/events/voice.py new file mode 100644 index 0000000..a11c7b0 --- /dev/null +++ b/src/pymax/types/events/voice.py @@ -0,0 +1,5 @@ +from pymax.types.domain.base import CamelModel + + +class AudioUploadSignal(CamelModel): + audio_id: int diff --git a/tests/api/test_auth_service.py b/tests/api/test_auth_service.py index c2a3daf..67bdb18 100644 --- a/tests/api/test_auth_service.py +++ b/tests/api/test_auth_service.py @@ -2,10 +2,15 @@ import pytest -from pymax.api.auth.enums import ProfileOptions, TwoFactorAction +import pymax.config as config_module +from pymax import ExtraConfig +from pymax.api.auth.enums import AuthType, ProfileOptions, TwoFactorAction from pymax.api.session.enums import DeviceType +from pymax.config import APP_VERSIONS, MIN_PREFERRED_BUILD +from pymax.fingerprint import FingerprintGenerator from pymax.protocol import Opcode from pymax.session.models import SessionInfo +from pymax.types.domain import HandshakeResponse, Login2Flags from pymax.types.domain.sync import SyncState from tests.conftest import ( FakeApp, @@ -28,6 +33,29 @@ async def get_code(self, email: str) -> str: return self.code +def test_supported_app_versions_match_packaged_fingerprints() -> None: + fingerprints = FingerprintGenerator().data + + assert set(fingerprints) == {version for version, _ in APP_VERSIONS} + assert all( + fingerprints[version]["build_number"] == build_number + for version, build_number in APP_VERSIONS + ) + + +@pytest.mark.parametrize(("roll", "preferred"), [(0.0, True), (0.9, False)]) +def test_generate_user_agent_selects_preferred_and_legacy_versions( + monkeypatch: pytest.MonkeyPatch, + roll: float, + preferred: bool, +) -> None: + monkeypatch.setattr(config_module, "random", lambda: roll) + + user_agent = ExtraConfig().generate_user_agent() + + assert (user_agent.build_number >= MIN_PREFERRED_BUILD) is preferred + + @pytest.mark.asyncio async def test_request_and_send_code_parse_auth_responses() -> None: app = FakeApp( @@ -55,9 +83,75 @@ async def test_request_and_send_code_parse_auth_responses() -> None: Opcode.AUTH, ] assert app.calls[0].payload["phone"] == "+79990000000" + assert app.calls[0].payload["mode"] == app.fingerprint_generator.generate_fingerprint( + version=app.config.device.user_agent.app_version, + device_id=app.config.device.device_id, + calls_seed=123, + ) assert app.calls[1].payload["verifyCode"] == "111111" +@pytest.mark.asyncio +async def test_web_request_code_omits_mode_without_calls_seed() -> None: + app = FakeApp( + [ + frame( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 2, + "altActionDuration": 5, + } + ) + ], + device_type=DeviceType.WEB, + ) + app.handshake_response = HandshakeResponse() + + await app.api.auth.request_code("+79990000000") + + assert app.calls[0].opcode == Opcode.AUTH_REQUEST + assert "mode" not in app.calls[0].payload + + +@pytest.mark.asyncio +async def test_mobile_request_code_requires_calls_seed() -> None: + app = FakeApp() + app.handshake_response = HandshakeResponse() + + with pytest.raises(ValueError, match="AuthService.request_code"): + await app.api.auth.request_code("+79990000000") + + assert app.calls == [] + + +@pytest.mark.asyncio +async def test_mobile_request_code_accepts_zero_calls_seed() -> None: + app = FakeApp( + [ + frame( + { + "token": "sms-token", + "codeLength": 6, + "requestMaxDuration": 60, + "requestCountLeft": 2, + "altActionDuration": 5, + } + ) + ] + ) + app.handshake_response = HandshakeResponse(calls_seed=0) + + await app.api.auth.request_code("+79990000000") + + assert app.calls[0].payload["mode"] == app.fingerprint_generator.generate_fingerprint( + version=app.config.device.user_agent.app_version, + device_id=app.config.device.device_id, + calls_seed=0, + ) + + @pytest.mark.asyncio async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> None: app = FakeApp( @@ -93,6 +187,13 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert app.calls[0].opcode == Opcode.LOGIN assert app.calls[0].payload["token"] == "local-token" assert app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.ANDROID + assert app.calls[0].payload[ + "chatCacheFingerprint" + ] == app.fingerprint_generator.generate_fingerprint( + version=app.config.device.user_agent.app_version, + device_id=app.config.device.device_id, + calls_seed=123, + ) assert app.session is not None assert app.session.mt_instance_id == "mt-test" assert app.session.sync.chats_sync == 777 @@ -110,6 +211,61 @@ async def test_mobile_login_sends_sync_payload_and_persists_updated_session() -> assert response.contacts[0]._actions is app.api.users +@pytest.mark.asyncio +async def test_mobile_login2_sends_flags_binds_models_and_updates_config_hash() -> None: + app = FakeApp( + [ + frame( + { + "profile": profile_payload(42), + "contactInfos": [user_payload(43)], + "config": {"hash": "new-hash"}, + } + ) + ] + ) + app.session = SessionInfo( + token="local-token", + device_id="device-test", + phone="+79990000000", + sync=SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="old-hash", + ), + ) + + response = await app.api.auth.mobile_login2( + Login2Flags( + config_enabled=True, + contact_enabled=True, + profile_enabled=True, + ) + ) + + assert app.calls[0].opcode == Opcode.LOGIN2 + assert app.calls[0].payload == { + "needProfile": True, + "contactsSync": 2, + "configHash": "old-hash", + } + assert response.profile is not None + assert response.profile.contact._actions is app.api.users + assert response.contacts[0] is not None + assert response.contacts[0]._actions is app.api.users + assert app.session is not None + assert app.session.sync == SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="new-hash", + ) + assert app.store.saved_sessions == [app.session] + + @pytest.mark.asyncio async def test_login_uses_web_payload_for_web_user_agent() -> None: app = FakeApp( @@ -141,6 +297,22 @@ async def test_login_without_session_raises_runtime_error() -> None: assert app.calls == [] +@pytest.mark.asyncio +async def test_mobile_login_requires_calls_seed() -> None: + app = FakeApp() + app.session = SessionInfo( + token="local-token", + device_id="device-test", + phone="+79990000000", + ) + app.handshake_response = HandshakeResponse() + + with pytest.raises(ValueError, match="AuthService.mobile_login"): + await app.api.auth.mobile_login() + + assert app.calls == [] + + @pytest.mark.asyncio async def test_set_2fa_runs_password_email_hint_and_final_commit() -> None: provider = StaticEmailProvider() @@ -252,7 +424,7 @@ async def test_confirm_registration_sends_profile_and_parses_token() -> None: { "userToken": 42, "profile": profile_payload(42), - "tokenType": "REGISTER", + "tokenType": "LOGIN", "token": "registered-token", } ) @@ -266,6 +438,7 @@ async def test_confirm_registration_sends_profile_and_parses_token() -> None: ) assert result.token == "registered-token" + assert result.token_type == AuthType.LOGIN assert result.profile.contact.id == 42 assert app.calls[0].opcode == Opcode.AUTH_CONFIRM assert app.calls[0].payload == { diff --git a/tests/api/test_chat_user_self_session_services.py b/tests/api/test_chat_user_self_session_services.py index 7a0f5f0..6d47a2b 100644 --- a/tests/api/test_chat_user_self_session_services.py +++ b/tests/api/test_chat_user_self_session_services.py @@ -2,6 +2,7 @@ import pytest +from pymax import PrivacyAccess, PrivacySettingsUpdate from pymax.api.session.enums import DeviceType from pymax.exceptions import PyMaxError from pymax.protocol import Opcode @@ -365,6 +366,40 @@ async def test_self_service_change_profile_and_close_all_sessions() -> None: ] +@pytest.mark.asyncio +async def test_change_profile_settings_updates_privacy_and_saved_config_hash() -> None: + app = FakeApp([frame({"hash": "new-config-hash"})]) + app.session = SessionInfo(token="token", device_id="dev", phone="+7") + + result = await app.api.account.change_profile_settings( + PrivacySettingsUpdate( + search_by_phone=PrivacyAccess.CONTACTS, + incoming_calls=PrivacyAccess.ALL, + chat_invites=PrivacyAccess.NOBODY, + phone_number_visibility=PrivacyAccess.CONTACTS, + hide_online_status=True, + safe_content_only=True, + ) + ) + + assert result is True + assert app.calls[0].opcode == Opcode.CONFIG + assert app.calls[0].payload == { + "settings": { + "user": { + "SEARCH_BY_PHONE": "CONTACTS", + "INCOMING_CALL": "ALL", + "CHATS_INVITE": "_NONE_", + "PHONE_NUMBER_PRIVACY": "CONTACTS", + "HIDDEN": True, + "CONTENT_LEVEL_ACCESS": True, + } + } + } + assert app.session.sync.config_hash == "new-config-hash" + assert app.store.saved_sessions == [app.session] + + @pytest.mark.asyncio async def test_close_all_sessions_returns_false_without_session_or_token() -> None: app = FakeApp() @@ -424,15 +459,15 @@ async def test_self_service_profile_photo_folders_and_logout() -> None: @pytest.mark.asyncio async def test_session_handshake_switches_between_mobile_and_web_payloads() -> None: - mobile_app = FakeApp([frame({})]) - await mobile_app.api.session.handshake( + mobile_app = FakeApp([frame({"callsSeed": 101})]) + mobile_response = await mobile_app.api.session.handshake( "mt", mobile_app.config.device.user_agent, "device", ) - web_app = FakeApp([frame({})], device_type=DeviceType.WEB) - await web_app.api.session.handshake( + web_app = FakeApp([frame({"serverTime": 123})], device_type=DeviceType.WEB) + web_response = await web_app.api.session.handshake( "ignored", web_app.config.device.user_agent, "web-device", @@ -443,6 +478,9 @@ async def test_session_handshake_switches_between_mobile_and_web_payloads() -> N assert web_app.calls[0].payload["deviceId"] == "web-device" assert web_app.calls[0].payload["userAgent"]["deviceType"] == DeviceType.WEB assert "mt_instanceid" not in web_app.calls[0].payload + assert mobile_response is not None + assert mobile_response.calls_seed == 101 + assert web_response.calls_seed is None @pytest.mark.asyncio diff --git a/tests/api/test_message_service.py b/tests/api/test_message_service.py index 019ee25..56992e9 100644 --- a/tests/api/test_message_service.py +++ b/tests/api/test_message_service.py @@ -5,8 +5,9 @@ from pymax.api.messages.enums import ItemType, MessagePayloadKey from pymax.api.uploads.payloads import AttachPhotoPayload from pymax.exceptions import UploadError -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.protocol import Opcode +from pymax.types.domain.attachments import VideoRequest from tests.conftest import FakeApp, frame, message_payload @@ -103,10 +104,13 @@ async def test_upload_attachments_handles_file_video_and_empty_lists() -> None: app = FakeApp() assert await app.api.messages._upload_attachments(None) == [] - result = await app.api.messages._upload_attachments([File(raw=b"abc", name="doc.txt")]) + file = File(raw=b"abc", name="doc.txt") + voice = Voice(raw=b"voice", name="voice.ogg") + result = await app.api.messages._upload_attachments([file, voice]) assert result[0].file_id == 30 - assert app.api.uploads.calls[0][0] == "file" + assert result[1].type.value == "AUDIO" + assert app.api.uploads.calls == [("file", file), ("voice", voice)] @pytest.mark.asyncio @@ -294,7 +298,12 @@ async def test_edit_message_uploads_single_and_multiple_attachments() -> None: assert app.calls[0].payload["attachments"] == [{"_type": "PHOTO", "photoToken": "photo-token"}] assert app.calls[1].payload["attachments"] == [ {"_type": "FILE", "fileId": 30}, - {"_type": "VIDEO", "videoId": 20, "token": "video-token"}, + { + "_type": "VIDEO", + "videoId": 20, + "token": "video-token", + "videoType": 0, + }, ] @@ -368,11 +377,48 @@ async def test_reaction_methods_parse_optional_reaction_info() -> None: ] +@pytest.mark.asyncio +async def test_vote_poll_builds_payload_and_parses_state() -> None: + app = FakeApp( + [ + frame( + { + "state": { + "total": 1, + "result": None, + "voterPreviewIds": [77], + } + } + ) + ] + ) + + state = await app.api.messages.vote_poll(100, 10, 42, [3]) + + assert state.total == 1 + assert state.voter_preview_ids == [77] + assert app.calls[0].opcode == Opcode.SEND_VOTE + assert app.calls[0].payload == { + "chatId": 100, + "messageId": 10, + "pollId": 42, + "answersIds": [3], + } + + @pytest.mark.asyncio async def test_get_video_and_file_by_id_parse_request_models() -> None: app = FakeApp( [ - frame({"cache": True, "dynamicUrl": "https://video.test"}), + frame( + { + "cache": True, + "FAILOVER_HOSTS": ["maxvd759.okcdn.ru"], + "MP4_480": "https://video.test/480", + "EXTERNAL": "https://m.ok.ru/video/1", + "MP4_720": "https://video.test/720", + } + ), frame({"unsafe": False, "url": "https://file.test"}), ] ) @@ -381,7 +427,8 @@ async def test_get_video_and_file_by_id_parse_request_models() -> None: file = await app.api.messages.get_file_by_id(100, "10", 30) assert video is not None - assert video.url == "https://video.test" + assert video.url == "https://video.test/720" + assert video.external == "https://m.ok.ru/video/1" assert file is not None assert file.url == "https://file.test" assert [call.opcode for call in app.calls] == [ @@ -400,6 +447,17 @@ async def test_get_video_and_file_by_id_parse_request_models() -> None: } +def test_video_request_supports_legacy_and_external_only_payloads() -> None: + legacy = VideoRequest.model_validate( + {"cache": True, "dynamicUrl": "https://video.test/legacy"} + ) + external = VideoRequest.model_validate({"cache": True, "EXTERNAL": "https://m.ok.ru/video/1"}) + + assert legacy.url == "https://video.test/legacy" + assert external.url is None + assert external.external == "https://m.ok.ru/video/1" + + def test_next_cid_is_monotonic_when_clock_does_not_move( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/api/test_upload_service.py b/tests/api/test_upload_service.py index 4441a0c..827863e 100644 --- a/tests/api/test_upload_service.py +++ b/tests/api/test_upload_service.py @@ -2,10 +2,12 @@ import pytest +from pymax.api.uploads.payloads import UploadPayload from pymax.api.uploads.service import UploadService -from pymax.files import File, Photo, Video +from pymax.files import File, Photo, Video, Voice from pymax.protocol import Opcode -from pymax.types.events import FileUploadSignal, VideoUploadSignal +from pymax.types import AttachmentType +from pymax.types.events import AudioUploadSignal, FileUploadSignal, VideoUploadSignal from tests.conftest import FakeApp, frame @@ -46,6 +48,15 @@ def post(self, **kwargs): return self.response +def test_upload_payload_uses_regular_video_defaults() -> None: + assert UploadPayload().to_payload() == { + "count": 1, + "type": 0, + "uploaderType": 0, + "profile": False, + } + + @pytest.mark.asyncio async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( monkeypatch: pytest.MonkeyPatch, @@ -70,22 +81,27 @@ async def test_upload_photo_requests_url_posts_file_and_returns_attach_payload( @pytest.mark.asyncio -async def test_upload_waiters_resolve_video_and_file_processing_signals() -> None: +async def test_upload_waiters_resolve_processing_signals() -> None: app = FakeApp() service = UploadService(app) loop = __import__("asyncio").get_running_loop() video_future = loop.create_future() file_future = loop.create_future() + voice_future = loop.create_future() service.video_upload_waiters[1] = video_future service.file_upload_waiters[2] = file_future + service.voice_upload_waiters[3] = voice_future await service.on_video_attach(VideoUploadSignal(video_id=1), None) await service.on_file_attach(FileUploadSignal(file_id=2), None) + await service.on_voice_attach(AudioUploadSignal(audio_id=3), None) assert video_future.result().video_id == 1 assert file_future.result().file_id == 2 + assert voice_future.result().audio_id == 3 assert service.video_upload_waiters == {} assert service.file_upload_waiters == {} + assert service.voice_upload_waiters == {} @pytest.mark.asyncio @@ -124,10 +140,57 @@ def resolve_processing() -> None: assert result.video_id == 10 assert result.token == "video-token" assert service.video_upload_waiters == {} - assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-4/5" + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "bytes 0-4/5" assert FakeHttpSession.posts[0]["url"] == "https://upload.test/video" +@pytest.mark.asyncio +async def test_upload_voice_posts_chunks_waits_for_processing_and_cleans_waiter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = FakeApp( + [ + frame( + { + "info": [ + { + "url": "https://upload.test/voice", + "videoId": 12, + "token": "voice-token", + } + ] + } + ) + ] + ) + service = UploadService(app) + + def resolve_processing() -> None: + service.voice_upload_waiters[12].set_result(AudioUploadSignal(audio_id=12)) + + FakeHttpSession.posts = [] + FakeHttpSession.response = FakeHttpResponse(200, on_enter=resolve_processing) + monkeypatch.setattr( + "pymax.api.uploads.service.aiohttp.ClientSession", + FakeHttpSession, + ) + + result = await service.upload_voice(Voice(raw=b"voice", name="voice.ogg")) + + assert result.type == AttachmentType.AUDIO + assert result.video_id == 12 + assert result.token == "voice-token" + assert app.calls[0].payload == { + "count": 1, + "type": 2, + "uploaderType": 1, + "profile": False, + } + assert service.voice_upload_waiters == {} + assert FakeHttpSession.posts[0]["headers"]["Content-Range"] == "0-4/5" + assert FakeHttpSession.posts[0]["url"] == "https://upload.test/voice" + + @pytest.mark.asyncio async def test_upload_file_posts_chunks_waits_for_processing_and_cleans_waiter( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/app/test_app_runtime.py b/tests/app/test_app_runtime.py index b53fe1d..3e9dc8b 100644 --- a/tests/app/test_app_runtime.py +++ b/tests/app/test_app_runtime.py @@ -13,7 +13,7 @@ from pymax.exceptions import ApiError from pymax.protocol import Command, InboundFrame, Opcode from pymax.session.models import SessionInfo -from tests.conftest import frame, make_config, profile_payload +from tests.conftest import frame, make_config, profile_payload, user_payload class RuntimeStore: @@ -123,7 +123,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), @@ -153,6 +153,139 @@ async def idle_ping_loop(self): assert store.closed is True +@pytest.mark.asyncio +async def test_app_start_completes_login2_and_uses_deferred_profile_and_contacts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "token": "login-token", + "chats": [], + "messages": {}, + "time": 777, + "config": {"hash": "login-hash"}, + "login2Flags": { + "contactEnabled": True, + "configEnabled": True, + "profileEnabled": True, + }, + } + ), + frame( + { + "profile": profile_payload(77), + "contactInfos": [user_payload(88)], + "config": {"hash": "login2-hash"}, + } + ), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.started is True + assert app.me is not None + assert app.me.contact.id == 77 + assert app.me.contact._actions is app.api.users + assert app.contacts[0] is not None + assert app.contacts[0].id == 88 + assert app.contacts[0]._actions is app.api.users + assert app.session is not None + assert app.session.token == "login-token" + assert app.session.sync.config_hash == "login2-hash" + assert [sent[0].opcode for sent in connection.sent] == [ + Opcode.SESSION_INIT, + Opcode.LOGIN, + Opcode.LOGIN2, + ] + assert connection.sent[2][0].payload == { + "needProfile": True, + "contactsSync": 777, + "configHash": "login-hash", + } + + await app.close() + + +@pytest.mark.asyncio +async def test_app_start_keeps_login_contacts_when_login2_contacts_are_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame( + { + "profile": profile_payload(77), + "contacts": [user_payload(88)], + "login2Flags": {"configEnabled": True}, + } + ), + frame({"config": {"hash": "login2-hash"}}), + ] + ) + app: App[object] = App(connection, config, StaticAuthFlow()) + + await app.start() + + assert app.contacts[0] is not None + assert app.contacts[0].id == 88 + assert connection.sent[2][0].payload["contactsSync"] == -1 + + await app.close() + + +@pytest.mark.asyncio +async def test_app_start_emits_missing_profile_error_to_root_router( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + store = RuntimeStore() + config = make_config().model_copy(update={"token": "config-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + frame({"chats": [], "messages": {}}), + ] + ) + root_router: Router[object] = Router() + app: App[object] = App(connection, config, StaticAuthFlow(), root_router) + app.dispatcher.bind_client(object()) + seen: list[Exception] = [] + + @root_router.on_error() + async def on_error(exc, ctx): + seen.append(exc) + + await app.start() + + assert len(seen) == 1 + assert isinstance(seen[0], RuntimeError) + assert str(seen[0]) == "Login response does not contain profile" + assert app.started is False + assert connection.closed is True + assert store.closed is True + + @pytest.mark.asyncio async def test_app_start_emits_login_errors_to_root_router( monkeypatch: pytest.MonkeyPatch, @@ -165,7 +298,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), InboundFrame( opcode=Opcode.LOGIN, cmd=Command.ERROR, @@ -218,7 +351,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), InboundFrame( opcode=Opcode.LOGIN, cmd=Command.ERROR, @@ -258,6 +391,74 @@ async def on_start(_client): assert store.closed is True +@pytest.mark.parametrize( + ("error", "message"), + [ + ("FAIL_LOGIN_TOKEN", "Token expired"), + ("login_failed", "FAIL_LOGIN_TOKEN"), + ], +) +@pytest.mark.asyncio +async def test_client_start_reauthenticates_after_login_token_revocation( + monkeypatch: pytest.MonkeyPatch, + error: str, + message: str, +) -> None: + async def idle_ping_loop(self): + await asyncio.Event().wait() + + monkeypatch.setattr(App, "_ping_loop", idle_ping_loop) + old_session = SessionInfo(token="revoked-token", device_id="dev", phone="+7") + store = RuntimeStore(old_session) + config = make_config().model_copy(update={"token": "revoked-token", "store": store}) + connection = RuntimeConnection( + [ + frame({"callsSeed": 123}), + InboundFrame( + opcode=Opcode.LOGIN, + cmd=Command.ERROR, + seq=1, + payload={ + "error": error, + "title": "Login failed", + "message": message, + "localizedMessage": "Session expired", + }, + ), + frame({"callsSeed": 456}), + frame( + { + "profile": profile_payload(77), + "token": "new-login-token", + "contacts": [profile_payload(77)["contact"]], + "chats": [], + "messages": {}, + } + ), + ] + ) + root_router: Router[RuntimeClient] = Router() + app: App[RuntimeClient] = App(connection, config, StaticAuthFlow(), root_router) + client = RuntimeClient(app, root_router) + app.dispatcher.bind_client(client) + errors: list[Exception] = [] + + @root_router.on_error() + async def on_error(exc, ctx): + errors.append(exc) + + await client.start() + + assert errors == [] + assert store.deleted == ["revoked-token"] + assert store.saved[0].token == "auth-token" + assert store.loaded is not None + assert store.loaded.token == "new-login-token" + assert client.extra_config.token is None + assert client._config.token is None + assert client.me is not None + + @pytest.mark.asyncio async def test_client_start_emits_disconnect_before_reraising_without_reconnect( monkeypatch: pytest.MonkeyPatch, @@ -273,7 +474,7 @@ async def fail_wait_closed() -> None: config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), @@ -418,7 +619,7 @@ async def idle_ping_loop(self): config = make_config().model_copy(update={"token": "config-token", "store": store}) connection = RuntimeConnection( [ - frame({}), + frame({"callsSeed": 123}), frame( { "profile": profile_payload(77), diff --git a/tests/conftest.py b/tests/conftest.py index de27e0f..cbf6c5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,10 @@ ) from pymax.api.users.service import UserService from pymax.config import ClientConfig, DeviceConfig +from pymax.fingerprint.fingerprint import FingerprintGenerator from pymax.protocol import Command, InboundFrame from pymax.session.models import SessionInfo +from pymax.types.domain import AttachmentType, HandshakeResponse from pymax.types.domain.sync import SyncOverrides @@ -76,6 +78,11 @@ def __init__(self) -> None: token="video-token", ) self.file_result: AttachFilePayload | None = AttachFilePayload(file_id=30) + self.voice_result: VideoAttachPayload | None = VideoAttachPayload( + type=AttachmentType.AUDIO, + video_id=40, + token="voice-token", + ) self.calls: list[tuple[str, Any]] = [] async def upload_photo(self, photo: Any) -> AttachPhotoPayload | None: @@ -90,6 +97,10 @@ async def upload_file(self, file: Any) -> AttachFilePayload | None: self.calls.append(("file", file)) return self.file_result + async def upload_voice(self, voice: Any) -> VideoAttachPayload | None: + self.calls.append(("voice", voice)) + return self.voice_result + def mobile_user_agent( device_type: DeviceType = DeviceType.ANDROID, @@ -152,7 +163,9 @@ def __init__( self.contacts: list[Any] = [] self.messages: dict[int, list[Any]] = {} self.session: SessionInfo | None = None + self.handshake_response: HandshakeResponse | None = HandshakeResponse(calls_seed=123) self.started = True + self.fingerprint_generator = FingerprintGenerator() self.api = SimpleNamespace() self.api.uploads = FakeUploads() diff --git a/tests/connection/test_connection.py b/tests/connection/test_connection.py index b176d00..6c26878 100644 --- a/tests/connection/test_connection.py +++ b/tests/connection/test_connection.py @@ -60,6 +60,12 @@ async def read(self) -> bytes: return item +class BlockingReader: + async def read(self) -> bytes: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + @pytest.mark.asyncio async def test_pending_requests_resolve_reject_discard_and_cancel() -> None: pending = PendingRequests() @@ -185,6 +191,43 @@ async def on_event(event: InboundFrame) -> None: assert [event.opcode for event in events] == [42] +@pytest.mark.asyncio +async def test_connection_wait_closed_returns_after_explicit_close() -> None: + manager = ConnectionManager( + reader=BlockingReader(), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + await manager.open() + waiter = asyncio.create_task(manager.wait_closed()) + await asyncio.sleep(0) + + await manager.close() + await waiter + + assert manager.is_open is False + + +@pytest.mark.asyncio +async def test_connection_wait_closed_propagates_external_cancellation() -> None: + manager = ConnectionManager( + reader=BlockingReader(), + transport=FakeTransport(), + protocol=FakeProtocol(), + ) + + await manager.open() + waiter = asyncio.create_task(manager.wait_closed()) + await asyncio.sleep(0) + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + + await manager.close() + + @pytest.mark.asyncio async def test_send_requires_open_connection() -> None: manager = ConnectionManager( diff --git a/tests/connection/test_readers_and_transports.py b/tests/connection/test_readers_and_transports.py index 62153d3..6f558a0 100644 --- a/tests/connection/test_readers_and_transports.py +++ b/tests/connection/test_readers_and_transports.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio + import pytest from pymax.connection.readers.tcp import TCPReader @@ -62,6 +64,7 @@ class FakeStreamWriter: def __init__(self) -> None: self.writes: list[bytes] = [] self.closed = False + self.transport = FakeStreamTransport() def write(self, data: bytes) -> None: self.writes.append(data) @@ -76,6 +79,14 @@ async def wait_closed(self) -> None: return None +class FakeStreamTransport: + def __init__(self) -> None: + self.aborted = False + + def abort(self) -> None: + self.aborted = True + + @pytest.mark.asyncio async def test_tcp_transport_connect_send_recv_and_close( monkeypatch: pytest.MonkeyPatch, @@ -100,6 +111,23 @@ async def open_connection(*args, **kwargs): assert writer.closed is True +@pytest.mark.asyncio +async def test_tcp_transport_ignores_tls_close_error() -> None: + writer = FakeStreamWriter() + + async def wait_closed() -> None: + raise OSError("SSL shutdown failed") + + writer.wait_closed = wait_closed + transport = TCPTransport("example.test", 443, proxy=None, use_ssl=True) + transport._writer = writer + + await transport.close() + + assert writer.closed is True + assert writer.transport.aborted is True + + @pytest.mark.asyncio async def test_tcp_transport_proxy_ssl_passes_server_hostname( monkeypatch: pytest.MonkeyPatch, @@ -150,11 +178,13 @@ def __init__(self) -> None: self.close_code = None self.sent: list[bytes | str] = [] self.closed = False + self.recv_decode: bool | None = None async def send(self, data: bytes | str) -> None: self.sent.append(data) async def recv(self, decode=True): + self.recv_decode = decode return "incoming" async def close(self) -> None: @@ -184,4 +214,5 @@ async def connect(*args, **kwargs): assert ws.sent == ["hello"] assert incoming == "incoming" + assert ws.recv_decode is False assert transport.connected is False diff --git a/tests/dispatch/test_dispatcher.py b/tests/dispatch/test_dispatcher.py index bbf8a37..c340692 100644 --- a/tests/dispatch/test_dispatcher.py +++ b/tests/dispatch/test_dispatcher.py @@ -72,6 +72,10 @@ async def on_delete(event, _client): async def on_file(signal, _client): seen.append(("file", signal.file_id, None)) + @dispatcher.on_internal(EventType.VOICE_READY) + async def on_voice(signal, _client): + seen.append(("voice", signal.audio_id, None)) + await dispatcher.dispatch( frame( { @@ -97,11 +101,15 @@ async def on_file(signal, _client): await dispatcher.dispatch( frame({"fileId": 99}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) ) + await dispatcher.dispatch( + frame({"audioId": 100}, opcode=Opcode.NOTIF_ATTACH, cmd=Command.REQUEST) + ) assert seen == [ ("chat", 5, True), ("delete", (1, 2), None), ("file", 99, None), + ("voice", 100, None), ] diff --git a/tests/domain/test_login_models.py b/tests/domain/test_login_models.py new file mode 100644 index 0000000..70306ca --- /dev/null +++ b/tests/domain/test_login_models.py @@ -0,0 +1,63 @@ +from pymax.types.domain import Login2Response, LoginResponse +from pymax.types.domain.sync import SyncState +from tests.conftest import profile_payload, user_payload + + +def test_login_response_accepts_login2_deferred_profile() -> None: + response = LoginResponse.model_validate( + { + "chats": [], + "messages": {}, + "config": {}, + "time": 1783438624879, + "updates": 1, + "login2Flags": { + "contactEnabled": True, + "configEnabled": True, + "profileEnabled": True, + }, + } + ) + + assert response.profile is None + assert response.login2_flags is not None + assert response.login2_flags.enabled is True + assert response.login2_flags.profile_enabled is True + + +def test_login2_response_maps_contact_infos() -> None: + response = Login2Response.model_validate( + { + "profile": profile_payload(42), + "contactInfos": [user_payload(43)], + "config": {"hash": "cfg-hash"}, + } + ) + + assert response.profile is not None + assert response.profile.contact.id == 42 + assert response.contacts[0] is not None + assert response.contacts[0].id == 43 + assert response.config is not None + assert response.config.hash == "cfg-hash" + + +def test_login2_response_updates_only_config_sync_state() -> None: + response = Login2Response.model_validate({"config": {"hash": "new-hash"}}) + current = SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="old-hash", + ) + + updated = response.update_sync_state(current) + + assert updated == SyncState( + chats_sync=1, + contacts_sync=2, + drafts_sync=3, + presence_sync=4, + config_hash="new-hash", + ) diff --git a/tests/domain/test_message_models.py b/tests/domain/test_message_models.py index 8c9296e..baf57a9 100644 --- a/tests/domain/test_message_models.py +++ b/tests/domain/test_message_models.py @@ -3,6 +3,7 @@ from pymax.types.domain import ( AudioAttachment, Message, + PollAttachment, UnknownAttachment, VideoAttachment, ) @@ -88,6 +89,41 @@ def test_video_attachment_accepts_missing_duration() -> None: assert attach.video_id == 42 +def test_poll_attachment_parses_vote_details() -> None: + payload = message_payload(1, 100) + payload["attaches"] = [ + { + "_type": "POLL", + "title": "Question", + "answers": [{"text": "Answer", "answerId": 1}], + "settings": 0, + "pollId": 42, + "version": 1, + "state": { + "total": 1, + "result": [ + { + "answerId": 1, + "voteCount": 1, + "votes": [{"timestamp": 123456, "userId": 77}], + "rate": 100, + "options": 0, + } + ], + "voterPreviewIds": [77], + }, + } + ] + + message = Message.model_validate(payload) + + attach = message.attaches[0] + assert isinstance(attach, PollAttachment) + assert attach.state.result is not None + assert attach.state.result[0].votes[0].user_id == 77 + assert attach.state.result[0].votes[0].timestamp == 123456 + + def test_message_elements_accept_missing_length_and_attribute_url() -> None: payload = message_payload(1, 100) payload["elements"] = [ diff --git a/tests/files/test_files_and_formatting.py b/tests/files/test_files_and_formatting.py index 46ae036..d73a3eb 100644 --- a/tests/files/test_files_and_formatting.py +++ b/tests/files/test_files_and_formatting.py @@ -2,6 +2,7 @@ import pytest +from pymax import Voice from pymax.files import File, Photo from pymax.formatting.markdown import Formatter @@ -45,6 +46,10 @@ def test_file_and_photo_validation_errors() -> None: Photo(raw=b"not image", name="bad.txt").validate_photo() +def test_voice_derives_name_from_path() -> None: + assert Voice(path="recordings/voice.ogg").name == "voice.ogg" + + def test_photo_url_validation_ignores_query_string() -> None: photo = Photo(url="https://example.com/image.jpg?quality=95&as=32x20") diff --git a/tests/protocol/test_protocols.py b/tests/protocol/test_protocols.py index 76ae14c..645ba69 100644 --- a/tests/protocol/test_protocols.py +++ b/tests/protocol/test_protocols.py @@ -129,6 +129,32 @@ def test_msgpack_codec_uses_first_dict_when_stream_has_extra_data() -> None: assert codec.decode(encoded) == {"ok": True} +def test_msgpack_codec_decodes_wrapped_value_extension() -> None: + codec = MsgpackPayloadCodec() + expected = { + "expiresAt": 1783264954296, + "pollingInterval": 5000, + "ttl": 119992, + } + encoded = msgpack.packb( + {key: msgpack.ExtType(1, msgpack.packb(value)) for key, value in expected.items()}, + use_bin_type=True, + ) + + assert codec.decode(encoded) == expected + + +def test_tcp_payload_decoder_preserves_unknown_extensions() -> None: + codec = MsgpackPayloadCodec() + extension = msgpack.ExtType(42, b"unknown") + decoder = TcpPayloadDecoder(serializer=codec) + + decoded = decoder.decode(msgpack.packb({"extension": extension}, use_bin_type=True)) + + assert decoded["extension"] == extension + assert isinstance(decoded["extension"], msgpack.ExtType) + + def test_tcp_payload_decoder_decompresses_lz4_for_compression_factor_four() -> None: # This is a raw LZ4 block produced by the official-compatible compressor. # Its first byte is 0xF4, which MsgPack reads as -12 when decompression is diff --git a/uv.lock b/uv.lock index 840292f..a5a4395 100644 --- a/uv.lock +++ b/uv.lock @@ -1017,7 +1017,7 @@ wheels = [ [[package]] name = "maxapi-python" -version = "2.3.1" +version = "2.4.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -1031,6 +1031,11 @@ dependencies = [ { name = "zstandard" }, ] +[package.optional-dependencies] +video = [ + { name = "tinytag" }, +] + [package.dev-dependencies] dev = [ { name = "furo" }, @@ -1070,9 +1075,11 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0" }, { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.1" }, { name = "qrcode", specifier = ">=8.2" }, + { name = "tinytag", marker = "extra == 'video'", specifier = ">=2.2.1" }, { name = "websockets", specifier = ">=16.0" }, { name = "zstandard", specifier = ">=0.25.0" }, ] +provides-extras = ["video"] [package.metadata.requires-dev] dev = [ @@ -2153,6 +2160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tinytag" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214, upload-time = "2026-03-15T18:48:01.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927, upload-time = "2026-03-15T18:47:59.613Z" }, +] + [[package]] name = "tomli" version = "2.4.1"