diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..31b3f7c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,153 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install runtime + test tools + run: | + python -m pip install --upgrade pip + # Устанавливаем package (runtime deps) + pip install -e "." + # Явно ставим инструменты для тестов/линта, чтобы они были доступны в runner + pip install pytest pytest-asyncio pytest-cov pytest-timeout flake8 mypy + + - name: Lint with flake8 + run: | + flake8 src/pymax tests \ + --count \ + --select=E9,F63,F7,F82 \ + --show-source \ + --statistics + flake8 src/pymax tests \ + --count \ + --exit-zero \ + --max-complexity=10 \ + --max-line-length=79 \ + --statistics + continue-on-error: true + + - name: Type check with mypy + run: | + mypy src/pymax \ + --ignore-missing-imports \ + --no-error-summary + continue-on-error: true + + - name: Run unit tests + run: | + pytest -m "not mockserver" \ + --cov=src/pymax \ + --cov-report=xml \ + --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Archive pytest cache + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-cache-${{ matrix.python-version }} + path: .pytest_cache/ + retention-days: 5 + + integration-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install runtime + test tools (integration) + run: | + python -m pip install --upgrade pip + pip install -e "." + pip install pytest pytest-asyncio pytest-cov pytest-timeout flake8 mypy + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Start MockServer + run: | + git clone https://github.com/fresh-milkshake/gomax-prerelease.git + cd gomax-prerelease/mockserver + go mod download + go run cmd/server/main.go & + sleep 3 + + - name: Run integration tests + run: | + pytest -m mockserver -v --tb=short + continue-on-error: true + env: + MOCKSERVER_WS_URL: ws://localhost:8080/ + MOCKSERVER_HTTP_URL: http://localhost:8080 + + code-quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + quality tools + run: | + python -m pip install --upgrade pip + pip install -e "." + # black/isort/pylint используются только в этом job + pip install black isort pylint + + - name: Check code formatting with black + run: black --check src/pymax tests + continue-on-error: true + + - name: Check import sorting with isort + run: isort --check-only src/pymax tests + continue-on-error: true diff --git a/README.md b/README.md index c67520d..277e713 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,41 @@ uv add -U maxapi-python ## Быстрый старт +### Аутентификация (`device_type`) + +> [!IMPORTANT] +> Параметр `device_type` в `UserAgentPayload` **критически важен** для выбора способа авторизации: + +**Вход по номеру телефона (DESKTOP):** + +```python +from pymax import MaxClient +from pymax.payloads import UserAgentPayload + +ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13") + +client = MaxClient( + phone="+79111111111", + work_dir="cache", + headers=ua, +) +``` + +**Вход через QR-код (WEB)** — токен совместим с веб-версией Max: + +```python +from pymax import MaxClient +from pymax.payloads import UserAgentPayload + +ua = UserAgentPayload(device_type="WEB", app_version="25.12.13") + +client = MaxClient( + phone="+7911111111", + work_dir="cache", + headers=ua, +) +``` + ### Базовый пример использования ```python diff --git a/examples/test.py b/examples/test.py index 38030d6..5929cb3 100644 --- a/examples/test.py +++ b/examples/test.py @@ -3,10 +3,10 @@ from pymax import MaxClient from pymax.payloads import UserAgentPayload -ua = UserAgentPayload(device_type="WEB") +ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13") client = MaxClient( - phone="+79911111111", + phone="+79116290861", work_dir="cache", headers=ua, ) diff --git a/pyproject.toml b/pyproject.toml index fcf6b10..c6a4fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "maxapi-python" -version = "1.2.1" +version = "1.2.2" description = "Python wrapper для API мессенджера Max" readme = "README.md" requires-python = ">=3.10" @@ -36,7 +36,25 @@ where = ["src"] [tool.setuptools.package-dir] "" = "src" +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "pytest-timeout>=2.1.0", + "flake8", + "mypy", +] + [dependency-groups] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "pytest-timeout>=2.1.0", + "flake8", + "mypy", +] dev = [ "furo>=2025.9.25", "ghp-import>=2.1.0", @@ -46,6 +64,10 @@ dev = [ "pre-commit>=4.3.0", "pydocstring>=0.2.1", "sphinx>=8.1.3", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "pytest-timeout>=2.1.0", ] [tool.hatch.build.targets.wheel] @@ -74,3 +96,33 @@ profile = "black" line_length = 79 multi_line_output = 3 include_trailing_comma = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short --strict-markers" +markers = [ + "asyncio: marker for asyncio tests", + "mockserver: marker for MockServer integration tests", + "integration: marker for integration tests", + "slow: marker for slow tests", +] + +[tool.coverage.run] +source = ["src/pymax"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] diff --git a/pytest.ini b/pytest.ini index 13bc1da..4e31bfb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,26 @@ [pytest] + +asyncio_mode = auto + testpaths = tests + python_files = test_*.py python_classes = Test* python_functions = test_* -addopts = -v --tb=short \ No newline at end of file + +addopts = + -v + --tb=short + --strict-markers + -ra + --color=yes + +markers = + asyncio: асинхронные тесты + mockserver: интеграционные тесты с MockServer + integration: интеграционные тесты + slow: медленные тесты + unit: модульные тесты + skip_ci: пропустить в CI + +timeout = 30 diff --git a/redocs/source/clients.rst b/redocs/source/clients.rst index c498df8..dbba07e 100644 --- a/redocs/source/clients.rst +++ b/redocs/source/clients.rst @@ -20,6 +20,28 @@ MaxClient logger=None, # Пользовательский логгер ) +.. warning:: + + Параметр ``device_type`` в ``UserAgentPayload`` **критически важен** для выбора способа авторизации: + + **DESKTOP** — вход по номеру телефона: + + .. code-block:: python + + from pymax.payloads import UserAgentPayload + + ua = UserAgentPayload(device_type="DESKTOP", app_version="25.12.13") + client = MaxClient(phone="+79111111111", headers=ua) + + **WEB** — вход через QR-код; токен совместим с веб-версией Max: + + .. code-block:: python + + from pymax.payloads import UserAgentPayload + + ua = UserAgentPayload(device_type="WEB", app_version="25.12.13") + client = MaxClient(phone="+79111111111", headers=ua) + Основные методы: .. code-block:: python diff --git a/src/pymax/core.py b/src/pymax/core.py index 3733a1d..77791a1 100644 --- a/src/pymax/core.py +++ b/src/pymax/core.py @@ -295,7 +295,7 @@ async def start(self) -> None: if self._token is None: await self._login() - await self._sync() + await self._sync(self.user_agent) await self._post_login_tasks(sync=False) diff --git a/src/pymax/mixins/websocket.py b/src/pymax/mixins/websocket.py index 0f4465e..8fbecb4 100644 --- a/src/pymax/mixins/websocket.py +++ b/src/pymax/mixins/websocket.py @@ -50,9 +50,7 @@ def _make_message( payload=payload, ).model_dump(by_alias=True) - self.logger.debug( - "make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq - ) + self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq) return msg async def _send_interactive_ping(self) -> None: @@ -68,9 +66,7 @@ async def _send_interactive_ping(self) -> None: self.logger.warning("Interactive ping failed", exc_info=True) await asyncio.sleep(DEFAULT_PING_INTERVAL) - async def connect( - self, user_agent: UserAgentPayload | None = None - ) -> dict[str, Any] | None: + async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any] | None: """ Устанавливает соединение WebSocket с сервером и выполняет handshake. @@ -173,9 +169,7 @@ async def _handle_file_upload(self, data: dict[str, Any]) -> None: fut = self._file_upload_waiters.pop(id_, None) if fut and not fut.done(): fut.set_result(data) - self.logger.debug( - "Fulfilled file upload waiter for %s=%s", key, id_ - ) + self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_) async def _handle_message_notifications(self, data: dict) -> None: if data.get("opcode") != Opcode.NOTIF_MESSAGE.value: @@ -359,9 +353,7 @@ async def _send_and_wait( ) return data except Exception: - self.logger.exception( - "Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"] - ) + self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]) raise RuntimeError("Send and wait failed") finally: self._pending.pop(msg["seq"], None) @@ -442,7 +434,7 @@ def _get_retry_delay(self, error: Exception, retry_count: int) -> float: else: return float(2**retry_count) - async def _sync(self) -> None: + async def _sync(self, user_agent: UserAgentPayload) -> None: self.logger.info("Starting initial sync") payload = SyncPayload( @@ -453,6 +445,7 @@ async def _sync(self) -> None: presence_sync=0, drafts_sync=0, chats_count=40, + user_agent=user_agent, ).model_dump(by_alias=True) try: data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload) @@ -473,9 +466,7 @@ async def _sync(self) -> None: self.logger.exception("Error parsing chat entry") if raw_payload.get("profile", {}).get("contact"): - self.me = Me.from_dict( - raw_payload.get("profile", {}).get("contact", {}) - ) + self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {})) self.logger.info( "Sync completed: dialogs=%d chats=%d channels=%d", diff --git a/src/pymax/payloads.py b/src/pymax/payloads.py index faf8bf9..4f31f09 100644 --- a/src/pymax/payloads.py +++ b/src/pymax/payloads.py @@ -39,6 +39,20 @@ class BaseWebSocketMessage(BaseModel): payload: dict[str, Any] +class UserAgentPayload(CamelModel): + device_type: str = Field(default=DEFAULT_DEVICE_TYPE) + locale: str = Field(default=DEFAULT_LOCALE) + device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE) + os_version: str = Field(default=DEFAULT_OS_VERSION) + device_name: str = Field(default=DEFAULT_DEVICE_NAME) + header_user_agent: str = Field(default=DEFAULT_USER_AGENT) + app_version: str = Field(default=DEFAULT_APP_VERSION) + screen: str = Field(default=DEFAULT_SCREEN) + timezone: str = Field(default=DEFAULT_TIMEZONE) + client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID) + build_number: int = Field(default=DEFAULT_BUILD_NUMBER) + + class RequestCodePayload(CamelModel): phone: str type: AuthType = AuthType.START_AUTH @@ -59,6 +73,21 @@ class SyncPayload(CamelModel): presence_sync: int = 0 drafts_sync: int = 0 chats_count: int = 40 + user_agent: UserAgentPayload = Field( + default_factory=lambda: UserAgentPayload( + device_type=DEFAULT_DEVICE_TYPE, + locale=DEFAULT_LOCALE, + device_locale=DEFAULT_DEVICE_LOCALE, + os_version=DEFAULT_OS_VERSION, + device_name=DEFAULT_DEVICE_NAME, + header_user_agent=DEFAULT_USER_AGENT, + app_version=DEFAULT_APP_VERSION, + screen=DEFAULT_SCREEN, + timezone=DEFAULT_TIMEZONE, + client_session_id=DEFAULT_CLIENT_SESSION_ID, + build_number=DEFAULT_BUILD_NUMBER, + ), + ) class ReplyLink(CamelModel): @@ -276,20 +305,6 @@ class RemoveReactionPayload(CamelModel): message_id: str -class UserAgentPayload(CamelModel): - device_type: str = Field(default=DEFAULT_DEVICE_TYPE) - locale: str = Field(default=DEFAULT_LOCALE) - device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE) - os_version: str = Field(default=DEFAULT_OS_VERSION) - device_name: str = Field(default=DEFAULT_DEVICE_NAME) - header_user_agent: str = Field(default=DEFAULT_USER_AGENT) - app_version: str = Field(default=DEFAULT_APP_VERSION) - screen: str = Field(default=DEFAULT_SCREEN) - timezone: str = Field(default=DEFAULT_TIMEZONE) - client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID) - build_number: int = Field(default=DEFAULT_BUILD_NUMBER) - - class ReworkInviteLinkPayload(CamelModel): revoke_private_link: bool = True chat_id: int diff --git a/src/pymax/static/constant.py b/src/pymax/static/constant.py index aa87c30..3729827 100644 --- a/src/pymax/static/constant.py +++ b/src/pymax/static/constant.py @@ -9,7 +9,7 @@ HOST: Final[str] = "api.oneme.ru" PORT: Final[int] = 443 DEFAULT_TIMEOUT: Final[float] = 20.0 -DEFAULT_DEVICE_TYPE: Final[str] = "WEB" +DEFAULT_DEVICE_TYPE: Final[str] = "DESKTOP" DEFAULT_LOCALE: Final[str] = "ru" DEFAULT_DEVICE_LOCALE: Final[str] = "ru" DEFAULT_DEVICE_NAME: Final[str] = "Chrome"