├── redocs ├── build.sh ├── source │ ├── decorators.rst │ ├── types.rst │ ├── conf.py │ ├── index.rst │ ├── installation.rst │ ├── clients.rst │ ├── quickstart.rst │ ├── _static │ │ └── logo.svg │ ├── examples.rst │ └── guides.rst ├── Makefile └── make.bat ├── .coderabbit.yaml ├── pytest.ini ├── src └── pymax │ ├── models.py │ ├── mixins │ ├── utils.py │ ├── __init__.py │ ├── scheduler.py │ ├── telemetry.py │ ├── channel.py │ ├── self.py │ ├── user.py │ ├── auth.py │ ├── handler.py │ └── group.py │ ├── formatter.py │ ├── static │ ├── constant.py │ └── enum.py │ ├── __init__.py │ ├── formatting.py │ ├── exceptions.py │ ├── crud.py │ ├── files.py │ ├── interfaces.py │ ├── filters.py │ ├── navigation.py │ └── payloads.py ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug_report.md │ └── refactor.md ├── pull_request_template.md ├── FUNDING.yml └── workflows │ └── publish.yml ├── examples ├── reg.py ├── flt_test.py ├── large_file_upload.py ├── telegram_bridge.py └── example.py ├── LICENSE ├── .pre-commit-config.yaml ├── pyproject.toml ├── .gitignore ├── ruff.toml ├── mkdocs.yml ├── assets ├── icon.svg └── logo.svg └── README.md /redocs/build.sh: -------------------------------------------------------------------------------- 1 | uv run make html 2 | -------------------------------------------------------------------------------- /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | reviews: 2 | auto_review: 3 | enabled: true 4 | branches: 5 | include: 6 | - "*" 7 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --tb=short -------------------------------------------------------------------------------- /redocs/source/decorators.rst: -------------------------------------------------------------------------------- 1 | Decorators 2 | ========== 3 | 4 | .. autoclass:: pymax.mixins.handler.HandlerMixin 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /redocs/source/types.rst: -------------------------------------------------------------------------------- 1 | Types 2 | ===== 3 | 4 | .. automodule:: pymax.types 5 | :members: 6 | 7 | .. automodule:: pymax.static.enum 8 | :members: 9 | -------------------------------------------------------------------------------- /src/pymax/models.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | 3 | from sqlmodel import Field, SQLModel 4 | 5 | 6 | class Auth(SQLModel, table=True): 7 | token: str | None = None 8 | device_id: UUID = Field(default_factory=uuid4, primary_key=True) 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Предложить новую функциональность 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Описание** 10 | Опишите новую функцию или улучшение. 11 | 12 | **Зачем это нужно** 13 | Кратко объясните, какую проблему решает или что улучшает. 14 | 15 | **Пример использования** 16 | Как это будет использоваться в коде: 17 | 18 | ```python 19 | # пример 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Сообщить о баге 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Описание** 11 | Что пошло не так? 12 | 13 | **Шаги для воспроизведения** 14 | 1. 15 | 2. 16 | 3. 17 | 18 | **Ожидаемый результат** 19 | Что должно было произойти 20 | 21 | **Фактический результат** 22 | Что произошло на самом деле 23 | 24 | **Дополнительно** 25 | Логи, скриншоты, версия Python/API 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Описание 2 | Кратко, что делает этот PR. Например, добавляет новый метод, исправляет баг, улучшает документацию. 3 | 4 | ## Тип изменений 5 | - [ ] Исправление бага 6 | - [ ] Новая функциональность 7 | - [ ] Улучшение документации 8 | - [ ] Рефакторинг 9 | 10 | ## Связанные задачи / Issue 11 | Ссылка на issue, если есть: # 12 | 13 | ## Тестирование 14 | Покажите пример кода, который проверяет изменения: 15 | 16 | ```python 17 | import pymax 18 | 19 | # пример использования нового функционала 20 | -------------------------------------------------------------------------------- /redocs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor 3 | about: Предложение по улучшению кода без изменения функционала 4 | title: '' 5 | labels: refactor 6 | assignees: '' 7 | --- 8 | 9 | ## Описание 10 | Опишите часть кода, которую нужно улучшить или рефакторить, без изменения функциональности. 11 | 12 | ## Причина 13 | Почему требуется рефакторинг? Например: 14 | - Улучшение читаемости кода 15 | - Повышение производительности 16 | - Упрощение поддержки 17 | 18 | ## Предлагаемые изменения 19 | Опишите, как вы планируете изменить код, чтобы улучшить его структуру или качество. 20 | 21 | ## Влияние 22 | Какие модули/части библиотеки могут быть затронуты? Нужно убедиться, что функционал остаётся прежним. 23 | 24 | ## Дополнительно 25 | Любая дополнительная информация, ссылки на документацию, примеры кода. 26 | -------------------------------------------------------------------------------- /examples/reg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pymax import MaxClient, Message 4 | from pymax.filters import Filters 5 | 6 | client = MaxClient( 7 | phone="+1234567890", 8 | work_dir="cache", 9 | ) 10 | 11 | 12 | @client.on_message(Filters.chat(0)) 13 | async def on_message(msg: Message): 14 | print(f"[{msg.sender}] {msg.text}") 15 | await client.send_message(chat_id=msg.chat_id, text="Привет!") 16 | await client.add_reaction( 17 | chat_id=msg.chat_id, message_id=str(msg.id), reaction="👍" 18 | ) 19 | 20 | 21 | @client.on_start 22 | async def on_start(): 23 | print(f"Клиент запущен. Ваш ID: {client.me.id}") 24 | history = await client.fetch_history(chat_id=0) 25 | for m in history: 26 | print(f"- {m.text}") 27 | 28 | 29 | async def main(): 30 | await client.start() 31 | 32 | 33 | if __name__ == "__main__": 34 | asyncio.run(main()) 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: [''] 16 | -------------------------------------------------------------------------------- /redocs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /src/pymax/mixins/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, NoReturn 2 | 3 | from pymax.exceptions import Error, RateLimitError 4 | 5 | 6 | class MixinsUtils: 7 | @staticmethod 8 | def handle_error(data: dict[str, Any]) -> NoReturn: 9 | error = data.get("payload", {}).get("error") 10 | localized_message = data.get("payload", {}).get("localizedMessage") 11 | title = data.get("payload", {}).get("title") 12 | message = data.get("payload", {}).get("message") 13 | 14 | if error == "too.many.requests": # TODO: вынести в статик 15 | raise RateLimitError( 16 | error=error, 17 | message=message, 18 | title=title, 19 | localized_message=localized_message, 20 | ) 21 | 22 | raise Error( 23 | error=error, 24 | message=message, 25 | title=title, 26 | localized_message=localized_message, 27 | ) 28 | -------------------------------------------------------------------------------- /src/pymax/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import AuthMixin 2 | from .channel import ChannelMixin 3 | from .group import GroupMixin 4 | from .handler import HandlerMixin 5 | from .message import MessageMixin 6 | from .scheduler import SchedulerMixin 7 | from .self import SelfMixin 8 | from .socket import SocketMixin 9 | from .telemetry import TelemetryMixin 10 | from .user import UserMixin 11 | from .websocket import WebSocketMixin 12 | 13 | 14 | class ApiMixin( 15 | AuthMixin, 16 | HandlerMixin, 17 | UserMixin, 18 | ChannelMixin, 19 | SelfMixin, 20 | MessageMixin, 21 | TelemetryMixin, 22 | GroupMixin, 23 | SchedulerMixin, 24 | ): 25 | pass 26 | 27 | 28 | __all__ = [ 29 | "ApiMixin", 30 | "AuthMixin", 31 | "ChannelMixin", 32 | "HandlerMixin", 33 | "MessageMixin", 34 | "SchedulerMixin", 35 | "SelfMixin", 36 | "SocketMixin", 37 | "TelemetryMixin", 38 | "UserMixin", 39 | "WebSocketMixin", 40 | ] 41 | -------------------------------------------------------------------------------- /src/pymax/formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import ClassVar 3 | 4 | 5 | class ColoredFormatter(logging.Formatter): 6 | COLORS: ClassVar = { 7 | "DEBUG": "\033[37m", 8 | "INFO": "\033[36m", 9 | "WARNING": "\033[33m", 10 | "ERROR": "\033[31m", 11 | "CRITICAL": "\033[41m", 12 | } 13 | 14 | RESET = "\033[0m" 15 | DIM = "\033[2m" 16 | BOLD = "\033[1m" 17 | 18 | def format(self, record: logging.LogRecord) -> str: 19 | level_color = self.COLORS.get(record.levelname, self.RESET) 20 | time_color = self.DIM 21 | name_color = "\033[35m" 22 | message_color = self.RESET 23 | 24 | log = ( 25 | f"{time_color}{self.formatTime(record, '%H:%M:%S')}{self.RESET} " 26 | f"[{level_color}{record.levelname}{self.RESET}] " 27 | f"{name_color}{record.name}{self.RESET}: " 28 | f"{message_color}{record.getMessage()}{self.RESET}" 29 | ) 30 | 31 | return log 32 | -------------------------------------------------------------------------------- /src/pymax/mixins/scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | from collections.abc import Awaitable, Callable 4 | from typing import Any 5 | 6 | from pymax.interfaces import ClientProtocol 7 | 8 | 9 | class SchedulerMixin(ClientProtocol): 10 | async def _run_periodic( 11 | self, func: Callable[[], Any | Awaitable[Any]], interval: float 12 | ) -> None: 13 | while True: 14 | try: 15 | result = func() 16 | if asyncio.iscoroutine(result): 17 | await result 18 | except Exception as e: 19 | tb = traceback.format_exc() 20 | self.logger.error(f"Error in scheduled task {func}: {e}") 21 | raise 22 | await asyncio.sleep(interval) 23 | 24 | async def _start_scheduled_tasks(self) -> None: 25 | for func, interval in self._scheduled_tasks: 26 | task = asyncio.create_task(self._run_periodic(func, interval)) 27 | self._background_tasks.add(task) 28 | task.add_done_callback(self._background_tasks.discard) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ink-developer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pymax/static/constant.py: -------------------------------------------------------------------------------- 1 | from re import Pattern, compile 2 | from typing import Final 3 | 4 | from websockets.typing import Origin 5 | 6 | PHONE_REGEX: Final[Pattern[str]] = compile(r"^\+?\d{10,15}$") 7 | WEBSOCKET_URI: Final[str] = "wss://ws-api.oneme.ru/websocket" 8 | WEBSOCKET_ORIGIN: Final[Origin] = Origin("https://web.max.ru") 9 | HOST: Final[str] = "api.oneme.ru" 10 | PORT: Final[int] = 443 11 | DEFAULT_TIMEOUT: Final[float] = 20.0 12 | DEFAULT_DEVICE_TYPE: Final[str] = "WEB" 13 | DEFAULT_LOCALE: Final[str] = "ru" 14 | DEFAULT_DEVICE_LOCALE: Final[str] = "ru" 15 | DEFAULT_DEVICE_NAME: Final[str] = "Chrome" 16 | DEFAULT_APP_VERSION: Final[str] = "25.12.1" 17 | DEFAULT_SCREEN: Final[str] = "1080x1920 1.0x" 18 | DEFAULT_OS_VERSION: Final[str] = "Linux" 19 | DEFAULT_USER_AGENT: Final[str] = ( 20 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" 21 | ) 22 | DEFAULT_BUILD_NUMBER: Final[int] = 0x97CB 23 | DEFAULT_CLIENT_SESSION_ID: Final[int] = 14 24 | DEFAULT_TIMEZONE: Final[str] = "Europe/Moscow" 25 | DEFAULT_CHAT_MEMBERS_LIMIT: Final[int] = 50 26 | DEFAULT_MARKER_VALUE: Final[int] = 0 27 | DEFAULT_PING_INTERVAL: Final[float] = 30.0 28 | RECV_LOOP_BACKOFF_DELAY: Final[float] = 0.5 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-docstring-first 7 | - id: check-executables-have-shebangs 8 | - id: check-shebang-scripts-are-executable 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: fix-byte-order-marker 12 | - id: mixed-line-ending 13 | - id: name-tests-test 14 | - id: trailing-whitespace 15 | - repo: https://github.com/psf/black 16 | rev: 25.9.0 17 | hooks: 18 | - id: black 19 | - repo: https://github.com/csachs/pyproject-flake8 20 | rev: v7.0.0 21 | hooks: 22 | - id: pyproject-flake8 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.18.2 25 | hooks: 26 | - id: mypy 27 | additional_dependencies: 28 | - "aiohttp" 29 | - "sqlalchemy" 30 | - "sqlmodel" 31 | - "types-aiofiles" 32 | - "types-requests" 33 | exclude: "^examples/" 34 | - repo: https://github.com/pycqa/isort 35 | rev: 7.0.0 36 | hooks: 37 | - id: isort 38 | - repo: https://github.com/PyCQA/bandit 39 | rev: 1.8.6 40 | hooks: 41 | - id: bandit 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release-build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up uv 19 | uses: astral-sh/setup-uv@v4 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Build release distributions 24 | run: | 25 | uv build 26 | 27 | - name: Upload distributions 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: release-dists 31 | path: dist/ 32 | 33 | pypi-publish: 34 | runs-on: ubuntu-latest 35 | needs: 36 | - release-build 37 | 38 | steps: 39 | - name: Retrieve release distributions 40 | uses: actions/download-artifact@v4 41 | with: 42 | name: release-dists 43 | path: dist/ 44 | 45 | - name: Set up uv 46 | uses: astral-sh/setup-uv@v4 47 | with: 48 | python-version: "3.10" 49 | 50 | - name: Publish release distributions to PyPI 51 | env: 52 | TOKEN: ${{ secrets.PYPI_API_TOKEN }} 53 | run: | 54 | uv publish -t $TOKEN 55 | -------------------------------------------------------------------------------- /examples/flt_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import pymax 5 | import pymax.static 6 | from pymax import MaxClient 7 | from pymax.filters import Filters 8 | from pymax.payloads import UserAgentPayload 9 | from pymax.static.enum import Opcode 10 | 11 | phone = "+7903223423" 12 | headers = UserAgentPayload(device_type="WEB") 13 | 14 | client = MaxClient( 15 | phone=phone, 16 | work_dir="cache", 17 | reconnect=False, 18 | logger=None, 19 | headers=headers, 20 | ) 21 | client.logger.setLevel(logging.DEBUG) 22 | 23 | 24 | @client.task(seconds=10) 25 | async def periodic_task() -> None: 26 | client.logger.info("Periodic task executed") 27 | 28 | 29 | @client.on_message(Filters.text("test") & ~Filters.chat(0)) 30 | async def handle_message(message: pymax.Message) -> None: 31 | print(f"New message from {message.sender}: {message.text}") 32 | 33 | 34 | @client.on_start 35 | async def on_start(): 36 | print("Client started") 37 | data = await client._send_and_wait( 38 | opcode=Opcode.FILE_UPLOAD, 39 | payload={"count": 1}, 40 | ) 41 | print("File upload response:", data) 42 | # opcode=pymax.static.enum.Opcode.CHATS_LIST, 43 | # payload={ 44 | # "marker": 1765721869777, 45 | # }, 46 | # ) 47 | 48 | # print("Chats list:", data) 49 | 50 | 51 | asyncio.run(client.start()) 52 | -------------------------------------------------------------------------------- /redocs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | import os 3 | import sys 4 | 5 | # -- Project information ----------------------------------------------------- 6 | project = "PyMax" 7 | author = "ink-developer" 8 | copyright = "2025, ink-developer" 9 | release = "1.1.21" 10 | 11 | # -- Path setup --------------------------------------------------------------- 12 | sys.path.insert(0, os.path.abspath("../../src")) 13 | 14 | # -- General configuration --------------------------------------------------- 15 | extensions = [ 16 | "sphinx.ext.autodoc", # Автодокументация классов/функций 17 | "sphinx.ext.napoleon", # Поддержка Google/NumPy docstrings 18 | "sphinx.ext.viewcode", # Ссылка на исходный код 19 | ] 20 | 21 | templates_path = ["_templates"] 22 | exclude_patterns = [ 23 | "_build", 24 | "Thumbs.db", 25 | ".DS_Store", 26 | ] 27 | 28 | language = "python" 29 | 30 | 31 | autodoc_default_options = { 32 | "members": True, 33 | "inherited-members": True, 34 | "undoc-members": False, 35 | "show-inheritance": False, 36 | } 37 | 38 | autodoc_typehints = "description" 39 | 40 | html_theme = "furo" 41 | html_static_path = ["_static"] 42 | 43 | pygments_style = "friendly" 44 | pygments_dark_style = "monokai" 45 | 46 | html_theme_options = { 47 | "sidebar_hide_name": False, 48 | "navigation_with_keys": True, 49 | } 50 | -------------------------------------------------------------------------------- /examples/large_file_upload.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | 5 | from pymax import MaxClient 6 | from pymax.files import File, Video 7 | 8 | client = MaxClient(phone="+1234567890", work_dir="cache", reconnect=False) 9 | client.logger.setLevel(logging.INFO) 10 | 11 | 12 | def create_big_file(file_path: Path, size_in_mb: int) -> None: 13 | with open(file_path, "wb") as f: 14 | f.seek(size_in_mb * 1024 * 1024 - 1) 15 | f.write(b"\0") 16 | 17 | 18 | @client.on_start 19 | async def upload_large_file_example(): 20 | await asyncio.sleep(2) 21 | 22 | file_path = Path("tests2/large_file.dat") 23 | 24 | if not file_path.exists(): 25 | create_big_file(file_path, size_in_mb=300) 26 | file_size = file_path.stat().st_size 27 | client.logger.info(f"File size: {file_size / (1024 * 1024):.2f} MB") 28 | 29 | file = File(path=str(file_path)) 30 | chat_id = 0 31 | 32 | client.logger.info("Starting file upload...") 33 | 34 | try: 35 | await client.send_message( 36 | chat_id=chat_id, 37 | text="📎 Вот большой файл", 38 | attachment=file, 39 | ) 40 | client.logger.info("File uploaded successfully!") 41 | 42 | except OSError as e: 43 | if "malloc failure" in str(e): 44 | client.logger.error("Memory error - file too large for current memory") 45 | client.logger.info("Recommendation: Upload smaller files or free up memory") 46 | else: 47 | raise 48 | 49 | 50 | if __name__ == "__main__": 51 | asyncio.run(client.start()) 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "maxapi-python" 3 | version = "1.1.21" 4 | description = "Python wrapper для API мессенджера Max" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [{ name = "ink", email = "mail@gmail.com" }] 8 | license = "MIT" 9 | keywords = ["max", "messenger", "api", "wrapper", "websocket"] 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "Operating System :: OS Independent", 13 | ] 14 | dependencies = [ 15 | "sqlmodel>=0.0.24", 16 | "websockets>=15.0", 17 | "msgpack>=1.1.1", 18 | "lz4>=4.4.4", 19 | "aiohttp>=3.12.15", 20 | "aiofiles>=24.1.0", 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/ink-developer/PyMax" 25 | Repository = "https://github.com/ink-developer/PyMax" 26 | Issues = "https://github.com/ink-developer/PyMax/issues" 27 | 28 | [build-system] 29 | requires = ["hatchling"] 30 | build-backend = "hatchling.build" 31 | 32 | [tool.setuptools.packages.find] 33 | where = ["src"] 34 | 35 | [tool.setuptools.package-dir] 36 | "" = "src" 37 | 38 | [dependency-groups] 39 | dev = [ 40 | "furo>=2025.9.25", 41 | "ghp-import>=2.1.0", 42 | "mkdocs>=1.6.1", 43 | "mkdocs-material>=9.6.18", 44 | "mkdocstrings[python]>=0.30.0", 45 | "pre-commit>=4.3.0", 46 | "pydocstring>=0.2.1", 47 | "sphinx>=8.1.3", 48 | ] 49 | 50 | [tool.hatch.build.targets.wheel] 51 | packages = ["src/pymax"] 52 | 53 | [tool.pyright] 54 | venv = ".venv" 55 | venvPath = "." 56 | 57 | [tool.black] 58 | line-length = 79 59 | target-version = ['py313'] 60 | 61 | [tool.flake8] 62 | max-line-length = 79 63 | max-complexity = 10 64 | 65 | [tool.mypy] 66 | python_version = "3.13" 67 | warn_return_any = true 68 | warn_unused_configs = true 69 | plugins = ["sqlalchemy.ext.mypy.plugin", "pydantic.mypy"] 70 | 71 | [tool.isort] 72 | profile = "black" 73 | line_length = 79 74 | multi_line_output = 3 75 | include_trailing_comma = true 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | *.egg 27 | 28 | # PyInstaller 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | *.py,cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Jupyter 51 | .ipynb_checkpoints 52 | 53 | # pyenv 54 | .python-version 55 | 56 | # pipenv 57 | Pipfile.lock 58 | 59 | # Poetry / other lock files (optional) 60 | poetry.lock 61 | uv.lock 62 | 63 | # Virtual environments 64 | venv/ 65 | ENV/ 66 | env/ 67 | .env/ 68 | .venv/ 69 | venv.bak/ 70 | venv*/ 71 | 72 | # Spyder project settings 73 | .spyproject 74 | 75 | # Rope 76 | .ropeproject 77 | 78 | # mkdocs 79 | /site 80 | 81 | # mypy 82 | .mypy_cache/ 83 | 84 | # ruff 85 | .ruff_cache/ 86 | 87 | # IDEs and editors 88 | .vscode/ 89 | .idea/ 90 | *.sublime-project 91 | *.sublime-workspace 92 | 93 | # OS files 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Logs and databases 98 | *.log 99 | *.sql 100 | *.sqlite 101 | *.sqlite3 102 | *.db 103 | session.db 104 | *.bak 105 | *.swp 106 | *.bin 107 | 108 | # Environment / secrets 109 | .env 110 | .env.* 111 | 112 | # Project-specific artifacts 113 | max_client.egg-info/ 114 | cache/ 115 | 116 | # Keep lockfiles and important configs tracked? If you want to track specific lockfiles, 117 | # remove them from this .gitignore (for example: remove poetry.lock or uv.lock). 118 | tests2/ 119 | tests/ 120 | 121 | 122 | # Bad dev's requirements 123 | requirements.txt 124 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 88 33 | indent-width = 4 34 | 35 | target-version = "py310" 36 | 37 | [lint] 38 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 39 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 40 | # McCabe complexity (`C901`) by default. 41 | select = ["E4", "E7", "E9", "F"] 42 | ignore = [] 43 | 44 | # Allow fix for all enabled rules (when `--fix`) is provided. 45 | fixable = ["ALL"] 46 | unfixable = [] 47 | 48 | # Allow unused variables when underscore-prefixed. 49 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 50 | 51 | [format] 52 | # Like Black, use double quotes for strings. 53 | quote-style = "double" 54 | 55 | # Like Black, indent with spaces, rather than tabs. 56 | indent-style = "space" 57 | 58 | # Like Black, respect magic trailing commas. 59 | skip-magic-trailing-comma = false 60 | 61 | # Like Black, automatically detect the appropriate line ending. 62 | line-ending = "auto" 63 | 64 | # Enable auto-formatting of code examples in docstrings. Markdown, 65 | # reStructuredText code/literal blocks and doctests are all supported. 66 | # 67 | # This is currently disabled by default, but it is planned for this 68 | # to be opt-out in the future. 69 | docstring-code-format = false 70 | 71 | # Set the line length limit used when formatting code snippets in 72 | # docstrings. 73 | # 74 | # This only has an effect when the `docstring-code-format` setting is 75 | # enabled. 76 | docstring-code-line-length = "dynamic" 77 | -------------------------------------------------------------------------------- /src/pymax/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python wrapper для API мессенджера Max 3 | """ 4 | 5 | from .core import ( 6 | MaxClient, 7 | SocketMaxClient, 8 | ) 9 | from .exceptions import ( 10 | InvalidPhoneError, 11 | LoginError, 12 | ResponseError, 13 | ResponseStructureError, 14 | SocketNotConnectedError, 15 | SocketSendError, 16 | WebSocketNotConnectedError, 17 | ) 18 | from .files import ( 19 | File, 20 | Photo, 21 | ) 22 | from .static.enum import ( 23 | AccessType, 24 | AttachType, 25 | AuthType, 26 | ChatType, 27 | ContactAction, 28 | DeviceType, 29 | ElementType, 30 | FormattingType, 31 | MarkupType, 32 | MessageStatus, 33 | MessageType, 34 | Opcode, 35 | ) 36 | from .types import ( 37 | Channel, 38 | Chat, 39 | Contact, 40 | ControlAttach, 41 | Dialog, 42 | Element, 43 | FileAttach, 44 | FileRequest, 45 | Me, 46 | Member, 47 | Message, 48 | MessageLink, 49 | Name, 50 | Names, 51 | PhotoAttach, 52 | Presence, 53 | ReactionCounter, 54 | ReactionInfo, 55 | Session, 56 | User, 57 | VideoAttach, 58 | VideoRequest, 59 | ) 60 | 61 | __author__ = "ink-developer" 62 | 63 | __all__ = [ 64 | # Перечисления и константы 65 | "AccessType", 66 | "AttachType", 67 | "AuthType", 68 | # Типы данных 69 | "Channel", 70 | "Chat", 71 | "ChatType", 72 | "Contact", 73 | "ContactAction", 74 | "ControlAttach", 75 | "DeviceType", 76 | "Dialog", 77 | "Element", 78 | "ElementType", 79 | "File", 80 | "FileAttach", 81 | "FileRequest", 82 | "FormattingType", 83 | # Исключения 84 | "InvalidPhoneError", 85 | "LoginError", 86 | "MarkupType", 87 | # Клиент 88 | "MaxClient", 89 | "Me", 90 | "Member", 91 | "Message", 92 | "MessageLink", 93 | "MessageStatus", 94 | "MessageType", 95 | "Name", 96 | "Names", 97 | "Opcode", 98 | "Photo", 99 | "PhotoAttach", 100 | "Presence", 101 | "ReactionCounter", 102 | "ReactionInfo", 103 | "ResponseError", 104 | "ResponseStructureError", 105 | "Session", 106 | "SocketMaxClient", 107 | "SocketNotConnectedError", 108 | "SocketSendError", 109 | "User", 110 | "VideoAttach", 111 | "VideoRequest", 112 | "WebSocketNotConnectedError", 113 | ] 114 | -------------------------------------------------------------------------------- /src/pymax/formatting.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pymax.static.enum import FormattingType 4 | from pymax.types import Element 5 | 6 | 7 | class Formatting: 8 | MARKUP_BLOCK_PATTERN = re.compile( 9 | ( 10 | r"\*\*(?P.+?)\*\*|" 11 | r"\*(?P.+?)\*|" 12 | r"__(?P.+?)__|" 13 | r"~~(?P.+?)~~" 14 | ), 15 | re.DOTALL, 16 | ) 17 | 18 | @staticmethod 19 | def get_elements_from_markdown(text: str) -> tuple[list[Element], str]: 20 | text = text.strip("\n") 21 | elements: list[Element] = [] 22 | clean_parts: list[str] = [] 23 | current_pos = 0 24 | 25 | last_end = 0 26 | for match in Formatting.MARKUP_BLOCK_PATTERN.finditer(text): 27 | between = text[last_end : match.start()] 28 | if between: 29 | clean_parts.append(between) 30 | current_pos += len(between) 31 | 32 | inner_text = None 33 | fmt_type = None 34 | if match.group("strong") is not None: 35 | inner_text = match.group("strong") 36 | fmt_type = FormattingType.STRONG 37 | elif match.group("italic") is not None: 38 | inner_text = match.group("italic") 39 | fmt_type = FormattingType.EMPHASIZED 40 | elif match.group("underline") is not None: 41 | inner_text = match.group("underline") 42 | fmt_type = FormattingType.UNDERLINE 43 | elif match.group("strike") is not None: 44 | inner_text = match.group("strike") 45 | fmt_type = FormattingType.STRIKETHROUGH 46 | 47 | if inner_text is not None and fmt_type is not None: 48 | next_pos = match.end() 49 | has_newline = ( 50 | next_pos < len(text) and text[next_pos] == "\n" 51 | ) or (next_pos == len(text)) 52 | 53 | length = len(inner_text) + (1 if has_newline else 0) 54 | elements.append( 55 | Element(type=fmt_type, from_=current_pos, length=length) 56 | ) 57 | 58 | clean_parts.append(inner_text) 59 | if has_newline: 60 | clean_parts.append("\n") 61 | 62 | current_pos += length 63 | 64 | if next_pos < len(text) and text[next_pos] == "\n": 65 | last_end = match.end() + 1 66 | else: 67 | last_end = match.end() 68 | else: 69 | last_end = match.end() 70 | 71 | tail = text[last_end:] 72 | if tail: 73 | clean_parts.append(tail) 74 | 75 | clean_text = "".join(clean_parts) 76 | return elements, clean_text 77 | -------------------------------------------------------------------------------- /redocs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 |
4 | 5 | .. image:: _static/logo.svg 6 | :align: center 7 | :width: 320px 8 | PyMax 9 | ===== 10 | 11 | .. raw:: html 12 | 13 |

14 | Python 3.10+ 15 | License: MIT 16 | Ruff 17 | Packaging 18 |

19 | 20 | .. rubric:: Кратко 21 | 22 | **pymax** — асинхронная Python-библиотека для работы с внутренним API мессенджера Max. 23 | Упрощает отправку сообщений, управление чатами/каналами и работу с историей через WebSocket. 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :titlesonly: 28 | :caption: Содержание 29 | 30 | installation 31 | quickstart 32 | clients 33 | types 34 | decorators 35 | examples 36 | guides 37 | 38 | .. rubric:: Особенности 39 | 40 | - Вход по номеру телефона 41 | - Отправка / редактирование / удаление сообщений 42 | - Управление чатами, каналами и диалогами 43 | - Получение истории сообщений 44 | 45 | --- 46 | 47 | Disclaimer 48 | ---------- 49 | 50 | .. warning:: 51 | 52 | Это **неофициальная** библиотека для работы с внутренним API Max. 53 | Использование может **нарушать условия предоставления услуг**. 54 | Вы используете её на свой страх и риск — разработчики не несут ответственности 55 | за блокировку аккаунтов, потерю данных или юридические последствия. 56 | 57 | --- 58 | 59 | Установка 60 | --------- 61 | 62 | Требуется Python 3.10+. 63 | 64 | .. code-block:: bash 65 | 66 | pip install -U maxapi-python 67 | 68 | или через uv: 69 | 70 | .. code-block:: bash 71 | 72 | uv add -U maxapi-python 73 | 74 | --- 75 | 76 | Быстрый старт 77 | ------------- 78 | 79 | Небольшой рабочий пример и описание в `quickstart`. 80 | 81 | Документация 82 | ------------ 83 | 84 | - `GitHub Pages `_ 85 | - `DeepWiki `_ 86 | 87 | Лицензия 88 | -------- 89 | 90 | Проект распространяется под MIT (см. LICENSE). 91 | 92 | Новости 93 | ------- 94 | 95 | - `Telegram `_ 96 | 97 | Star History 98 | ------------ 99 | 100 | .. image:: https://api.star-history.com/svg?repos=ink-developer/PyMax&type=date&legend=top-left 101 | 102 | Авторы 103 | ------ 104 | 105 | - `ink `_ — главный разработчик 106 | - `noxzion `_ — оригинальный автор 107 | 108 | Контрибьюторы 109 | ------------- 110 | 111 | .. image:: https://contrib.rocks/image?repo=ink-developer/PyMax 112 | :alt: Contributors 113 | -------------------------------------------------------------------------------- /src/pymax/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidPhoneError(Exception): 2 | """ 3 | Исключение, вызываемое при неверном формате номера телефона. 4 | 5 | Args: 6 | phone (str): Некорректный номер телефона. 7 | """ 8 | 9 | def __init__(self, phone: str) -> None: 10 | super().__init__(f"Invalid phone number format: {phone}") 11 | 12 | 13 | class WebSocketNotConnectedError(Exception): 14 | """ 15 | Исключение, вызываемое при попытке обращения к WebSocket, 16 | если соединение не установлено. 17 | """ 18 | 19 | def __init__(self) -> None: 20 | super().__init__("WebSocket is not connected") 21 | 22 | 23 | class SocketNotConnectedError(Exception): 24 | """ 25 | Исключение, вызываемое при попытке обращения к сокету, 26 | если соединение не установлено. 27 | """ 28 | 29 | def __init__(self) -> None: 30 | super().__init__("Socket is not connected") 31 | 32 | 33 | class SocketSendError(Exception): 34 | """ 35 | Исключение, вызываемое при ошибке отправки данных через сокет. 36 | """ 37 | 38 | def __init__(self) -> None: 39 | super().__init__("Send and wait failed (socket)") 40 | 41 | 42 | class ResponseError(Exception): 43 | """ 44 | Исключение, вызываемое при ошибке в ответе от сервера. 45 | """ 46 | 47 | def __init__(self, message: str) -> None: 48 | super().__init__(f"Response error: {message}") 49 | 50 | 51 | class ResponseStructureError(Exception): 52 | """ 53 | Исключение, вызываемое при неверной структуре ответа от сервера. 54 | """ 55 | 56 | def __init__(self, message: str) -> None: 57 | super().__init__(f"Response structure error: {message}") 58 | 59 | 60 | class Error(Exception): 61 | """ 62 | Базовое исключение для ошибок PyMax. 63 | """ 64 | 65 | def __init__( 66 | self, 67 | error: str, 68 | message: str, 69 | title: str, 70 | localized_message: str | None = None, 71 | ) -> None: 72 | self.error = error 73 | self.message = message 74 | self.title = title 75 | self.localized_message = localized_message 76 | 77 | parts = [] 78 | if localized_message: 79 | parts.append(localized_message) 80 | if message: 81 | parts.append(message) 82 | if title: 83 | parts.append(f"({title})") 84 | parts.append(f"[{error}]") 85 | 86 | super().__init__("PyMax Error: " + " ".join(parts)) 87 | 88 | 89 | class RateLimitError(Error): 90 | """ 91 | Исключение, вызываемое при превышении лимита запросов. 92 | """ 93 | 94 | def __init__( 95 | self, error: str, message: str, title: str, localized_message: str | None = None 96 | ) -> None: 97 | super().__init__(error, message, title, localized_message) 98 | 99 | 100 | class LoginError(Error): 101 | """ 102 | Исключение, вызываемое при ошибке авторизации. 103 | """ 104 | 105 | def __init__( 106 | self, error: str, message: str, title: str, localized_message: str | None = None 107 | ) -> None: 108 | super().__init__(error, message, title, localized_message) 109 | -------------------------------------------------------------------------------- /redocs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============= 3 | 4 | Требования 5 | ---------- 6 | 7 | - **Python 3.10+** 8 | - pip или uv package manager 9 | 10 | Установка через pip 11 | -------------------- 12 | 13 | Самый простой способ установить PyMax: 14 | 15 | .. code-block:: bash 16 | 17 | pip install -U maxapi-python 18 | 19 | Или в виртуальном окружении: 20 | 21 | .. code-block:: bash 22 | 23 | python -m venv venv 24 | source venv/bin/activate # На Windows: venv\Scripts\activate 25 | pip install -U maxapi-python 26 | 27 | Установка через uv (рекомендуется) 28 | ----------------------------------- 29 | 30 | UV — это быстрый пакетный менеджер, написанный на Rust: 31 | 32 | .. code-block:: bash 33 | 34 | uv add maxapi-python 35 | 36 | Или добавить в ``pyproject.toml``: 37 | 38 | .. code-block:: toml 39 | 40 | [project] 41 | dependencies = [ 42 | "maxapi-python>=1.0.0", 43 | ] 44 | 45 | Установка из исходников 46 | ------------------------ 47 | 48 | Для разработки или тестирования последней версии: 49 | 50 | .. code-block:: bash 51 | 52 | git clone https://github.com/ink-developer/PyMax.git 53 | cd PyMax 54 | pip install -e . 55 | 56 | Или с использованием uv: 57 | 58 | .. code-block:: bash 59 | 60 | git clone https://github.com/ink-developer/PyMax.git 61 | cd PyMax 62 | uv sync 63 | 64 | Проверка установки 65 | ------------------- 66 | 67 | Проверить, что библиотека установлена корректно: 68 | 69 | .. code-block:: python 70 | 71 | import pymax 72 | print(pymax.__version__) 73 | 74 | Системные требования 75 | -------------------- 76 | 77 | - **ОС**: Linux, macOS, Windows 78 | - **Python**: 3.10, 3.11, 3.12, 3.13 79 | - **Интернет**: Требуется для подключения к WebSocket серверу Max 80 | 81 | .. note:: 82 | 83 | Библиотека использует асинхронный I/O (asyncio), поэтому работает только в асинхронных контекстах. 84 | 85 | Зависимости 86 | ----------- 87 | 88 | Основные зависимости (устанавливаются автоматически): 89 | 90 | - ``aiohttp`` — для HTTP запросов 91 | - ``aiosqlite`` — для локального хранилища сессии 92 | - ``pydantic`` — для валидации данных 93 | 94 | Все зависимости указаны в ``pyproject.toml`` и устанавливаются автоматически. 95 | 96 | Обновление 97 | ---------- 98 | 99 | Обновить до последней версии: 100 | 101 | .. code-block:: bash 102 | 103 | pip install -U maxapi-python 104 | 105 | Или через uv: 106 | 107 | .. code-block:: bash 108 | 109 | uv add -U maxapi-python 110 | 111 | Удаление 112 | -------- 113 | 114 | Удалить библиотеку: 115 | 116 | .. code-block:: bash 117 | 118 | pip uninstall maxapi-python 119 | 120 | Или через uv: 121 | 122 | .. code-block:: bash 123 | 124 | uv remove maxapi-python 125 | 126 | Решение проблем 127 | --------------- 128 | 129 | **ImportError: No module named 'pymax'** 130 | 131 | Убедитесь, что вы установили библиотеку: 132 | 133 | .. code-block:: bash 134 | 135 | pip install -U maxapi-python 136 | 137 | **версия Python слишком старая** 138 | 139 | Обновите Python до 3.10 или новее: 140 | 141 | .. code-block:: bash 142 | 143 | python --version 144 | 145 | **Ошибки зависимостей** 146 | 147 | Попробуйте переустановить: 148 | 149 | .. code-block:: bash 150 | 151 | pip install --force-reinstall -U maxapi-python 152 | -------------------------------------------------------------------------------- /src/pymax/crud.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | from uuid import UUID 3 | 4 | from sqlalchemy.engine.base import Engine 5 | from sqlmodel import Session, SQLModel, create_engine, select 6 | 7 | from .models import Auth 8 | from .static.enum import DeviceType 9 | 10 | 11 | class Database: 12 | def __init__(self, workdir: str) -> None: 13 | self.workdir = workdir 14 | self.engine = self.get_engine(workdir) 15 | self.create_all() 16 | self._ensure_single_auth() 17 | 18 | def create_all(self) -> None: 19 | SQLModel.metadata.create_all(self.engine) 20 | 21 | def get_engine(self, workdir: str) -> Engine: 22 | return create_engine(f"sqlite:///{workdir}/session.db") 23 | 24 | def get_session(self) -> Session: 25 | return Session(bind=self.engine) 26 | 27 | def get_auth_token(self) -> str | None: 28 | with self.get_session() as session: 29 | token = cast(str | None, session.exec(select(Auth.token)).first()) 30 | return token 31 | 32 | def get_device_id(self) -> UUID: 33 | with self.get_session() as session: 34 | device_id = session.exec(select(Auth.device_id)).first() 35 | 36 | if device_id is None: 37 | auth = Auth() 38 | session.add(auth) 39 | session.commit() 40 | session.refresh(auth) 41 | return auth.device_id 42 | return device_id 43 | 44 | def insert_auth(self, auth: Auth) -> Auth: 45 | with self.get_session() as session: 46 | session.add(auth) 47 | session.commit() 48 | session.refresh(auth) 49 | return auth 50 | 51 | def update_auth_token(self, device_id: str, token: str) -> None: 52 | with self.get_session() as session: 53 | auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first() 54 | if auth: 55 | auth.token = token 56 | session.add(auth) 57 | session.commit() 58 | session.refresh(auth) 59 | return 60 | 61 | existing = session.exec(select(Auth)).first() 62 | if existing: 63 | existing.device_id = device_id 64 | existing.token = token 65 | session.add(existing) 66 | session.commit() 67 | session.refresh(existing) 68 | return 69 | 70 | new_auth = Auth(device_id=device_id, token=token) 71 | session.add(new_auth) 72 | session.commit() 73 | session.refresh(new_auth) 74 | 75 | def update(self, auth: Auth) -> Auth: 76 | with self.get_session() as session: 77 | session.add(auth) 78 | session.commit() 79 | session.refresh(auth) 80 | return auth 81 | 82 | def _ensure_single_auth(self) -> None: 83 | with self.get_session() as session: 84 | rows = session.exec(select(Auth)).all() 85 | if not rows: 86 | auth = Auth(device_type=DeviceType.WEB.value) 87 | session.add(auth) 88 | session.commit() 89 | session.refresh(auth) 90 | return 91 | 92 | if len(rows) > 1: 93 | _ = rows[0] 94 | for extra in rows[1:]: 95 | session.delete(extra) 96 | session.commit() 97 | -------------------------------------------------------------------------------- /redocs/source/clients.rst: -------------------------------------------------------------------------------- 1 | Clients 2 | ======= 3 | 4 | MaxClient 5 | --------- 6 | 7 | Основной асинхронный WebSocket клиент для взаимодействия с Max API. 8 | 9 | Инициализация: 10 | 11 | .. code-block:: python 12 | 13 | from pymax import MaxClient 14 | 15 | client = MaxClient( 16 | phone="+79001234567", # Номер телефона (обязательно) 17 | work_dir="./cache", # Папка для кэша сессии 18 | reconnect=True, # Автоматическое переподключение 19 | send_fake_telemetry=True, # Отправлять телеметрию 20 | logger=None, # Пользовательский логгер 21 | ) 22 | 23 | Основные методы: 24 | 25 | .. code-block:: python 26 | 27 | # Запустить клиент 28 | await client.start() 29 | 30 | # Закрыть клиент 31 | await client.close() 32 | 33 | # Получить информацию о чате 34 | chat = await client.get_chat(chat_id=123456) 35 | chats = await client.get_chats([123, 456]) 36 | 37 | # Получить информацию о пользователе 38 | user = await client.get_user(user_id=789012) 39 | 40 | # Отправить сообщение 41 | result = await client.send_message( 42 | chat_id=123456, 43 | text="Сообщение" 44 | ) 45 | 46 | # Редактировать сообщение 47 | await client.edit_message( 48 | chat_id=123456, 49 | message_id=msg_id, 50 | text="Новый текст" 51 | ) 52 | 53 | # Удалить сообщение 54 | await client.delete_message( 55 | chat_id=123456, 56 | message_id=msg_id 57 | ) 58 | 59 | # Получить историю сообщений 60 | history = await client.fetch_history( 61 | chat_id=123456, 62 | limit=50 63 | ) 64 | 65 | Свойства: 66 | 67 | .. code-block:: python 68 | 69 | client.me # Информация о себе (Me) 70 | client.is_connected # Статус подключения (bool) 71 | client.chats # Список всех чатов (list[Chat]) 72 | client.dialogs # Список диалогов (list[Dialog]) 73 | client.channels # Список каналов (list[Channel]) 74 | client.phone # Номер телефона (str) 75 | client.token # Токен сессии (str | None) 76 | 77 | Обработчики событий: 78 | 79 | .. code-block:: python 80 | 81 | @client.on_start 82 | async def on_start(): 83 | """При запуске клиента""" 84 | pass 85 | 86 | 87 | @client.on_message() 88 | async def on_message(message: Message): 89 | """При получении сообщения""" 90 | pass 91 | 92 | 93 | Контекстный менеджер: 94 | 95 | .. code-block:: python 96 | 97 | async with MaxClient(phone="+79001234567") as client: 98 | # Клиент автоматически подключён 99 | await client.send_message(chat_id=123456, text="Привет!") 100 | # Клиент автоматически закроется 101 | 102 | Автоматическое подключение/отключение: 103 | 104 | .. code-block:: python 105 | 106 | client = MaxClient(phone="+79001234567", reconnect=True) 107 | 108 | # Клиент автоматически переподключится при разрыве соединения 109 | await client.start() 110 | 111 | Документация API 112 | ---------------- 113 | 114 | .. autoclass:: pymax.MaxClient 115 | :members: 116 | :inherited-members: 117 | 118 | SocketMaxClient 119 | --------------- 120 | 121 | Низкоуровневый WebSocket клиент для прямого взаимодействия с API. 122 | Обычно не требуется использовать напрямую - используйте MaxClient вместо этого. 123 | 124 | .. note:: 125 | 126 | Если вам нужны низкоуровневые детали, смотрите исходный код библиотеки. 127 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Документация PyMax 2 | site_description: Python wrapper для API мессенджера Max 3 | site_author: ink-developer 4 | site_url: https://github.com/ink-developer/PyMax 5 | 6 | repo_name: ink-developer/PyMax 7 | repo_url: https://github.com/ink-developer/PyMax 8 | edit_uri: edit/main/docs/ 9 | 10 | theme: 11 | name: material 12 | language: ru 13 | palette: 14 | - scheme: slate 15 | primary: black 16 | accent: blue 17 | toggle: 18 | icon: material/brightness-7 19 | name: Переключить на светлую тему 20 | - scheme: default 21 | primary: white 22 | accent: blue 23 | toggle: 24 | icon: material/brightness-3 25 | name: Переключить на темную тему 26 | features: 27 | - announce.dismiss 28 | - content.action.edit 29 | - content.action.view 30 | - content.code.annotate 31 | - content.code.copy 32 | - content.code.select 33 | - content.tooltips 34 | - header.autohide 35 | - navigation.expand 36 | - navigation.footer 37 | - navigation.indexes 38 | - navigation.instant 39 | - navigation.instant.download 40 | - navigation.instant.loading 41 | - navigation.prune 42 | - navigation.sections 43 | - navigation.top 44 | - navigation.tracking 45 | - search.highlight 46 | - search.share 47 | - search.suggest 48 | - toc.follow 49 | icon: 50 | repo: fontawesome/brands/github 51 | edit: material/pencil 52 | view: material/eye 53 | logo: assets/icon.svg 54 | favicon: assets/icon.svg 55 | 56 | plugins: 57 | - search 58 | # - mkdocstrings: 59 | # default_handler: python 60 | # handlers: 61 | # python: 62 | # paths: [src] 63 | # options: 64 | # show_source: true 65 | # show_root_heading: true 66 | # show_category_heading: true 67 | # show_signature_annotations: true 68 | # show_bases: true 69 | # show_submodules: true 70 | # heading_level: 2 71 | # members_order: source 72 | # docstring_style: google 73 | # preload_modules: [pymax] 74 | # filters: ["!^_"] 75 | # merge_init_into_class: true 76 | 77 | markdown_extensions: 78 | - abbr 79 | - admonition 80 | - attr_list 81 | - def_list 82 | - footnotes 83 | - md_in_html 84 | - toc: 85 | permalink: true 86 | - pymdownx.arithmatex: 87 | generic: true 88 | - pymdownx.betterem: 89 | smart_enable: all 90 | - pymdownx.caret 91 | - pymdownx.details 92 | - pymdownx.highlight: 93 | anchor_linenums: true 94 | line_spans: __span 95 | pygments_lang_class: true 96 | - pymdownx.inlinehilite 97 | - pymdownx.keys 98 | - pymdownx.magiclink 99 | - pymdownx.mark 100 | - pymdownx.smartsymbols 101 | - pymdownx.snippets: 102 | check_paths: true 103 | - pymdownx.superfences: 104 | custom_fences: 105 | - name: mermaid 106 | class: mermaid 107 | format: !!python/name:pymdownx.superfences.fence_code_format 108 | - pymdownx.tabbed: 109 | alternate_style: true 110 | combine_header_slug: true 111 | slugify: !!python/object/apply:pymdownx.slugs.slugify 112 | kwds: 113 | case: lower 114 | - pymdownx.tasklist: 115 | custom_checkbox: true 116 | - pymdownx.tilde 117 | 118 | extra: 119 | social: 120 | - icon: fontawesome/brands/github 121 | link: https://github.com/ink-developer/PyMax 122 | - icon: fontawesome/brands/python 123 | link: https://pypi.org/project/maxapi-python 124 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pymax/files.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | from abc import ABC, abstractmethod 3 | from pathlib import Path 4 | from typing import ClassVar 5 | 6 | from aiofiles import open as aio_open 7 | from aiohttp import ClientSession 8 | from typing_extensions import override 9 | 10 | 11 | class BaseFile(ABC): 12 | def __init__(self, url: str | None = None, path: str | None = None) -> None: 13 | self.url = url 14 | self.path = path 15 | 16 | if self.url is None and self.path is None: 17 | raise ValueError("Either url or path must be provided.") 18 | 19 | if self.url and self.path: 20 | raise ValueError("Only one of url or path must be provided.") 21 | 22 | @abstractmethod 23 | async def read(self) -> bytes: 24 | if self.url: 25 | async with ( 26 | ClientSession() as session, 27 | session.get(self.url) as response, 28 | ): 29 | response.raise_for_status() 30 | return await response.read() 31 | elif self.path: 32 | async with aio_open(self.path, "rb") as f: 33 | return await f.read() 34 | else: 35 | raise ValueError("Either url or path must be provided.") 36 | 37 | 38 | class Photo(BaseFile): 39 | ALLOWED_EXTENSIONS: ClassVar[set[str]] = { 40 | ".jpg", 41 | ".jpeg", 42 | ".png", 43 | ".gif", 44 | ".webp", 45 | ".bmp", 46 | } # FIXME: костыль ✅ 47 | 48 | def __init__(self, url: str | None = None, path: str | None = None) -> None: 49 | super().__init__(url, path) 50 | 51 | def validate_photo(self) -> tuple[str, str] | None: 52 | if self.path: 53 | extension = Path(self.path).suffix.lower() 54 | if extension not in self.ALLOWED_EXTENSIONS: 55 | raise ValueError( 56 | f"Invalid photo extension: {extension}. Allowed: {self.ALLOWED_EXTENSIONS}" 57 | ) 58 | 59 | return (extension[1:], ("image/" + extension[1:]).lower()) 60 | elif self.url: 61 | extension = Path(self.url).suffix.lower() 62 | if extension not in self.ALLOWED_EXTENSIONS: 63 | raise ValueError( 64 | f"Invalid photo extension in URL: {extension}. Allowed: {self.ALLOWED_EXTENSIONS}" 65 | ) 66 | 67 | mime_type = mimetypes.guess_type(self.url)[0] 68 | 69 | if not mime_type or not mime_type.startswith("image/"): 70 | raise ValueError(f"URL does not appear to be an image: {self.url}") 71 | 72 | return (extension[1:], mime_type) 73 | return None 74 | 75 | @override 76 | async def read(self) -> bytes: 77 | return await super().read() 78 | 79 | 80 | class Video(BaseFile): 81 | def __init__(self, url: str | None = None, path: str | None = None) -> None: 82 | self.file_name: str = "" 83 | if path: 84 | self.file_name = Path(path).name 85 | elif url: 86 | self.file_name = Path(url).name 87 | 88 | if not self.file_name: 89 | raise ValueError("Either url or path must be provided.") 90 | super().__init__(url, path) 91 | 92 | @override 93 | async def read(self) -> bytes: 94 | return await super().read() 95 | 96 | 97 | class File(BaseFile): 98 | def __init__(self, url: str | None = None, path: str | None = None) -> None: 99 | self.file_name: str = "" 100 | if path: 101 | self.file_name = Path(path).name 102 | elif url: 103 | self.file_name = Path(url).name 104 | 105 | if not self.file_name: 106 | raise ValueError("Either url or path must be provided.") 107 | 108 | super().__init__(url, path) 109 | 110 | @override 111 | async def read(self) -> bytes: 112 | return await super().read() 113 | -------------------------------------------------------------------------------- /src/pymax/mixins/telemetry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import time 4 | 5 | from pymax.exceptions import Error 6 | from pymax.interfaces import ClientProtocol 7 | from pymax.navigation import Navigation 8 | from pymax.payloads import ( 9 | NavigationEventParams, 10 | NavigationEventPayload, 11 | NavigationPayload, 12 | ) 13 | from pymax.static.enum import Opcode 14 | 15 | 16 | class TelemetryMixin(ClientProtocol): 17 | async def _send_navigation_event( 18 | self, events: list[NavigationEventPayload] 19 | ) -> None: 20 | try: 21 | payload = NavigationPayload(events=events).model_dump(by_alias=True) 22 | data = await self._send_and_wait( 23 | opcode=Opcode.LOG, 24 | payload=payload, 25 | ) 26 | payload_data = data.get("payload", {}) 27 | if payload_data and payload_data.get("error"): 28 | error = payload_data.get("error") 29 | self.logger.error("Navigation event error: %s", error) 30 | except Exception: 31 | self.logger.warning("Failed to send navigation event", exc_info=True) 32 | return 33 | 34 | async def _send_cold_start(self) -> None: 35 | if not self.me: 36 | self.logger.error("Cannot send cold start, user not set") 37 | return 38 | 39 | payload = NavigationEventPayload( 40 | event="COLD_START", 41 | time=int(time.time() * 1000), 42 | user_id=self.me.id, 43 | params=NavigationEventParams( 44 | action_id=self._action_id, 45 | screen_to=Navigation.get_screen_id("chats_list_tab"), 46 | screen_from=1, 47 | source_id=1, 48 | session_id=self._session_id, 49 | ), 50 | ) 51 | 52 | self._action_id += 1 53 | 54 | await self._send_navigation_event([payload]) 55 | 56 | async def _send_random_navigation(self) -> None: 57 | if not self.me: 58 | self.logger.error("Cannot send navigation event, user not set") 59 | return 60 | 61 | screen_from = self._current_screen 62 | screen_to = Navigation.get_random_navigation(screen_from) 63 | 64 | self._action_id += 1 65 | self._current_screen = screen_to 66 | 67 | payload = NavigationEventPayload( 68 | event="NAV", 69 | time=int(time.time() * 1000), 70 | user_id=self.me.id, 71 | params=NavigationEventParams( 72 | action_id=self._action_id, 73 | screen_from=Navigation.get_screen_id(screen_from), 74 | screen_to=Navigation.get_screen_id(screen_to), 75 | source_id=1, 76 | session_id=self._session_id, 77 | ), 78 | ) 79 | 80 | await self._send_navigation_event([payload]) 81 | 82 | def _get_random_sleep_time(self) -> int: 83 | # TODO: вынести в статик 84 | sleep_options = [ 85 | (1000, 3000), 86 | (300, 1000), 87 | (60, 300), 88 | (5, 60), 89 | (5, 20), 90 | ] 91 | 92 | weights = [0.05, 0.10, 0.15, 0.20, 0.50] 93 | 94 | low, high = random.choices( # nosec B311 95 | sleep_options, weights=weights, k=1 96 | )[0] 97 | return random.randint(low, high) # nosec B311 98 | 99 | async def _start(self) -> None: 100 | if not self.is_connected: 101 | self.logger.error("Cannot start telemetry, client not connected") 102 | return 103 | 104 | await self._send_cold_start() 105 | 106 | try: 107 | while self.is_connected: 108 | await self._send_random_navigation() 109 | await asyncio.sleep(self._get_random_sleep_time()) 110 | 111 | except asyncio.CancelledError: 112 | self.logger.debug("Telemetry task cancelled") 113 | except Exception: 114 | self.logger.warning("Telemetry task failed", exc_info=True) 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | PyMax 3 |

4 | 5 |

6 | Python wrapper для API мессенджера Max 7 |

8 | 9 |

10 | Python 3.11+ 11 | License: MIT 12 | Ruff 13 | Packaging 14 |

15 | 16 | --- 17 | > ⚠️ **Дисклеймер** 18 | > 19 | > * Это **неофициальная** библиотека для работы с внутренним API Max. 20 | > * Использование может **нарушать условия предоставления услуг** сервиса. 21 | > * **Вы используете её исключительно на свой страх и риск.** 22 | > * **Разработчики и контрибьюторы не несут никакой ответственности** за любые последствия использования этого пакета, включая, но не ограничиваясь: блокировку аккаунтов, утерю данных, юридические риски и любые другие проблемы. 23 | > * API может быть изменен в любой момент без предупреждения. 24 | --- 25 | 26 | ## Описание 27 | 28 | **`pymax`** — асинхронная Python библиотека для работы с API мессенджера Max. Предоставляет интерфейс для отправки сообщений, управления чатами, каналами и диалогами через WebSocket соединение. 29 | 30 | ### Основные возможности 31 | 32 | - Вход по номеру телефона 33 | - Отправка, редактирование и удаление сообщений 34 | - Работа с чатами и каналами 35 | - История сообщений 36 | 37 | ## Установка 38 | 39 | > [!IMPORTANT] 40 | > Для работы библиотеки требуется Python 3.10 или выше 41 | 42 | ### Установка через pip 43 | 44 | ```bash 45 | pip install -U maxapi-python 46 | ``` 47 | 48 | ### Установка через uv 49 | 50 | ```bash 51 | uv add -U maxapi-python 52 | ``` 53 | 54 | ## Быстрый старт 55 | 56 | ### Базовый пример использования 57 | 58 | ```python 59 | import asyncio 60 | 61 | from pymax import MaxClient, Message 62 | from pymax.filters import Filters 63 | 64 | client = MaxClient( 65 | phone="+1234567890", 66 | work_dir="cache", # директория для сессий 67 | ) 68 | 69 | 70 | # Обработка входящих сообщений 71 | @client.on_message(Filters.chat(0)) # фильтр по ID чата 72 | async def on_message(msg: Message) -> None: 73 | print(f"[{msg.sender}] {msg.text}") 74 | 75 | await client.send_message( 76 | chat_id=msg.chat_id, 77 | text="Привет, я бот на PyMax!", 78 | ) 79 | 80 | await client.add_reaction( 81 | chat_id=msg.chat_id, 82 | message_id=str(msg.id), 83 | reaction="👍", 84 | ) 85 | 86 | 87 | @client.on_start 88 | async def on_start() -> None: 89 | print(f"Клиент запущен. Ваш ID: {client.me.id}") 90 | 91 | # Получение истории 92 | history = await client.fetch_history(chat_id=0) 93 | print("Последние сообщения из чата 0:") 94 | for m in history: 95 | print(f"- {m.text}") 96 | 97 | 98 | async def main(): 99 | await client.start() # подключение и авторизация 100 | 101 | 102 | if __name__ == "__main__": 103 | asyncio.run(main()) 104 | ``` 105 | 106 | ## Документация 107 | 108 | [GitHub Pages](https://maxapiteam.github.io/PyMax/) 109 | [DeepWiki](https://deepwiki.com/MaxApiTeam/PyMax) 110 | 111 | ## Лицензия 112 | 113 | Этот проект распространяется под лицензией MIT. См. файл [LICENSE](LICENSE) для получения информации. 114 | 115 | ## Новости 116 | 117 | [Telegram](https://t.me/pymax_news) 118 | 119 | ## Star History 120 | 121 | [![Star History Chart](https://api.star-history.com/svg?repos=ink-developer/PyMax&type=date&legend=top-left)](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left) 122 | 123 | ## Авторы 124 | - **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация 125 | - **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта 126 | 127 | 128 | ## Контрибьюторы 129 | 130 | Спасибо всем за помощь в разработке! 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /src/pymax/interfaces.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | import ssl 4 | from abc import ABC, abstractmethod 5 | from collections.abc import Awaitable, Callable 6 | from logging import Logger 7 | from typing import TYPE_CHECKING, Any, Literal 8 | 9 | from .payloads import UserAgentPayload 10 | from .static.constant import DEFAULT_TIMEOUT 11 | from .static.enum import Opcode 12 | from .types import Channel, Chat, Dialog, Me, Message, User 13 | 14 | if TYPE_CHECKING: 15 | from pathlib import Path 16 | from uuid import UUID 17 | 18 | import websockets 19 | 20 | from pymax import AttachType 21 | from pymax.types import ReactionInfo 22 | 23 | from .crud import Database 24 | from .filters import BaseFilter 25 | 26 | 27 | class ClientProtocol(ABC): 28 | def __init__(self, logger: Logger) -> None: 29 | super().__init__() 30 | self.logger = logger 31 | self._users: dict[int, User] = {} 32 | self.chats: list[Chat] = [] 33 | self._database: Database 34 | self._device_id: UUID 35 | self.uri: str 36 | self.is_connected: bool = False 37 | self.phone: str 38 | self.dialogs: list[Dialog] = [] 39 | self.channels: list[Channel] = [] 40 | self.me: Me | None = None 41 | self.host: str 42 | self.port: int 43 | self.proxy: str | Literal[True] | None 44 | self.registration: bool 45 | self.first_name: str 46 | self.last_name: str | None 47 | self._token: str | None 48 | self._work_dir: str 49 | self.reconnect: bool 50 | self._database_path: Path 51 | self._ws: websockets.ClientConnection | None = None 52 | self._seq: int = 0 53 | self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {} 54 | self._recv_task: asyncio.Task[Any] | None = None 55 | self._incoming: asyncio.Queue[dict[str, Any]] | None = None 56 | self._file_upload_waiters: dict[ 57 | int, 58 | asyncio.Future[dict[str, Any]], 59 | ] = {} 60 | self.user_agent = UserAgentPayload() 61 | self._outgoing: asyncio.Queue[dict[str, Any]] | None = None 62 | self._outgoing_task: asyncio.Task[Any] | None = None 63 | self._error_count: int = 0 64 | self._circuit_breaker: bool = False 65 | self._last_error_time: float = 0.0 66 | self._session_id: int 67 | self._action_id: int = 0 68 | self._current_screen: str = "chats_list_tab" 69 | self._on_message_handlers: list[ 70 | tuple[Callable[[Message], Any], BaseFilter[Message] | None] 71 | ] = [] 72 | self._on_message_edit_handlers: list[ 73 | tuple[Callable[[Message], Any], BaseFilter[Message] | None] 74 | ] = [] 75 | self._on_message_delete_handlers: list[ 76 | tuple[Callable[[Message], Any], BaseFilter[Message] | None] 77 | ] = [] 78 | self._on_reaction_change_handlers: list[ 79 | Callable[[str, int, ReactionInfo], Any] 80 | ] = [] 81 | self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = [] 82 | self._on_raw_receive_handlers: list[ 83 | Callable[[dict[str, Any]], Any | Awaitable[Any]] 84 | ] = [] 85 | self._scheduled_tasks: list[ 86 | tuple[Callable[[], Any | Awaitable[Any]], float] 87 | ] = [] 88 | self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None 89 | self._background_tasks: set[asyncio.Task[Any]] = set() 90 | self._ssl_context: ssl.SSLContext 91 | self._socket: socket.socket | None = None 92 | 93 | @abstractmethod 94 | async def _send_and_wait( 95 | self, 96 | opcode: Opcode, 97 | payload: dict[str, Any], 98 | cmd: int = 0, 99 | timeout: float = DEFAULT_TIMEOUT, 100 | ) -> dict[str, Any]: 101 | pass 102 | 103 | @abstractmethod 104 | async def _get_chat(self, chat_id: int) -> Chat | None: 105 | pass 106 | 107 | @abstractmethod 108 | async def _queue_message( 109 | self, 110 | opcode: int, 111 | payload: dict[str, Any], 112 | cmd: int = 0, 113 | timeout: float = DEFAULT_TIMEOUT, 114 | max_retries: int = 3, 115 | ) -> Message | None: 116 | pass 117 | 118 | @abstractmethod 119 | def _create_safe_task( 120 | self, coro: Awaitable[Any], name: str | None = None 121 | ) -> asyncio.Task[Any]: 122 | pass 123 | -------------------------------------------------------------------------------- /src/pymax/filters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from abc import ABC, abstractmethod 5 | from typing import Generic, TypeVar 6 | 7 | from pymax.static.enum import AttachType, ChatType, MessageStatus 8 | from pymax.types import Message 9 | 10 | T_co = TypeVar("T_co") 11 | 12 | 13 | class BaseFilter(ABC, Generic[T_co]): 14 | event_type: type[T_co] 15 | 16 | @abstractmethod 17 | def __call__(self, event: T_co) -> bool: ... 18 | 19 | def __and__(self, other: BaseFilter[T_co]) -> BaseFilter[T_co]: 20 | return AndFilter(self, other) 21 | 22 | def __or__(self, other: BaseFilter[T_co]) -> BaseFilter[T_co]: 23 | return OrFilter(self, other) 24 | 25 | def __invert__(self) -> BaseFilter[T_co]: 26 | return NotFilter(self) 27 | 28 | 29 | class AndFilter(BaseFilter[T_co]): 30 | def __init__(self, *filters: BaseFilter[T_co]) -> None: 31 | self.filters = filters 32 | self.event_type = filters[0].event_type 33 | 34 | def __call__(self, event: T_co) -> bool: 35 | return all(f(event) for f in self.filters) 36 | 37 | 38 | class OrFilter(BaseFilter[T_co]): 39 | def __init__(self, *filters: BaseFilter[T_co]) -> None: 40 | self.filters = filters 41 | self.event_type = filters[0].event_type 42 | 43 | def __call__(self, event: T_co) -> bool: 44 | return any(f(event) for f in self.filters) 45 | 46 | 47 | class NotFilter(BaseFilter[T_co]): 48 | def __init__(self, base_filter: BaseFilter[T_co]) -> None: 49 | self.base_filter = base_filter 50 | self.event_type = base_filter.event_type 51 | 52 | def __call__(self, event: T_co) -> bool: 53 | return not self.base_filter(event) 54 | 55 | 56 | class ChatFilter(BaseFilter[Message]): 57 | event_type = Message 58 | 59 | def __init__(self, chat_id: int) -> None: 60 | self.chat_id = chat_id 61 | 62 | def __call__(self, message: Message) -> bool: 63 | return message.chat_id == self.chat_id 64 | 65 | 66 | class TextFilter(BaseFilter[Message]): 67 | event_type = Message 68 | 69 | def __init__(self, text: str) -> None: 70 | self.text = text 71 | 72 | def __call__(self, message: Message) -> bool: 73 | return self.text in message.text 74 | 75 | 76 | class SenderFilter(BaseFilter[Message]): 77 | event_type = Message 78 | 79 | def __init__(self, user_id: int) -> None: 80 | self.user_id = user_id 81 | 82 | def __call__(self, message: Message) -> bool: 83 | return message.sender == self.user_id 84 | 85 | 86 | class StatusFilter(BaseFilter[Message]): 87 | event_type = Message 88 | 89 | def __init__(self, status: MessageStatus) -> None: 90 | self.status = status 91 | 92 | def __call__(self, message: Message) -> bool: 93 | return message.status == self.status 94 | 95 | 96 | class TextContainsFilter(BaseFilter[Message]): 97 | event_type = Message 98 | 99 | def __init__(self, substring: str) -> None: 100 | self.substring = substring 101 | 102 | def __call__(self, message: Message) -> bool: 103 | return self.substring in message.text 104 | 105 | 106 | class RegexTextFilter(BaseFilter[Message]): 107 | event_type = Message 108 | 109 | def __init__(self, pattern: str) -> None: 110 | self.pattern = pattern 111 | self.regex = re.compile(pattern) 112 | 113 | def __call__(self, message: Message) -> bool: 114 | return bool(self.regex.search(message.text)) 115 | 116 | 117 | class MediaFilter(BaseFilter[Message]): 118 | event_type = Message 119 | 120 | def __call__(self, message: Message) -> bool: 121 | return message.attaches is not None and len(message.attaches) > 0 122 | 123 | 124 | class FileFilter(BaseFilter[Message]): 125 | event_type = Message 126 | 127 | def __call__(self, message: Message) -> bool: 128 | if message.attaches is None: 129 | return False 130 | return any(attach.type == AttachType.FILE for attach in message.attaches) 131 | 132 | 133 | class Filters: 134 | @staticmethod 135 | def chat(chat_id: int) -> BaseFilter[Message]: 136 | return ChatFilter(chat_id) 137 | 138 | @staticmethod 139 | def text(text: str) -> BaseFilter[Message]: 140 | return TextFilter(text) 141 | 142 | @staticmethod 143 | def sender(user_id: int) -> BaseFilter[Message]: 144 | return SenderFilter(user_id) 145 | 146 | @staticmethod 147 | def status(status: MessageStatus) -> BaseFilter[Message]: 148 | return StatusFilter(status) 149 | 150 | @staticmethod 151 | def text_contains(substring: str) -> BaseFilter[Message]: 152 | return TextContainsFilter(substring) 153 | 154 | @staticmethod 155 | def text_matches(pattern: str) -> BaseFilter[Message]: 156 | return RegexTextFilter(pattern) 157 | 158 | @staticmethod 159 | def has_media() -> BaseFilter[Message]: 160 | return MediaFilter() 161 | 162 | @staticmethod 163 | def has_file() -> BaseFilter[Message]: 164 | return FileFilter() 165 | -------------------------------------------------------------------------------- /src/pymax/mixins/channel.py: -------------------------------------------------------------------------------- 1 | from pymax.exceptions import Error, ResponseError, ResponseStructureError 2 | from pymax.interfaces import ClientProtocol 3 | from pymax.mixins.utils import MixinsUtils 4 | from pymax.payloads import ( 5 | GetGroupMembersPayload, 6 | JoinChatPayload, 7 | ResolveLinkPayload, 8 | SearchGroupMembersPayload, 9 | ) 10 | from pymax.static.constant import ( 11 | DEFAULT_CHAT_MEMBERS_LIMIT, 12 | DEFAULT_MARKER_VALUE, 13 | ) 14 | from pymax.static.enum import Opcode 15 | from pymax.types import Channel, Member 16 | 17 | 18 | class ChannelMixin(ClientProtocol): 19 | async def resolve_channel_by_name(self, name: str) -> Channel | None: 20 | """ 21 | Получает информацию о канале по его имени 22 | 23 | :param name: Имя канала 24 | :type name: str 25 | :return: Объект Channel или None, если канал не найден 26 | :rtype: Channel | None 27 | """ 28 | payload = ResolveLinkPayload( 29 | link=f"https://max.ru/{name}", 30 | ).model_dump(by_alias=True) 31 | 32 | data = await self._send_and_wait(opcode=Opcode.LINK_INFO, payload=payload) 33 | if data.get("payload", {}).get("error"): 34 | MixinsUtils.handle_error(data) 35 | 36 | channel = Channel.from_dict(data.get("payload", {}).get("chat", {})) 37 | if channel not in self.channels: 38 | self.channels.append(channel) 39 | return channel 40 | 41 | async def join_channel(self, link: str) -> Channel | None: 42 | """ 43 | Присоединяется к каналу по ссылке 44 | 45 | :param link: Ссылка на канал 46 | :type link: str 47 | :return: Объект канала, если присоединение прошло успешно, иначе None 48 | :rtype: Channel | None 49 | """ 50 | payload = JoinChatPayload( 51 | link=link, 52 | ).model_dump(by_alias=True) 53 | 54 | data = await self._send_and_wait(opcode=Opcode.CHAT_JOIN, payload=payload) 55 | if data.get("payload", {}).get("error"): 56 | MixinsUtils.handle_error(data) 57 | 58 | channel = Channel.from_dict(data.get("payload", {}).get("chat", {})) 59 | if channel not in self.channels: 60 | self.channels.append(channel) 61 | return channel 62 | 63 | async def _query_members( 64 | self, payload: GetGroupMembersPayload | SearchGroupMembersPayload 65 | ) -> tuple[list[Member], int | None]: 66 | data = await self._send_and_wait( 67 | opcode=Opcode.CHAT_MEMBERS, 68 | payload=payload.model_dump(by_alias=True, exclude_none=True), 69 | ) 70 | response_payload = data.get("payload", {}) 71 | if data.get("payload", {}).get("error"): 72 | MixinsUtils.handle_error(data) 73 | marker = response_payload.get("marker") 74 | if isinstance(marker, str): 75 | marker = int(marker) 76 | elif isinstance(marker, int): 77 | pass 78 | elif marker is None: 79 | # маркер может отсутствовать 80 | pass 81 | else: 82 | raise ResponseStructureError("Invalid marker type in response") 83 | members = response_payload.get("members") 84 | member_list = [] 85 | if isinstance(members, list): 86 | for item in members: 87 | if not isinstance(item, dict): 88 | raise ResponseStructureError("Invalid member structure in response") 89 | member_list.append(Member.from_dict(item)) 90 | else: 91 | raise ResponseStructureError("Invalid members type in response") 92 | return member_list, marker 93 | 94 | async def load_members( 95 | self, 96 | chat_id: int, 97 | marker: int | None = DEFAULT_MARKER_VALUE, 98 | count: int = DEFAULT_CHAT_MEMBERS_LIMIT, 99 | ) -> tuple[list[Member], int | None]: 100 | """ 101 | Загружает членов канала 102 | 103 | :param chat_id: Идентификатор канала 104 | :type chat_id: int 105 | :param marker: Маркер для пагинации. По умолчанию DEFAULT_MARKER_VALUE 106 | :type marker: int | None 107 | :param count: Количество членов для загрузки. По умолчанию DEFAULT_CHAT_MEMBERS_LIMIT. 108 | :type count: int 109 | :return: Список участников канала и маркер для следующей страницы 110 | :rtype: tuple[list[Member], int | None] 111 | """ 112 | 113 | payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count) 114 | return await self._query_members(payload) 115 | 116 | async def find_members( 117 | self, chat_id: int, query: str 118 | ) -> tuple[list[Member], int | None]: 119 | """ 120 | Поиск участников канала по строке 121 | Внимание! веб-клиент всегда возвращает только определённое количество пользователей, 122 | тоесть пагинация здесь не реализована! 123 | 124 | :param chat_id: Идентификатор канала 125 | :type chat_id: int 126 | :param query: Строка для поиска участников 127 | :type query: str 128 | :return: Список участников канала 129 | :rtype: tuple[list[Member], int | None] 130 | """ 131 | payload = SearchGroupMembersPayload(chat_id=chat_id, query=query) 132 | return await self._query_members(payload) 133 | -------------------------------------------------------------------------------- /src/pymax/static/enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Opcode(int, Enum): 5 | PING = 1 6 | DEBUG = 2 7 | RECONNECT = 3 8 | LOG = 5 9 | SESSION_INIT = 6 10 | PROFILE = 16 11 | AUTH_REQUEST = 17 12 | AUTH = 18 13 | LOGIN = 19 14 | LOGOUT = 20 15 | SYNC = 21 16 | CONFIG = 22 17 | AUTH_CONFIRM = 23 18 | PRESET_AVATARS = 25 19 | ASSETS_GET = 26 20 | ASSETS_UPDATE = 27 21 | ASSETS_GET_BY_IDS = 28 22 | ASSETS_ADD = 29 23 | SEARCH_FEEDBACK = 31 24 | CONTACT_INFO = 32 25 | CONTACT_ADD = 33 26 | CONTACT_UPDATE = 34 27 | CONTACT_PRESENCE = 35 28 | CONTACT_LIST = 36 29 | CONTACT_SEARCH = 37 30 | CONTACT_MUTUAL = 38 31 | CONTACT_PHOTOS = 39 32 | CONTACT_SORT = 40 33 | CONTACT_VERIFY = 42 34 | REMOVE_CONTACT_PHOTO = 43 35 | CONTACT_INFO_BY_PHONE = 46 36 | CHAT_INFO = 48 37 | CHAT_HISTORY = 49 38 | CHAT_MARK = 50 39 | CHAT_MEDIA = 51 40 | CHAT_DELETE = 52 41 | CHATS_LIST = 53 42 | CHAT_CLEAR = 54 43 | CHAT_UPDATE = 55 44 | CHAT_CHECK_LINK = 56 45 | CHAT_JOIN = 57 46 | CHAT_LEAVE = 58 47 | CHAT_MEMBERS = 59 48 | PUBLIC_SEARCH = 60 49 | CHAT_CLOSE = 61 50 | CHAT_CREATE = 63 51 | MSG_SEND = 64 52 | MSG_TYPING = 65 53 | MSG_DELETE = 66 54 | MSG_EDIT = 67 55 | CHAT_SEARCH = 68 56 | MSG_SHARE_PREVIEW = 70 57 | MSG_GET = 71 58 | MSG_SEARCH_TOUCH = 72 59 | MSG_SEARCH = 73 60 | MSG_GET_STAT = 74 61 | CHAT_SUBSCRIBE = 75 62 | VIDEO_CHAT_START = 76 63 | CHAT_MEMBERS_UPDATE = 77 64 | VIDEO_CHAT_HISTORY = 79 65 | PHOTO_UPLOAD = 80 66 | STICKER_UPLOAD = 81 67 | VIDEO_UPLOAD = 82 68 | VIDEO_PLAY = 83 69 | CHAT_PIN_SET_VISIBILITY = 86 70 | FILE_UPLOAD = 87 71 | FILE_DOWNLOAD = 88 72 | LINK_INFO = 89 73 | MSG_DELETE_RANGE = 92 74 | SESSIONS_INFO = 96 75 | SESSIONS_CLOSE = 97 76 | PHONE_BIND_REQUEST = 98 77 | PHONE_BIND_CONFIRM = 99 78 | CONFIRM_PRESENT = 101 79 | GET_INBOUND_CALLS = 103 80 | EXTERNAL_CALLBACK = 105 81 | AUTH_VALIDATE_PASSWORD = 107 82 | AUTH_VALIDATE_HINT = 108 83 | AUTH_VERIFY_EMAIL = 109 84 | AUTH_CHECK_EMAIL = 110 85 | AUTH_SET_2FA = 111 86 | AUTH_CREATE_TRACK = 112 87 | AUTH_LOGIN_CHECK_PASSWORD = 115 88 | CHAT_COMPLAIN = 117 89 | MSG_SEND_CALLBACK = 118 90 | SUSPEND_BOT = 119 91 | LOCATION_STOP = 124 92 | LOCATION_SEND = 125 93 | LOCATION_REQUEST = 126 94 | GET_LAST_MENTIONS = 127 95 | NOTIF_MESSAGE = 128 96 | NOTIF_TYPING = 129 97 | NOTIF_MARK = 130 98 | NOTIF_CONTACT = 131 99 | NOTIF_PRESENCE = 132 100 | NOTIF_CONFIG = 134 101 | NOTIF_CHAT = 135 102 | NOTIF_ATTACH = 136 103 | NOTIF_CALL_START = 137 104 | NOTIF_CONTACT_SORT = 139 105 | NOTIF_MSG_DELETE_RANGE = 140 106 | NOTIF_MSG_DELETE = 142 107 | NOTIF_CALLBACK_ANSWER = 143 108 | CHAT_BOT_COMMANDS = 144 109 | BOT_INFO = 145 110 | NOTIF_LOCATION = 147 111 | NOTIF_LOCATION_REQUEST = 148 112 | NOTIF_ASSETS_UPDATE = 150 113 | NOTIF_DRAFT = 152 114 | NOTIF_DRAFT_DISCARD = 153 115 | NOTIF_MSG_DELAYED = 154 116 | NOTIF_MSG_REACTIONS_CHANGED = 155 117 | NOTIF_MSG_YOU_REACTED = 156 118 | CALLS_TOKEN = 158 119 | NOTIF_PROFILE = 159 120 | WEB_APP_INIT_DATA = 160 121 | DRAFT_SAVE = 176 122 | DRAFT_DISCARD = 177 123 | MSG_REACTION = 178 124 | MSG_CANCEL_REACTION = 179 125 | MSG_GET_REACTIONS = 180 126 | MSG_GET_DETAILED_REACTIONS = 181 127 | STICKER_CREATE = 193 128 | STICKER_SUGGEST = 194 129 | VIDEO_CHAT_MEMBERS = 195 130 | CHAT_HIDE = 196 131 | CHAT_SEARCH_COMMON_PARTICIPANTS = 198 132 | PROFILE_DELETE = 199 133 | PROFILE_DELETE_TIME = 200 134 | ASSETS_REMOVE = 259 135 | ASSETS_MOVE = 260 136 | ASSETS_LIST_MODIFY = 261 137 | FOLDERS_GET = 272 138 | FOLDERS_GET_BY_ID = 273 139 | FOLDERS_UPDATE = 274 140 | FOLDERS_REORDER = 275 141 | FOLDERS_DELETE = 276 142 | NOTIF_FOLDERS = 277 143 | 144 | 145 | class ChatType(str, Enum): 146 | DIALOG = "DIALOG" 147 | CHAT = "CHAT" 148 | CHANNEL = "CHANNEL" 149 | 150 | 151 | class MessageType(str, Enum): 152 | TEXT = "TEXT" 153 | SYSTEM = "SYSTEM" 154 | SERVICE = "SERVICE" 155 | 156 | 157 | class MessageStatus(str, Enum): 158 | EDITED = "EDITED" 159 | REMOVED = "REMOVED" 160 | 161 | 162 | class ElementType(str, Enum): 163 | TEXT = "text" 164 | MENTION = "mention" 165 | LINK = "link" 166 | EMOJI = "emoji" 167 | 168 | 169 | class AuthType(str, Enum): 170 | START_AUTH = "START_AUTH" 171 | CHECK_CODE = "CHECK_CODE" 172 | REGISTER = "REGISTER" 173 | RESEND = "RESEND" 174 | 175 | 176 | class AccessType(str, Enum): 177 | PUBLIC = "PUBLIC" 178 | PRIVATE = "PRIVATE" 179 | SECRET = "SECRET" # nosec B105 180 | 181 | 182 | class DeviceType(str, Enum): 183 | WEB = "WEB" 184 | ANDROID = "ANDROID" 185 | IOS = "IOS" 186 | 187 | 188 | class AttachType(str, Enum): 189 | PHOTO = "PHOTO" 190 | VIDEO = "VIDEO" 191 | FILE = "FILE" 192 | STICKER = "STICKER" 193 | AUDIO = "AUDIO" 194 | CONTROL = "CONTROL" 195 | 196 | 197 | class FormattingType(str, Enum): 198 | STRONG = "STRONG" 199 | EMPHASIZED = "EMPHASIZED" 200 | UNDERLINE = "UNDERLINE" 201 | STRIKETHROUGH = "STRIKETHROUGH" 202 | 203 | 204 | class MarkupType(str, Enum): 205 | BOLD = "**" 206 | ITALIC = "*" 207 | UNDERLINE = "__" 208 | STRIKETHROUGH = "~~" 209 | 210 | 211 | class ContactAction(str, Enum): 212 | ADD = "ADD" 213 | REMOVE = "REMOVE" 214 | -------------------------------------------------------------------------------- /src/pymax/navigation.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | class Navigation: 5 | SCREENS_GRAPH = { # noqa: RUF012 6 | "chats_list_tab": [ 7 | "chat", 8 | "contacts_tab", 9 | "call_history_tab", 10 | "settings_tab", 11 | "create_chat", 12 | "chat_attachments_voices", 13 | ], 14 | "chat": [ 15 | "chats_list_tab", 16 | "chat_attachments_media", 17 | ], 18 | "contacts_tab": [ 19 | "call_history_tab", 20 | "chats_list_tab", 21 | "settings_tab", 22 | "create_chat", 23 | ], 24 | "call_history_tab": [ 25 | "chats_list_tab", 26 | "settings_tab", 27 | "contacts_tab", 28 | ], 29 | "settings_tab": [ 30 | "settings_folders", 31 | "settings_privacy", 32 | "settings_notifications", 33 | "settings_chat_decoration", 34 | "call_history_tab", 35 | "contacts_tab", 36 | "chats_list_tab", 37 | ], 38 | "settings_folders": [ 39 | "settings_tab", 40 | "chats_list_tab", 41 | "contacts_tab", 42 | "call_history_tab", 43 | ], 44 | "settings_privacy": [ 45 | "settings_tab", 46 | "chats_list_tab", 47 | "contacts_tab", 48 | "call_history_tab", 49 | ], 50 | "settings_notifications": [ 51 | "settings_tab", 52 | "contacts_tab", 53 | "call_history_tab", 54 | "chats_list_tab", 55 | ], 56 | "settings_chat_decoration": [ 57 | "settings_tab", 58 | "chats_list_tab", 59 | "contacts_tab", 60 | "call_history_tab", 61 | ], 62 | "create_chat": [ 63 | "chats_list_tab", 64 | "contacts_tab", 65 | ], 66 | "chat_attachments_media": [ 67 | "chat_attachments_files", 68 | "chat_attachments_voices", 69 | "chat_attachments_links", 70 | "chat", 71 | ], 72 | "chat_attachments_files": [ 73 | "chat_attachments_voices", 74 | "chat_attachments_media", 75 | "chat_attachments_links", 76 | "chat", 77 | ], 78 | "chat_attachments_voices": [ 79 | "chat_attachments_links", 80 | "chat_attachments_media", 81 | "chat_attachments_files", 82 | "chat", 83 | ], 84 | "chat_attachments_links": [ 85 | "chat_attachments_media", 86 | "chat_attachments_files", 87 | "chat_attachments_voices", 88 | "chat", 89 | ], 90 | } 91 | SCREENS = { # noqa: RUF012 92 | "application_background": 1, 93 | "auth_sign_method": 50, 94 | "auth_phone_login": 51, 95 | "auth_otp": 52, 96 | "auth_empty_profile": 53, 97 | "auth_avatars": 54, 98 | "contacts_tab": 100, 99 | "contacts_search": 102, 100 | "contacts_search_by_phone": 103, 101 | "chats_list_tab": 150, 102 | "chats_list_search_initial": 151, 103 | "chats_list_search_result": 152, 104 | "create_chat": 200, 105 | "create_chat_members_picker": 201, 106 | "create_chat_info": 202, 107 | "avatar_picker_gallery": 250, 108 | "avatar_picker_crop": 251, 109 | "avatar_picker_camera": 252, 110 | "avatar_viewer": 253, 111 | "call_history_tab": 300, 112 | "call_new_call": 302, 113 | "call_create_group_link": 303, 114 | "call_add_participants": 304, 115 | "call": 305, 116 | "chat": 350, 117 | "chat_attach_picker": 351, 118 | "chat_attach_picker_media_viewer": 352, 119 | "chat_attach_picker_camera": 353, 120 | "chat_share_location": 354, 121 | "chat_share_contact": 355, 122 | "chat_forward": 357, 123 | "chat_media_viewer": 358, 124 | "chat_system_file_viewer": 359, 125 | "chat_location_viewer": 360, 126 | "chat_info": 400, 127 | "chat_info_all_participants": 401, 128 | "chat_info_editing": 402, 129 | "chat_info_add_participants": 403, 130 | "chat_info_administrators": 404, 131 | "chat_info_add_administrator": 405, 132 | "chat_info_blocked_participants": 406, 133 | "chat_info_change_owner": 407, 134 | "chat_attachments_media": 408, 135 | "chat_attachments_files": 409, 136 | "chat_attachments_links": 410, 137 | "chat_info_invite_link": 411, 138 | "chat_attachments_voices": 412, 139 | "settings_tab": 450, 140 | "settings_profile_editing": 451, 141 | "settings_shortname_change": 452, 142 | "settings_phone_change": 453, 143 | "settings_notifications": 454, 144 | "settings_notifications_system": 455, 145 | "settings_folders": 456, 146 | "settings_privacy": 457, 147 | "settings_privacy_block_list": 458, 148 | "settings_media": 459, 149 | "settings_messages": 460, 150 | "settings_stickers": 461, 151 | "settings_chat_decoration": 462, 152 | "settings_phone_change_phone_input": 463, 153 | "settings_phone_change_phone_otp": 464, 154 | "settings_cache": 465, 155 | "settings_profile_avatars": 466, 156 | "settings_about_application": 467, 157 | "settings_privacy_sensitive_content": 479, 158 | "miniapp": 500, 159 | } 160 | 161 | @classmethod 162 | def get_screen_id(cls, screen_name: str) -> int: 163 | screen_id = cls.SCREENS.get(screen_name) 164 | 165 | if screen_id is None: 166 | raise ValueError(f"Unknown screen name: {screen_name}") 167 | 168 | return screen_id 169 | 170 | @classmethod 171 | def can_navigate(cls, from_screen: str, to_screen: str) -> bool: 172 | if from_screen == to_screen: 173 | return True 174 | return to_screen in cls.SCREENS_GRAPH.get(from_screen, []) 175 | 176 | @classmethod 177 | def get_random_navigation(cls, screen_name: str) -> str: 178 | return random.choice( # nosec B311 179 | cls.SCREENS_GRAPH.get(screen_name, []) 180 | ) 181 | 182 | @classmethod 183 | def get_screen_name(cls, screen_id: int) -> str | None: 184 | for name, id_ in cls.SCREENS.items(): 185 | if id_ == screen_id: 186 | return name 187 | return None 188 | -------------------------------------------------------------------------------- /src/pymax/mixins/self.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from uuid import uuid4 3 | 4 | from pymax.exceptions import Error 5 | from pymax.interfaces import ClientProtocol 6 | from pymax.mixins.utils import MixinsUtils 7 | from pymax.payloads import ( 8 | ChangeProfilePayload, 9 | CreateFolderPayload, 10 | DeleteFolderPayload, 11 | GetFolderPayload, 12 | UpdateFolderPayload, 13 | ) 14 | from pymax.static.enum import Opcode 15 | from pymax.types import Folder, FolderList, FolderUpdate 16 | 17 | 18 | class SelfMixin(ClientProtocol): 19 | async def change_profile( 20 | self, 21 | first_name: str, 22 | last_name: str | None = None, 23 | description: str | None = None, 24 | ) -> bool: 25 | """ 26 | Изменяет информацию профиля текущего пользователя. 27 | 28 | :param first_name: Имя пользователя. 29 | :type first_name: str 30 | :param last_name: Фамилия пользователя. По умолчанию None. 31 | :type last_name: str | None 32 | :param description: Описание профиля. По умолчанию None. 33 | :type description: str | None 34 | :return: True, если профиль успешно изменен. 35 | :rtype: bool 36 | """ 37 | 38 | payload = ChangeProfilePayload( 39 | first_name=first_name, 40 | last_name=last_name, 41 | description=description, 42 | ).model_dump( 43 | by_alias=True, 44 | exclude_none=True, 45 | ) 46 | 47 | data = await self._send_and_wait(opcode=Opcode.PROFILE, payload=payload) 48 | 49 | if data.get("payload", {}).get("error"): 50 | MixinsUtils.handle_error(data) 51 | 52 | return True 53 | 54 | async def create_folder( 55 | self, title: str, chat_include: list[int], filters: list[Any] | None = None 56 | ) -> FolderUpdate: 57 | """ 58 | Создает новую папку для группировки чатов. 59 | 60 | :param title: Название папки. 61 | :type title: str 62 | :param chat_include: Список ID чатов для включения в папку. 63 | :type chat_include: list[int] 64 | :param filters: Список фильтров для папки (опциональный параметр). 65 | :type filters: list[Any] | None 66 | :return: Объект FolderUpdate с информацией о созданной папке. 67 | :rtype: FolderUpdate 68 | """ 69 | self.logger.info("Creating folder") 70 | 71 | payload = CreateFolderPayload( 72 | id=str(uuid4()), 73 | title=title, 74 | include=chat_include, 75 | filters=filters or [], 76 | ).model_dump(by_alias=True) 77 | 78 | data = await self._send_and_wait(opcode=Opcode.FOLDERS_UPDATE, payload=payload) 79 | 80 | if data.get("payload", {}).get("error"): 81 | MixinsUtils.handle_error(data) 82 | 83 | return FolderUpdate.from_dict(data.get("payload", {})) 84 | 85 | async def get_folders(self, folder_sync: int = 0) -> FolderList: 86 | """ 87 | Получает список всех папок пользователя. 88 | 89 | :param folder_sync: Синхронизационный маркер папок. По умолчанию 0. 90 | :type folder_sync: int 91 | :return: Объект FolderList с информацией о папках. 92 | :rtype: FolderList 93 | """ 94 | self.logger.info("Fetching folders") 95 | 96 | payload = GetFolderPayload(folder_sync=folder_sync).model_dump(by_alias=True) 97 | 98 | data = await self._send_and_wait(opcode=Opcode.FOLDERS_GET, payload=payload) 99 | 100 | if data.get("payload", {}).get("error"): 101 | MixinsUtils.handle_error(data) 102 | 103 | return FolderList.from_dict(data.get("payload", {})) 104 | 105 | async def update_folder( 106 | self, 107 | folder_id: str, 108 | title: str, 109 | chat_include: list[int] | None = None, 110 | filters: list[Any] | None = None, 111 | options: list[Any] | None = None, 112 | ) -> FolderUpdate | None: 113 | """ 114 | Обновляет параметры существующей папки. 115 | 116 | :param folder_id: Идентификатор папки. 117 | :type folder_id: str 118 | :param title: Название папки. 119 | :type title: str 120 | :param chat_include: Список ID чатов для включения в папку. 121 | :type chat_include: list[int] | None 122 | :param filters: Список фильтров для папки. 123 | :type filters: list[Any] | None 124 | :param options: Список опций для папки. 125 | :type options: list[Any] | None 126 | :return: Объект FolderUpdate с результатом или None. 127 | :rtype: FolderUpdate | None 128 | """ 129 | self.logger.info("Updating folder") 130 | 131 | payload = UpdateFolderPayload( 132 | id=folder_id, 133 | title=title, 134 | include=chat_include or [], 135 | filters=filters or [], 136 | options=options or [], 137 | ).model_dump(by_alias=True, exclude_none=True) 138 | 139 | data = await self._send_and_wait(opcode=Opcode.FOLDERS_UPDATE, payload=payload) 140 | 141 | if data.get("payload", {}).get("error"): 142 | MixinsUtils.handle_error(data) 143 | 144 | return FolderUpdate.from_dict(data.get("payload", {})) 145 | 146 | async def delete_folder(self, folder_id: str) -> FolderUpdate | None: 147 | """ 148 | Удаляет папку. 149 | 150 | :param folder_id: Идентификатор папки для удаления. 151 | :type folder_id: str 152 | :return: Объект FolderUpdate с результатом операции или None. 153 | :rtype: FolderUpdate | None 154 | """ 155 | self.logger.info("Deleting folder") 156 | 157 | payload = DeleteFolderPayload(folder_ids=[folder_id]).model_dump(by_alias=True) 158 | data = await self._send_and_wait(opcode=Opcode.FOLDERS_DELETE, payload=payload) 159 | if data.get("payload", {}).get("error"): 160 | MixinsUtils.handle_error(data) 161 | 162 | return FolderUpdate.from_dict(data.get("payload", {})) 163 | 164 | async def close_all_sessions(self) -> bool: 165 | """ 166 | Закрывает все активные сессии, кроме текущей. 167 | 168 | :return: True, если операция выполнена успешно. 169 | :rtype: bool 170 | """ 171 | self.logger.info("Closing all other sessions") 172 | 173 | data = await self._send_and_wait(opcode=Opcode.SESSIONS_CLOSE, payload={}) 174 | 175 | if data.get("payload", {}).get("error"): 176 | MixinsUtils.handle_error(data) 177 | 178 | return True 179 | 180 | async def logout(self) -> bool: 181 | """ 182 | Выполняет выход из текущей сессии. 183 | 184 | :return: True, если выход выполнен успешно. 185 | :rtype: bool 186 | """ 187 | self.logger.info("Logging out") 188 | 189 | data = await self._send_and_wait(opcode=Opcode.LOGOUT, payload={}) 190 | 191 | if data.get("payload", {}).get("error"): 192 | MixinsUtils.handle_error(data) 193 | 194 | return True 195 | -------------------------------------------------------------------------------- /redocs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | За 5 минут до первого работающего бота. 5 | 6 | Установка 7 | --------- 8 | 9 | .. code-block:: bash 10 | 11 | pip install -U maxapi-python 12 | 13 | Первый бот: Echo 14 | ---------------- 15 | 16 | Самый простой бот — повторяет сообщения пользователя: 17 | 18 | .. code-block:: python 19 | 20 | import asyncio 21 | from pymax import MaxClient 22 | from pymax.types import Message 23 | 24 | client = MaxClient(phone="+79001234567") 25 | 26 | @client.on_message() 27 | async def echo(message: Message) -> None: 28 | if message.text: 29 | await client.send_message( 30 | chat_id=message.chat_id, 31 | text=f"Echo: {message.text}" 32 | ) 33 | 34 | if __name__ == "__main__": 35 | asyncio.run(client.start()) 36 | 37 | Запуск: 38 | 39 | .. code-block:: bash 40 | 41 | python bot.py 42 | 43 | При первом запуске вам потребуется ввести код подтверждения из приложения Max. 44 | 45 | Фильтры сообщений 46 | ------------------ 47 | 48 | Обрабатывать только определённые сообщения: 49 | 50 | .. code-block:: python 51 | 52 | from pymax.filters import Filters 53 | from pymax.types import Message 54 | 55 | # Только из конкретного чата 56 | @client.on_message(Filters.chat(123456)) 57 | async def handle_chat(message: Message) -> None: 58 | await client.send_message( 59 | chat_id=message.chat_id, 60 | text="Это из моего чата!" 61 | ) 62 | 63 | # Только с определённым текстом 64 | @client.on_message(Filters.text("привет")) 65 | async def greet(message: Message) -> None: 66 | await client.send_message( 67 | chat_id=message.chat_id, 68 | text="И тебе привет!" 69 | ) 70 | 71 | # Только личные сообщения 72 | @client.on_message(Filters.dialog()) 73 | async def private_handler(message: Message) -> None: 74 | print(f"Личное сообщение: {message.text}") 75 | 76 | Обработчики событий 77 | -------------------- 78 | 79 | Реагировать на события клиента: 80 | 81 | .. code-block:: python 82 | 83 | from pymax.types import Message, Chat 84 | 85 | @client.on_start() 86 | async def startup() -> None: 87 | print(f"Клиент запущен! ID: {client.me.id}") 88 | 89 | @client.on_message_delete() 90 | async def message_deleted(message: Message) -> None: 91 | print(f"Сообщение удалено: {message.id}") 92 | 93 | @client.on_chat_update() 94 | async def chat_changed(chat: Chat) -> None: 95 | print(f"Чат обновлен: {chat.title}") 96 | 97 | Получение информации 98 | --------------------- 99 | 100 | Информация о пользователе: 101 | 102 | .. code-block:: python 103 | 104 | from pymax.types import Message, User 105 | 106 | @client.on_message() 107 | async def get_user_info(message: Message) -> None: 108 | user: User | None = await client.get_user(message.sender) 109 | if user: 110 | name = user.names[0].first_name if user.names else "Неизвестно" 111 | await client.send_message( 112 | chat_id=message.chat_id, 113 | text=f"Привет, {name}!" 114 | ) 115 | 116 | Информация о чате: 117 | 118 | .. code-block:: python 119 | 120 | from pymax.types import Message, Chat 121 | 122 | @client.on_message() 123 | async def get_chat_info(message: Message) -> None: 124 | chat: Chat | None = await client.get_chat(message.chat_id) 125 | if chat: 126 | await client.send_message( 127 | chat_id=message.chat_id, 128 | text=f"Название чата: {chat.title}" 129 | ) 130 | 131 | История сообщений: 132 | 133 | .. code-block:: python 134 | 135 | from pymax.filters import Filters 136 | from pymax.types import Message 137 | 138 | @client.on_message(Filters.text("история")) 139 | async def fetch_history(message: Message) -> None: 140 | history = await client.fetch_history( 141 | chat_id=message.chat_id, 142 | limit=10 143 | ) 144 | 145 | text = "Последние 10 сообщений:\n" 146 | for msg in history: 147 | text += f"- {msg.text}\n" 148 | 149 | await client.send_message( 150 | chat_id=message.chat_id, 151 | text=text 152 | ) 153 | 154 | Отправка файлов 155 | ---------------- 156 | 157 | .. code-block:: python 158 | 159 | from pymax.filters import Filters 160 | from pymax.files import File 161 | from pymax.types import Message 162 | 163 | @client.on_message(Filters.text("файл")) 164 | async def send_file(message: Message) -> None: 165 | file = File(path="document.pdf") 166 | await client.send_message( 167 | chat_id=message.chat_id, 168 | text="Вот документ", 169 | attachment=file 170 | ) 171 | 172 | Полный пример: простой помощник 173 | -------------------------------- 174 | 175 | .. code-block:: python 176 | 177 | import asyncio 178 | from pymax import MaxClient 179 | from pymax.filters import Filters 180 | from pymax.types import Message, User 181 | 182 | client = MaxClient( 183 | phone="+79001234567", 184 | work_dir="./cache" 185 | ) 186 | 187 | @client.on_start() 188 | async def on_start() -> None: 189 | print(f"Помощник запущен! ID: {client.me.id}") 190 | await client.send_message( 191 | chat_id=123456, 192 | text="Я запустился!", 193 | notify=False 194 | ) 195 | 196 | @client.task(minutes=1) 197 | async def status_check() -> None: 198 | """Проверка статуса каждую минуту""" 199 | print("Помощник все еще работает!") 200 | 201 | @client.on_message(Filters.text("привет")) 202 | async def hello(message: Message) -> None: 203 | user: User | None = await client.get_user(message.sender) 204 | name = user.names[0].first_name if user and user.names else "друг" 205 | 206 | await client.send_message( 207 | chat_id=message.chat_id, 208 | text=f"Привет, {name}! 👋" 209 | ) 210 | 211 | @client.on_message(Filters.text("помощь")) 212 | async def help_command(message: Message) -> None: 213 | help_text = """Доступные команды: 214 | - привет — приветствие 215 | - помощь — показать эту справку 216 | - время — текущее время 217 | """ 218 | await client.send_message( 219 | chat_id=message.chat_id, 220 | text=help_text 221 | ) 222 | 223 | @client.on_message(Filters.text("время")) 224 | async def time_command(message: Message) -> None: 225 | from datetime import datetime 226 | current_time = datetime.now().strftime("%H:%M:%S") 227 | 228 | await client.send_message( 229 | chat_id=message.chat_id, 230 | text=f"Текущее время: {current_time} ⏰" 231 | ) 232 | 233 | 234 | if __name__ == "__main__": 235 | asyncio.run(client.start()) 236 | 237 | Дальше 238 | ------ 239 | 240 | - Смотрите :doc:`guides` для подробных гайдов 241 | - Смотрите :doc:`examples` для больше примеров 242 | - Смотрите :doc:`clients` для полного справочника методов 243 | 244 | .. note:: 245 | 246 | Обратитесь к :doc:`installation` если у вас есть проблемы с установкой. 247 | -------------------------------------------------------------------------------- /examples/telegram_bridge.py: -------------------------------------------------------------------------------- 1 | # Всякая всячина 2 | import asyncio 3 | 4 | # Библиотека для работы с файлами 5 | from io import BytesIO 6 | 7 | import aiohttp 8 | 9 | # Импорты библиотеки aiogram для TG-бота 10 | from aiogram import Bot, Dispatcher, types 11 | 12 | # Импорты библиотеки PyMax 13 | from pymax import MaxClient, Message, Photo 14 | from pymax.types import FileAttach, PhotoAttach, VideoAttach 15 | 16 | # УСТАНОВИТЬ ЗАВИСИМОСТИ - pip install maxapi-python aiogram==3.22.0 17 | 18 | 19 | # Настройки ботов 20 | PHONE = "+79998887766" # Номер телефона Max 21 | telegram_bot_TOKEN = "token" # Токен TG-бота 22 | 23 | chats = { # В формате айди чата в Max: айди чата в Telegram (айди чата Max можно узнать из ссылки на чат в веб версии web.max.ru) 24 | -68690734055662: -1003177746657, 25 | } 26 | 27 | 28 | # Создаём зеркальный массив для отправки из Telegram в Max 29 | chats_telegram = {value: key for key, value in chats.items()} 30 | 31 | 32 | # Инициализация клиента MAX 33 | client = MaxClient(phone=PHONE, work_dir="cache", reconnect=True) 34 | 35 | 36 | # Инициализация TG-бота 37 | telegram_bot = Bot(token=telegram_bot_TOKEN) 38 | dp = Dispatcher() 39 | 40 | 41 | # Обработчик входящих сообщений MAX 42 | @client.on_message() 43 | async def handle_message(message: Message) -> None: 44 | try: 45 | tg_id = chats[message.chat_id] 46 | except KeyError: 47 | return 48 | 49 | sender = await client.get_user(user_id=message.sender) 50 | 51 | if message.attaches: 52 | for attach in message.attaches: 53 | # Проверка на видео 54 | if isinstance(attach, VideoAttach): 55 | async with aiohttp.ClientSession() as session: 56 | try: 57 | # Получаем видео по айди 58 | video = await client.get_video_by_id( 59 | chat_id=message.chat_id, 60 | message_id=message.id, 61 | video_id=attach.video_id, 62 | ) 63 | 64 | # Загружаем видео по URL 65 | async with session.get(video.url) as response: 66 | response.raise_for_status() # Проверка на ошибки HTTP 67 | video_bytes = BytesIO(await response.read()) 68 | video_bytes.name = response.headers.get("X-File-Name") 69 | 70 | # Отправляем видео через телеграм бота 71 | await telegram_bot.send_video( 72 | chat_id=tg_id, 73 | caption=f"{sender.names[0].name}: {message.text}", 74 | video=types.BufferedInputFile( 75 | video_bytes.getvalue(), filename=video_bytes.name 76 | ), 77 | ) 78 | 79 | # Очищаем память 80 | video_bytes.close() 81 | 82 | except aiohttp.ClientError as e: 83 | print(f"Ошибка при загрузке видео: {e}") 84 | except Exception as e: 85 | print(f"Ошибка при отправке видео: {e}") 86 | 87 | # Проверка на изображение 88 | elif isinstance(attach, PhotoAttach): 89 | async with aiohttp.ClientSession() as session: 90 | try: 91 | # Загружаем изображение по URL 92 | async with session.get(attach.base_url) as response: 93 | response.raise_for_status() # Проверка на ошибки HTTP 94 | photo_bytes = BytesIO(await response.read()) 95 | photo_bytes.name = response.headers.get("X-File-Name") 96 | 97 | # Отправляем фото через телеграм бота 98 | await telegram_bot.send_photo( 99 | chat_id=tg_id, 100 | caption=f"{sender.names[0].name}: {message.text}", 101 | photo=types.BufferedInputFile( 102 | photo_bytes.getvalue(), filename=photo_bytes.name 103 | ), 104 | ) 105 | 106 | # Очищаем память 107 | photo_bytes.close() 108 | 109 | except aiohttp.ClientError as e: 110 | print(f"Ошибка при загрузке изображения: {e}") 111 | except Exception as e: 112 | print(f"Ошибка при отправке фото: {e}") 113 | 114 | # Проверка на файл 115 | elif isinstance(attach, FileAttach): 116 | async with aiohttp.ClientSession() as session: 117 | try: 118 | # Получаем файл по айди 119 | file = await client.get_file_by_id( 120 | chat_id=message.chat_id, 121 | message_id=message.id, 122 | file_id=attach.file_id, 123 | ) 124 | 125 | # Загружаем файл по URL 126 | async with session.get(file.url) as response: 127 | response.raise_for_status() # Проверка на ошибки HTTP 128 | file_bytes = BytesIO(await response.read()) 129 | file_bytes.name = response.headers.get("X-File-Name") 130 | 131 | # Отправляем файл через телеграм бота 132 | await telegram_bot.send_document( 133 | chat_id=tg_id, 134 | caption=f"{sender.names[0].name}: {message.text}", 135 | document=types.BufferedInputFile( 136 | file_bytes.getvalue(), filename=file_bytes.name 137 | ), 138 | ) 139 | 140 | # Очищаем память 141 | file_bytes.close() 142 | 143 | except aiohttp.ClientError as e: 144 | print(f"Ошибка при загрузке файла: {e}") 145 | except Exception as e: 146 | print(f"Ошибка при отправке файла: {e}") 147 | else: 148 | await telegram_bot.send_message( 149 | chat_id=tg_id, text=f"{sender.names[0].name}: {message.text}" 150 | ) 151 | 152 | 153 | # Обработчик запуска клиента, функция выводит все сообщения из чата "Избранное" 154 | @client.on_start 155 | async def handle_start() -> None: 156 | print("Клиент запущен") 157 | 158 | # Получение истории сообщений 159 | history = await client.fetch_history(chat_id=0) 160 | if history: 161 | for message in history: 162 | user = await client.get_user(message.sender) 163 | if user: 164 | print(f"{user.names[0].name}: {message.text}") 165 | 166 | 167 | # Обработчик сообщений Telegram 168 | @dp.message() 169 | async def handle_tg_message(message: types.Message, bot: Bot) -> None: 170 | max_id = chats_telegram[message.chat.id] 171 | await client.send_message( 172 | chat_id=max_id, 173 | text=f"{message.from_user.first_name}: {message.text}", 174 | notify=True, 175 | ) 176 | 177 | 178 | # Раннер ботов 179 | async def main() -> None: 180 | # TG-бот в фоне 181 | telegram_bot_task = asyncio.create_task(dp.start_polling(telegram_bot)) 182 | 183 | try: 184 | await client.start() 185 | finally: 186 | await client.close() 187 | telegram_bot_task.cancel() 188 | 189 | 190 | if __name__ == "__main__": 191 | try: 192 | asyncio.run(main()) 193 | except KeyboardInterrupt: 194 | print("Программа остановлена пользователем.") 195 | -------------------------------------------------------------------------------- /src/pymax/payloads.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import AliasChoices, BaseModel, Field 4 | 5 | from pymax.static.constant import ( 6 | DEFAULT_APP_VERSION, 7 | DEFAULT_BUILD_NUMBER, 8 | DEFAULT_CLIENT_SESSION_ID, 9 | DEFAULT_DEVICE_LOCALE, 10 | DEFAULT_DEVICE_NAME, 11 | DEFAULT_DEVICE_TYPE, 12 | DEFAULT_LOCALE, 13 | DEFAULT_OS_VERSION, 14 | DEFAULT_SCREEN, 15 | DEFAULT_TIMEZONE, 16 | DEFAULT_USER_AGENT, 17 | ) 18 | from pymax.static.enum import AttachType, AuthType, ContactAction 19 | 20 | 21 | def to_camel(string: str) -> str: 22 | parts = string.split("_") 23 | return parts[0] + "".join(word.capitalize() for word in parts[1:]) 24 | 25 | 26 | class CamelModel(BaseModel): 27 | model_config = { 28 | "alias_generator": to_camel, 29 | "populate_by_name": True, 30 | "arbitrary_types_allowed": True, 31 | } 32 | 33 | 34 | class BaseWebSocketMessage(BaseModel): 35 | ver: Literal[10, 11] = 11 36 | cmd: int 37 | seq: int 38 | opcode: int 39 | payload: dict[str, Any] 40 | 41 | 42 | class RequestCodePayload(CamelModel): 43 | phone: str 44 | type: AuthType = AuthType.START_AUTH 45 | language: str = "ru" 46 | 47 | 48 | class SendCodePayload(CamelModel): 49 | token: str 50 | verify_code: str 51 | auth_token_type: AuthType = AuthType.CHECK_CODE 52 | 53 | 54 | class SyncPayload(CamelModel): 55 | interactive: bool = True 56 | token: str 57 | chats_sync: int = 0 58 | contacts_sync: int = 0 59 | presence_sync: int = 0 60 | drafts_sync: int = 0 61 | chats_count: int = 40 62 | 63 | 64 | class ReplyLink(CamelModel): 65 | type: str = "REPLY" 66 | message_id: str 67 | 68 | 69 | class UploadPayload(CamelModel): 70 | count: int = 1 71 | 72 | 73 | class AttachPhotoPayload(CamelModel): 74 | type: AttachType = Field(default=AttachType.PHOTO, alias="_type") 75 | photo_token: str 76 | 77 | 78 | class VideoAttachPayload(CamelModel): 79 | type: AttachType = Field(default=AttachType.VIDEO, alias="_type") 80 | video_id: int 81 | token: str 82 | 83 | 84 | class AttachFilePayload(CamelModel): 85 | type: AttachType = Field(default=AttachType.FILE, alias="_type") 86 | file_id: int 87 | 88 | 89 | class MessageElement(CamelModel): 90 | type: str 91 | from_: int = Field(..., alias="from") 92 | length: int 93 | 94 | 95 | class SendMessagePayloadMessage(CamelModel): 96 | text: str 97 | cid: int 98 | elements: list[MessageElement] 99 | attaches: list[AttachPhotoPayload | AttachFilePayload | VideoAttachPayload] 100 | link: ReplyLink | None = None 101 | 102 | 103 | class SendMessagePayload(CamelModel): 104 | chat_id: int 105 | message: SendMessagePayloadMessage 106 | notify: bool = False 107 | 108 | 109 | class EditMessagePayload(CamelModel): 110 | chat_id: int 111 | message_id: int 112 | text: str 113 | elements: list[MessageElement] 114 | attaches: list[AttachPhotoPayload | AttachFilePayload | VideoAttachPayload] 115 | 116 | 117 | class DeleteMessagePayload(CamelModel): 118 | chat_id: int 119 | message_ids: list[int] 120 | for_me: bool = False 121 | 122 | 123 | class FetchContactsPayload(CamelModel): 124 | contact_ids: list[int] 125 | 126 | 127 | class FetchHistoryPayload(CamelModel): 128 | chat_id: int 129 | from_time: int = Field( 130 | validation_alias=AliasChoices("from_time", "from"), 131 | serialization_alias="from", 132 | ) 133 | forward: int 134 | backward: int = 200 135 | get_messages: bool = True 136 | 137 | 138 | class ChangeProfilePayload(CamelModel): 139 | first_name: str 140 | last_name: str | None = None 141 | description: str | None = None 142 | 143 | 144 | class ResolveLinkPayload(CamelModel): 145 | link: str 146 | 147 | 148 | class PinMessagePayload(CamelModel): 149 | chat_id: int 150 | notify_pin: bool 151 | pin_message_id: int 152 | 153 | 154 | class CreateGroupAttach(CamelModel): 155 | type: Literal["CONTROL"] = Field("CONTROL", alias="_type") 156 | event: str = "new" 157 | chat_type: str = "CHAT" 158 | title: str 159 | user_ids: list[int] 160 | 161 | 162 | class CreateGroupMessage(CamelModel): 163 | cid: int 164 | attaches: list[CreateGroupAttach] 165 | 166 | 167 | class CreateGroupPayload(CamelModel): 168 | message: CreateGroupMessage 169 | notify: bool = True 170 | 171 | 172 | class InviteUsersPayload(CamelModel): 173 | chat_id: int 174 | user_ids: list[int] 175 | show_history: bool 176 | operation: str = "add" 177 | 178 | 179 | class RemoveUsersPayload(CamelModel): 180 | chat_id: int 181 | user_ids: list[int] 182 | operation: str = "remove" 183 | clean_msg_period: int 184 | 185 | 186 | class ChangeGroupSettingsOptions(BaseModel): 187 | ONLY_OWNER_CAN_CHANGE_ICON_TITLE: bool | None 188 | ALL_CAN_PIN_MESSAGE: bool | None 189 | ONLY_ADMIN_CAN_ADD_MEMBER: bool | None 190 | ONLY_ADMIN_CAN_CALL: bool | None 191 | MEMBERS_CAN_SEE_PRIVATE_LINK: bool | None 192 | 193 | 194 | class ChangeGroupSettingsPayload(CamelModel): 195 | chat_id: int 196 | options: ChangeGroupSettingsOptions 197 | 198 | 199 | class ChangeGroupProfilePayload(CamelModel): 200 | chat_id: int 201 | theme: str | None 202 | description: str | None 203 | 204 | 205 | class GetGroupMembersPayload(CamelModel): 206 | type: Literal["MEMBER"] = "MEMBER" 207 | marker: int | None = None 208 | chat_id: int 209 | count: int 210 | 211 | 212 | class SearchGroupMembersPayload(CamelModel): 213 | type: Literal["MEMBER"] = "MEMBER" 214 | query: str 215 | chat_id: int 216 | 217 | 218 | class NavigationEventParams(BaseModel): 219 | action_id: int 220 | screen_to: int 221 | screen_from: int | None = None 222 | source_id: int 223 | session_id: int 224 | 225 | 226 | class NavigationEventPayload(CamelModel): 227 | event: str 228 | time: int 229 | type: str = "NAV" 230 | user_id: int 231 | params: NavigationEventParams 232 | 233 | 234 | class NavigationPayload(CamelModel): 235 | events: list[NavigationEventPayload] 236 | 237 | 238 | class GetVideoPayload(CamelModel): 239 | chat_id: int 240 | message_id: int | str 241 | video_id: int 242 | 243 | 244 | class GetFilePayload(CamelModel): 245 | chat_id: int 246 | message_id: str | int 247 | file_id: int 248 | 249 | 250 | class SearchByPhonePayload(CamelModel): 251 | phone: str 252 | 253 | 254 | class JoinChatPayload(CamelModel): 255 | link: str 256 | 257 | 258 | class ReactionInfoPayload(CamelModel): 259 | reaction_type: str = "EMOJI" 260 | id: str 261 | 262 | 263 | class AddReactionPayload(CamelModel): 264 | chat_id: int 265 | message_id: str 266 | reaction: ReactionInfoPayload 267 | 268 | 269 | class GetReactionsPayload(CamelModel): 270 | chat_id: int 271 | message_ids: list[str] 272 | 273 | 274 | class RemoveReactionPayload(CamelModel): 275 | chat_id: int 276 | message_id: str 277 | 278 | 279 | class UserAgentPayload(CamelModel): 280 | device_type: str = Field(default=DEFAULT_DEVICE_TYPE) 281 | locale: str = Field(default=DEFAULT_LOCALE) 282 | device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE) 283 | os_version: str = Field(default=DEFAULT_OS_VERSION) 284 | device_name: str = Field(default=DEFAULT_DEVICE_NAME) 285 | header_user_agent: str = Field(default=DEFAULT_USER_AGENT) 286 | app_version: str = Field(default=DEFAULT_APP_VERSION) 287 | screen: str = Field(default=DEFAULT_SCREEN) 288 | timezone: str = Field(default=DEFAULT_TIMEZONE) 289 | client_session_id: int = Field(default=DEFAULT_CLIENT_SESSION_ID) 290 | build_number: int = Field(default=DEFAULT_BUILD_NUMBER) 291 | 292 | 293 | class ReworkInviteLinkPayload(CamelModel): 294 | revoke_private_link: bool = True 295 | chat_id: int 296 | 297 | 298 | class ContactActionPayload(CamelModel): 299 | contact_id: int 300 | action: ContactAction 301 | 302 | 303 | class RegisterPayload(CamelModel): 304 | last_name: str | None = None 305 | first_name: str 306 | token: str 307 | token_type: AuthType = AuthType.REGISTER 308 | 309 | 310 | class CreateFolderPayload(CamelModel): 311 | id: str 312 | title: str 313 | include: list[int] 314 | filters: list[Any] = [] 315 | 316 | 317 | class GetChatInfoPayload(CamelModel): 318 | chat_ids: list[int] 319 | 320 | 321 | class GetFolderPayload(CamelModel): 322 | folder_sync: int = 0 323 | 324 | 325 | class UpdateFolderPayload(CamelModel): 326 | id: str 327 | title: str 328 | include: list[int] 329 | filters: list[Any] = [] 330 | options: list[Any] = [] 331 | 332 | 333 | class DeleteFolderPayload(CamelModel): 334 | folder_ids: list[str] 335 | 336 | 337 | class LeaveChatPayload(CamelModel): 338 | chat_id: int 339 | 340 | 341 | class FetchChatsPayload(CamelModel): 342 | marker: int 343 | -------------------------------------------------------------------------------- /src/pymax/mixins/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pymax.exceptions import Error, ResponseError, ResponseStructureError 4 | from pymax.interfaces import ClientProtocol 5 | from pymax.mixins.utils import MixinsUtils 6 | from pymax.payloads import ( 7 | ContactActionPayload, 8 | FetchContactsPayload, 9 | SearchByPhonePayload, 10 | ) 11 | from pymax.static.enum import ContactAction, Opcode 12 | from pymax.types import Contact, Session, User 13 | 14 | 15 | class UserMixin(ClientProtocol): 16 | def get_cached_user(self, user_id: int) -> User | None: 17 | """ 18 | Получает пользователя из кеша по его идентификатору. 19 | 20 | Проверяет внутренний кеш пользователей и возвращает объект User 21 | если пользователь был ранее загружен. 22 | 23 | :param user_id: Идентификатор пользователя. 24 | :type user_id: int 25 | :return: Объект User из кеша или None, если пользователь не найден. 26 | :rtype: User | None 27 | """ 28 | user = self._users.get(user_id) 29 | self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user)) 30 | return user 31 | 32 | async def get_users(self, user_ids: list[int]) -> list[User]: 33 | """ 34 | Получает информацию о пользователях по их идентификаторам. 35 | 36 | Метод использует внутренний кеш для избежания повторных запросов. 37 | Если пользователь уже загружен, берется из кеша, иначе выполняется 38 | сетевой запрос к серверу. 39 | 40 | :param user_ids: Список идентификаторов пользователей. 41 | :type user_ids: list[int] 42 | :return: Список объектов User в порядке, соответствующем входному списку. 43 | :rtype: list[User] 44 | """ 45 | self.logger.debug("get_users ids=%s", user_ids) 46 | cached = {uid: self._users[uid] for uid in user_ids if uid in self._users} 47 | missing_ids = [uid for uid in user_ids if uid not in self._users] 48 | 49 | if missing_ids: 50 | self.logger.debug("Fetching missing users: %s", missing_ids) 51 | fetched_users = await self.fetch_users(missing_ids) 52 | if fetched_users: 53 | for user in fetched_users: 54 | self._users[user.id] = user 55 | cached[user.id] = user 56 | 57 | ordered = [cached[uid] for uid in user_ids if uid in cached] 58 | self.logger.debug("get_users result_count=%d", len(ordered)) 59 | return ordered 60 | 61 | async def get_user(self, user_id: int) -> User | None: 62 | """ 63 | Получает информацию о пользователе по его идентификатору. 64 | 65 | Метод использует внутренний кеш. Если пользователь уже загружен, 66 | возвращает его из кеша, иначе выполняет запрос к серверу. 67 | 68 | :param user_id: Идентификатор пользователя. 69 | :type user_id: int 70 | :return: Объект User или None, если пользователь не найден. 71 | :rtype: User | None 72 | """ 73 | self.logger.debug("get_user id=%s", user_id) 74 | if user_id in self._users: 75 | return self._users[user_id] 76 | 77 | users = await self.fetch_users([user_id]) 78 | if users: 79 | self._users[user_id] = users[0] 80 | return users[0] 81 | return None 82 | 83 | async def fetch_users(self, user_ids: list[int]) -> list[User]: 84 | """ 85 | Загружает информацию о пользователях с сервера. 86 | 87 | Запрашивает данные о пользователях по их идентификаторам и добавляет 88 | их в внутренний кеш. 89 | 90 | :param user_ids: Список идентификаторов пользователей для загрузки. 91 | :type user_ids: list[int] 92 | :return: Список загруженных объектов User. 93 | :rtype: list[User] 94 | """ 95 | self.logger.info("Fetching users count=%d", len(user_ids)) 96 | 97 | payload = FetchContactsPayload(contact_ids=user_ids).model_dump(by_alias=True) 98 | 99 | data = await self._send_and_wait(opcode=Opcode.CONTACT_INFO, payload=payload) 100 | 101 | if data.get("payload", {}).get("error"): 102 | MixinsUtils.handle_error(data) 103 | 104 | users = [User.from_dict(u) for u in data["payload"].get("contacts", [])] 105 | for user in users: 106 | self._users[user.id] = user 107 | 108 | self.logger.debug("Fetched users: %d", len(users)) 109 | return users 110 | 111 | async def search_by_phone(self, phone: str) -> User: 112 | """ 113 | Выполняет поиск пользователя по номеру телефона. 114 | 115 | :param phone: Номер телефона пользователя. 116 | :type phone: str 117 | :return: Объект User с найденными данными пользователя. 118 | :rtype: User 119 | :raises Error: Если пользователь не найден или произошла ошибка. 120 | """ 121 | self.logger.info("Searching user by phone: %s", phone) 122 | 123 | payload = SearchByPhonePayload(phone=phone).model_dump(by_alias=True) 124 | 125 | data = await self._send_and_wait( 126 | opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload 127 | ) 128 | 129 | if data.get("payload", {}).get("error"): 130 | MixinsUtils.handle_error(data) 131 | 132 | if not data.get("payload"): 133 | raise Error("no_payload", "No payload in response", "User Error") 134 | 135 | user = User.from_dict(data["payload"]["contact"]) 136 | if not user: 137 | raise Error("no_user", "User data missing in response", "User Error") 138 | 139 | self._users[user.id] = user 140 | self.logger.debug("Found user by phone: %s", user) 141 | return user 142 | 143 | async def get_sessions(self) -> list[Session]: 144 | """ 145 | Получает информацию о всех активных сессиях пользователя. 146 | 147 | Возвращает список всех сессий, в которых авторизован пользователь. 148 | 149 | :return: Список объектов Session. 150 | :rtype: list[Session] 151 | :raises Error: Если произошла ошибка при получении данных. 152 | """ 153 | self.logger.info("Fetching sessions") 154 | 155 | data = await self._send_and_wait(opcode=Opcode.SESSIONS_INFO, payload={}) 156 | 157 | if data.get("payload", {}).get("error"): 158 | MixinsUtils.handle_error(data) 159 | 160 | if not data.get("payload"): 161 | raise Error("no_payload", "No payload in response", "Session Error") 162 | 163 | return [Session.from_dict(s) for s in data["payload"].get("sessions", [])] 164 | 165 | async def _contact_action(self, payload: ContactActionPayload) -> dict[str, Any]: 166 | data = await self._send_and_wait( 167 | opcode=Opcode.CONTACT_UPDATE, # 34 168 | payload=payload.model_dump(by_alias=True), 169 | ) 170 | response_payload = data.get("payload") 171 | if not isinstance(response_payload, dict): 172 | raise ResponseStructureError("Invalid response structure") 173 | if error := response_payload.get("error"): 174 | raise ResponseError(error) 175 | return response_payload 176 | 177 | async def add_contact(self, contact_id: int) -> Contact: 178 | """ 179 | Добавляет контакт в список контактов 180 | 181 | :param contact_id: ID контакта 182 | :type contact_id: int 183 | :return: Объект контакта 184 | :rtype: Contact 185 | :raises ResponseStructureError: Если структура ответа неверна 186 | """ 187 | payload = await self._contact_action( 188 | ContactActionPayload(contact_id=contact_id, action=ContactAction.ADD) 189 | ) 190 | contact_dict = payload.get("contact") 191 | if isinstance(contact_dict, dict): 192 | return Contact.from_dict(contact_dict) 193 | raise ResponseStructureError("Wrong contact structure in response") 194 | 195 | async def remove_contact(self, contact_id: int) -> Literal[True]: 196 | """ 197 | Удаляет контакт из списка контактов 198 | 199 | :param contact_id: ID контакта 200 | :type contact_id: int 201 | :return: True если успешно 202 | :rtype: Literal[True] 203 | :raises ResponseStructureError: Если структура ответа неверна 204 | """ 205 | await self._contact_action( 206 | ContactActionPayload(contact_id=contact_id, action=ContactAction.REMOVE) 207 | ) 208 | return True 209 | 210 | def get_chat_id(self, first_user_id: int, second_user_id: int) -> int: 211 | """ 212 | Получение айди лс (диалога) 213 | 214 | :param first_user_id: ID первого пользователя 215 | :type first_user_id: int 216 | :param second_user_id: ID второго пользователя 217 | :type second_user_id: int 218 | :return: Айди диалога 219 | :rtype: int 220 | """ 221 | return first_user_id ^ second_user_id 222 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /redocs/source/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | from time import time 5 | from typing import Any 6 | 7 | import pymax 8 | from pymax import MaxClient, Message, ReactionInfo, SocketMaxClient 9 | from pymax.files import File, Video 10 | from pymax.payloads import UserAgentPayload 11 | from pymax.static.enum import AttachType, Opcode 12 | from pymax.types import Chat 13 | 14 | phone = "+7903223111" 15 | headers = UserAgentPayload(device_type="WEB") 16 | 17 | client = MaxClient( 18 | phone=phone, 19 | work_dir="cache", 20 | reconnect=False, 21 | logger=None, 22 | headers=headers, 23 | ) 24 | client.logger.setLevel(logging.INFO) 25 | 26 | 27 | @client.on_raw_receive 28 | async def handle_raw_receive(data: dict[str, Any]) -> None: 29 | print(f"Raw data received: {data}") 30 | 31 | 32 | @client.task(seconds=10) 33 | async def periodic_task() -> None: 34 | # print(f"Periodic task executed at {datetime.datetime.now()}") 35 | ... 36 | 37 | 38 | @client.on_start 39 | async def handle_start() -> None: 40 | print(f"Client started as {client.me.names[0].first_name}!") 41 | 42 | chat_id = -1 43 | max_messages = 1000 44 | messages = [] 45 | from_time = int(time() * 1000) 46 | while len(messages) < max_messages: 47 | r = await client.fetch_history( 48 | chat_id=chat_id, from_time=from_time, backward=30 49 | ) 50 | if not r: 51 | break 52 | from_time = r[0].time 53 | messages.extend(r) 54 | print(f"First message time: {from_time}, id: {r[0].id}, text: {r[0].text}") 55 | print(f"Last message time: {from_time}, id: {r[-1].id}, text: {r[-1].text}") 56 | print(f"Loaded {len(messages)}/{max_messages} messages...") 57 | # channel = await client.resolve_channel_by_name("fm92") 58 | # if channel: 59 | # print(f"Resolved channel by name: {channel.title}, ID: {channel.id}") 60 | # else: 61 | # print("Channel not found by name.") 62 | 63 | # channel = await client.join_channel(link) 64 | # if channel: 65 | # print(f"Joined channel: {channel.title}, ID: {channel.id}") 66 | # else: 67 | # print("Failed to join channel.") 68 | # await client.send_message( 69 | # "Hello! The client has started successfully.", 70 | # chat_id=2265456546456, 71 | # notify=True, 72 | # ) 73 | # folder_update = await client.create_folder( 74 | # title="My Folder", 75 | # chat_include=[0], 76 | # ) 77 | # print(f"Folder created: {folder_update.folder.title}") 78 | # video_path = "tests2/test.mp4" 79 | # video_file = Video(path=video_path) 80 | 81 | # await client.send_message( 82 | # text="Here is the video you requested.", 83 | # chat_id=0, 84 | # attachment=video_file, 85 | # notify=True, 86 | # ) 87 | # chat_id = -6970655 88 | # for chat in client.chats: 89 | # if chat.id == chat_id: 90 | # print(f"Found chat: {chat.title}, ID: {chat.id}") 91 | # members_count = chat.participants_count 92 | # marker = 0 93 | # member_list = [] 94 | # while len(member_list) < members_count: 95 | # await asyncio.sleep(10) 96 | # r = await client.load_members( 97 | # chat_id=chat_id, 98 | # marker=marker, 99 | # count=200, 100 | # ) 101 | # members, marker = r 102 | # member_list.extend(members) 103 | # print(f"Loaded {len(member_list)}/{members_count} members...") 104 | # for member in member_list: 105 | # print( 106 | # f"Member {member.contact.names[0].first_name}, ID: {member.contact.id}" 107 | # ) 108 | # r = await client.load_members(chat_id=chat_id, count=50) 109 | # print(f"Loaded {len(r)} members from chat {chat_id}") 110 | # member_list, marker = r 111 | # for member in member_list: 112 | # print(f"Member {member.contact.names[0].first_name}, ID: {member.contact.id}") 113 | 114 | 115 | # @client.on_reaction_change 116 | # async def handle_reaction_change( 117 | # message_id: str, chat_id: int, reaction_info: ReactionInfo 118 | # ) -> None: 119 | # print( 120 | # f"Reaction changed on message {message_id} in chat {chat_id}: " 121 | # f"Total count: {reaction_info.total_count}, " 122 | # f"Your reaction: {reaction_info.your_reaction}, " 123 | # f"Counters: {reaction_info.counters[0].reaction}={reaction_info.counters[0].count}" 124 | # ) 125 | 126 | 127 | # @client.on_chat_update 128 | # async def handle_chat_update(chat: Chat) -> None: 129 | # print(f"Chat updated: {chat.id}, new title: {chat.title}") 130 | 131 | 132 | @client.on_message() 133 | async def handle_message(message: Message) -> None: 134 | print( 135 | f"New message in chat {message.chat_id} from {message.sender}: {message.text}" 136 | ) 137 | # if message.link and message.link.message.attaches: 138 | # for attach in message.link.message.attaches: 139 | # print(f"Link attach type: {attach.type}") 140 | 141 | 142 | @client.on_message_edit() 143 | async def handle_edited_message(message: Message) -> None: 144 | print(f"Edited message in chat {message.chat_id}: {message.text}") 145 | 146 | 147 | @client.on_message_delete() 148 | async def handle_deleted_message(message: Message) -> None: 149 | print(f"Deleted message in chat {message.chat_id}: {message.id}") 150 | 151 | 152 | # async def login_flow_test(): 153 | # await client.connect() 154 | # temp_token = await client.request_code(phone) 155 | # code = input("Введите код: ").strip() 156 | # await client.login_with_code(temp_token, code) 157 | 158 | 159 | # asyncio.run(login_flow_test()) 160 | 161 | # @client.on_message(filter=Filter(chat_id=0)) 162 | # async def handle_message(message: Message) -> None: 163 | # print(str(message.sender) + ": " + message.text) 164 | 165 | 166 | # @client.on_message_edit() 167 | # async def handle_edited_message(message: Message) -> None: 168 | # print(f"Edited message in chat {message.chat_id}: {message.text}") 169 | 170 | 171 | # @client.on_message_delete() 172 | # async def handle_deleted_message(message: Message) -> None: 173 | # print(f"Deleted message in chat {message.chat_id}: {message.id}") 174 | 175 | 176 | # @client.on_start 177 | # async def handle_start() -> None: 178 | # print(f"Client started successfully at {datetime.datetime.now()}!") 179 | # print(client.me.id) 180 | 181 | # await client.send_message( 182 | # "Hello, this is a test message sent upon client start!", 183 | # chat_id=23424, 184 | # notify=True, 185 | # ) 186 | # file_path = "ruff.toml" 187 | # file = File(path=file_path) 188 | # msg = await client.send_message( 189 | # text="Here is the file you requested.", 190 | # chat_id=0, 191 | # attachment=file, 192 | # notify=True, 193 | # ) 194 | # if msg: 195 | # print(f"File sent successfully in message ID: {msg.id}") 196 | # history = await client.fetch_history(chat_id=0) 197 | # if history: 198 | # for message in history: 199 | # if message.attaches: 200 | # for attach in message.attaches: 201 | # if attach.type == AttachType.AUDIO: 202 | # print(attach.url) 203 | # chat = await client.rework_invite_link(chat_id=0) 204 | # print(chat.link) 205 | # text = """ 206 | # **123** 207 | # *123* 208 | # __123__ 209 | # ~~123~~ 210 | # """ 211 | # message = await client.send_message(text, chat_id=0, notify=True) 212 | # react_info = await client.add_reaction( 213 | # chat_id=0, message_id="115368067020359151", reaction="👍" 214 | # ) 215 | # if react_info: 216 | # print("Reaction added!") 217 | # print(react_info.total_count) 218 | # react_info = await client.get_reactions( 219 | # chat_id=0, message_ids=["115368067020359151"] 220 | # ) 221 | # if react_info: 222 | # print("Reactions fetched!") 223 | # for msg_id, info in react_info.items(): 224 | # print(f"Message ID: {msg_id}, Total Reactions: {info.total_count}") 225 | # react_info = await client.remove_reaction( 226 | # chat_id=0, message_id="115368067020359151" 227 | # ) 228 | # if react_info: 229 | # print("Reaction removed!") 230 | # print(react_info.total_count) 231 | # print(client.dialogs) 232 | 233 | # if history: 234 | # for message in history: 235 | # if message.link: 236 | # print(message.link.chat_id) 237 | # print(message.link.message.text) 238 | # for attach in message.attaches: 239 | # if attach.type == AttachType.CONTROL: 240 | # print(attach.event) 241 | # print(attach.extra) 242 | # if attach.type == AttachType.VIDEO: 243 | # print(message) 244 | # vid = await client.get_video_by_id( 245 | # chat_id=0, 246 | # video_id=attach.video_id, 247 | # message_id=message.id, 248 | # ) 249 | # print(vid.url) 250 | # elif attach.type == AttachType.FILE: 251 | # file = await client.get_file_by_id( 252 | # chat_id=0, 253 | # file_id=attach.file_id, 254 | # message_id=message.id, 255 | # ) 256 | # print(file.url) 257 | # print(client.me.names[0].first_name) 258 | # user = await client.get_user(client.me.id) 259 | 260 | # photo1 = Photo(path="tests/test.jpeg") 261 | # photo2 = Photo(path="tests/test.jpg") 262 | 263 | # await client.send_message( 264 | # "Hello with photo!", chat_id=0, photos=[photo1, photo2], notify=True 265 | # ) 266 | 267 | 268 | if __name__ == "__main__": 269 | try: 270 | asyncio.run(client.start()) 271 | except KeyboardInterrupt: 272 | print("Client stopped by user") 273 | -------------------------------------------------------------------------------- /src/pymax/mixins/auth.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import sys 4 | from typing import Any 5 | 6 | from pymax.exceptions import Error 7 | from pymax.interfaces import ClientProtocol 8 | from pymax.mixins.utils import MixinsUtils 9 | from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload 10 | from pymax.static.constant import PHONE_REGEX 11 | from pymax.static.enum import AuthType, Opcode 12 | 13 | 14 | class AuthMixin(ClientProtocol): 15 | def _check_phone(self) -> bool: 16 | return bool(re.match(PHONE_REGEX, self.phone)) 17 | 18 | async def request_code(self, phone: str, language: str = "ru") -> str: 19 | """ 20 | Запрашивает код аутентификации для указанного номера телефона и возвращает временный токен. 21 | 22 | Метод отправляет запрос на получение кода верификации на переданный номер телефона. 23 | Используется в процессе аутентификации или регистрации. 24 | 25 | :param phone: Номер телефона в международном формате. 26 | :type phone: str 27 | :param language: Язык для сообщения с кодом. По умолчанию "ru". 28 | :type language: str 29 | :return: Временный токен для дальнейшей аутентификации. 30 | :rtype: str 31 | :raises ValueError: Если полученные данные имеют неверный формат. 32 | :raises Error: Если сервер вернул ошибку. 33 | 34 | .. note:: 35 | Используется только в пользовательском flow аутентификации. 36 | """ 37 | self.logger.info("Requesting auth code") 38 | 39 | payload = RequestCodePayload( 40 | phone=phone, type=AuthType.START_AUTH, language=language 41 | ).model_dump(by_alias=True) 42 | 43 | data = await self._send_and_wait(opcode=Opcode.AUTH_REQUEST, payload=payload) 44 | 45 | if data.get("payload", {}).get("error"): 46 | MixinsUtils.handle_error(data) 47 | 48 | self.logger.debug( 49 | "Code request response opcode=%s seq=%s", 50 | data.get("opcode"), 51 | data.get("seq"), 52 | ) 53 | payload_data = data.get("payload") 54 | if isinstance(payload_data, dict): 55 | return payload_data["token"] 56 | else: 57 | self.logger.error("Invalid payload data received") 58 | raise ValueError("Invalid payload data received") 59 | 60 | async def resend_code(self, phone: str, language: str = "ru") -> str: 61 | """ 62 | Повторно запрашивает код аутентификации для указанного номера телефона и возвращает временный токен. 63 | 64 | :param phone: Номер телефона в международном формате. 65 | :type phone: str 66 | :param language: Язык для сообщения с кодом. По умолчанию "ru". 67 | :type language: str 68 | :return: Временный токен для дальнейшей аутентификации. 69 | :rtype: str 70 | :raises ValueError: Если полученные данные имеют неверный формат. 71 | :raises Error: Если сервер вернул ошибку. 72 | """ 73 | self.logger.info("Resending auth code") 74 | 75 | payload = RequestCodePayload( 76 | phone=phone, type=AuthType.RESEND, language=language 77 | ).model_dump(by_alias=True) 78 | 79 | data = await self._send_and_wait(opcode=Opcode.AUTH_REQUEST, payload=payload) 80 | 81 | if data.get("payload", {}).get("error"): 82 | MixinsUtils.handle_error(data) 83 | 84 | self.logger.debug( 85 | "Code resend response opcode=%s seq=%s", 86 | data.get("opcode"), 87 | data.get("seq"), 88 | ) 89 | payload_data = data.get("payload") 90 | if isinstance(payload_data, dict): 91 | return payload_data["token"] 92 | else: 93 | self.logger.error("Invalid payload data received") 94 | raise ValueError("Invalid payload data received") 95 | 96 | async def _send_code(self, code: str, token: str) -> dict[str, Any]: 97 | """ 98 | Отправляет код верификации на сервер для подтверждения. 99 | 100 | :param code: Код верификации (6 цифр). 101 | :type code: str 102 | :param token: Временный токен, полученный из request_code. 103 | :type token: str 104 | :return: Словарь с данными ответа сервера, содержащий токены аутентификации. 105 | :rtype: dict[str, Any] 106 | :raises Error: Если сервер вернул ошибку. 107 | """ 108 | self.logger.info("Sending verification code") 109 | 110 | payload = SendCodePayload( 111 | token=token, 112 | verify_code=code, 113 | auth_token_type=AuthType.CHECK_CODE, 114 | ).model_dump(by_alias=True) 115 | 116 | data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload) 117 | 118 | if data.get("payload", {}).get("error"): 119 | MixinsUtils.handle_error(data) 120 | 121 | self.logger.debug( 122 | "Send code response opcode=%s seq=%s", 123 | data.get("opcode"), 124 | data.get("seq"), 125 | ) 126 | payload_data = data.get("payload") 127 | if isinstance(payload_data, dict): 128 | return payload_data 129 | else: 130 | self.logger.error("Invalid payload data received") 131 | raise ValueError("Invalid payload data received") 132 | 133 | async def _login(self) -> None: 134 | self.logger.info("Starting login flow") 135 | 136 | temp_token = await self.request_code(self.phone) 137 | if not temp_token or not isinstance(temp_token, str): 138 | self.logger.critical("Failed to request code: token missing") 139 | raise ValueError("Failed to request code") 140 | 141 | print("Введите код: ", end="", flush=True) 142 | code = await asyncio.to_thread(lambda: sys.stdin.readline().strip()) 143 | if len(code) != 6 or not code.isdigit(): 144 | self.logger.error("Invalid code format entered") 145 | raise ValueError("Invalid code format") 146 | 147 | login_resp = await self._send_code(code, temp_token) 148 | token = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token", "") 149 | 150 | if not token: 151 | self.logger.critical("Failed to login, token not received") 152 | raise ValueError("Failed to login, token not received") 153 | 154 | self._token = token 155 | self._database.update_auth_token(str(self._device_id), self._token) 156 | self.logger.info("Login successful, token saved to database") 157 | 158 | async def _submit_reg_info( 159 | self, first_name: str, last_name: str | None, token: str 160 | ) -> dict[str, Any]: 161 | try: 162 | self.logger.info("Submitting registration info") 163 | 164 | payload = RegisterPayload( 165 | first_name=first_name, 166 | last_name=last_name, 167 | token=token, 168 | ).model_dump(by_alias=True) 169 | 170 | data = await self._send_and_wait( 171 | opcode=Opcode.AUTH_CONFIRM, payload=payload 172 | ) 173 | if data.get("payload", {}).get("error"): 174 | MixinsUtils.handle_error(data) 175 | 176 | self.logger.debug( 177 | "Registration info response opcode=%s seq=%s", 178 | data.get("opcode"), 179 | data.get("seq"), 180 | ) 181 | payload_data = data.get("payload") 182 | if isinstance(payload_data, dict): 183 | return payload_data 184 | raise ValueError("Invalid payload data received") 185 | except Exception: 186 | self.logger.error("Submit registration info failed", exc_info=True) 187 | raise RuntimeError("Submit registration info failed") 188 | 189 | async def _register(self, first_name: str, last_name: str | None = None) -> None: 190 | self.logger.info("Starting registration flow") 191 | 192 | request_code_payload = await self.request_code(self.phone) 193 | temp_token = request_code_payload 194 | 195 | if not temp_token or not isinstance(temp_token, str): 196 | self.logger.critical("Failed to request code: token missing") 197 | raise ValueError("Failed to request code") 198 | 199 | print("Введите код: ", end="", flush=True) 200 | code = await asyncio.to_thread(lambda: sys.stdin.readline().strip()) 201 | if len(code) != 6 or not code.isdigit(): 202 | self.logger.error("Invalid code format entered") 203 | raise ValueError("Invalid code format") 204 | 205 | registration_response = await self._send_code(code, temp_token) 206 | token = ( 207 | registration_response.get("tokenAttrs", {}) 208 | .get("REGISTER", {}) 209 | .get("token", "") 210 | ) 211 | if not token: 212 | self.logger.critical("Failed to register, token not received") 213 | raise ValueError("Failed to register, token not received") 214 | 215 | data = await self._submit_reg_info(first_name, last_name, token) 216 | self._token = data.get("token") 217 | if not self._token: 218 | self.logger.critical("Failed to register, token not received") 219 | raise ValueError("Failed to register, token not received") 220 | 221 | self.logger.info("Registration successful") 222 | self.logger.info("Token: %s", self._token) 223 | self.logger.warning( 224 | "IMPORTANT: Use this token ONLY with device_type='DESKTOP' and the special init user agent" 225 | ) 226 | self.logger.warning("This token MUST NOT be used in web clients") 227 | -------------------------------------------------------------------------------- /redocs/source/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ========= 3 | 4 | Готовые примеры для различных сценариев. 5 | 6 | Echo Bot 7 | -------- 8 | 9 | Простейший бот - повторяет сообщения: 10 | 11 | .. code-block:: python 12 | 13 | import asyncio 14 | from pymax import MaxClient 15 | 16 | client = MaxClient(phone="+79001234567") 17 | 18 | @client.on_message() 19 | async def echo(message): 20 | if message.text: 21 | await client.send_message( 22 | chat_id=message.chat_id, 23 | text=f"Echo: {message.text}" 24 | ) 25 | 26 | asyncio.run(client.start()) 27 | 28 | Greeter Bot 29 | ----------- 30 | 31 | Приветствует новых пользователей: 32 | 33 | .. code-block:: python 34 | 35 | import asyncio 36 | from pymax import MaxClient 37 | from pymax.filters import Filters 38 | 39 | client = MaxClient(phone="+79001234567") 40 | 41 | @client.on_message(Filters.private()) 42 | async def greet(message): 43 | user = await client.get_user(message.sender) 44 | if user and user.names: 45 | name = user.names[0].first_name 46 | await client.send_message( 47 | chat_id=message.chat_id, 48 | text=f"Привет, {name}! 👋" 49 | ) 50 | 51 | @client.on_start 52 | async def on_start(): 53 | print(f"Greeter запущен! ID: {client.me.id}") 54 | 55 | asyncio.run(client.start()) 56 | 57 | Command Handler 58 | --------------- 59 | 60 | Обработка команд с префиксом: 61 | 62 | .. code-block:: python 63 | 64 | import asyncio 65 | from pymax import MaxClient 66 | from datetime import datetime 67 | from pymax.filters import Filters 68 | 69 | client = MaxClient(phone="+79001234567") 70 | 71 | commands = { 72 | "/привет": "Привет! 👋", 73 | "/помощь": "Доступные команды: /привет, /время, /помощь", 74 | "/время": lambda: f"Время: {datetime.now().strftime('%H:%M:%S')} ⏰", 75 | } 76 | 77 | @client.on_message() 78 | async def handle_command(message): 79 | if not message.text or not message.text.startswith("/"): 80 | return 81 | 82 | command = message.text.split()[0] 83 | 84 | if command in commands: 85 | response = commands[command] 86 | if callable(response): 87 | response = response() 88 | 89 | await client.send_message( 90 | chat_id=message.chat_id, 91 | text=response 92 | ) 93 | 94 | asyncio.run(client.start()) 95 | 96 | Broadcast Bot 97 | ------------- 98 | 99 | Отправляет сообщение во все чаты: 100 | 101 | .. code-block:: python 102 | 103 | import asyncio 104 | from pymax import MaxClient 105 | from pymax.filters import Filters 106 | 107 | client = MaxClient(phone="+79001234567") 108 | 109 | @client.on_message(Filters.text("рассылка")) 110 | async def broadcast(message): 111 | text = message.text.replace("рассылка ", "") 112 | 113 | for chat in client.chats: 114 | try: 115 | await client.send_message( 116 | chat_id=chat.id, 117 | text=text, 118 | notify=False 119 | ) 120 | except Exception as e: 121 | print(f"Ошибка в чате {chat.title}: {e}") 122 | 123 | await client.send_message( 124 | chat_id=message.chat_id, 125 | text="✅ Рассылка завершена" 126 | ) 127 | 128 | asyncio.run(client.start()) 129 | 130 | File Manager 131 | ------------ 132 | 133 | Работа с файлами и вложениями: 134 | 135 | .. code-block:: python 136 | 137 | import asyncio 138 | from pymax import MaxClient 139 | from pymax.files import File 140 | from pymax.static.enum import AttachType 141 | from pymax.filters import Filters 142 | 143 | client = MaxClient(phone="+79001234567") 144 | 145 | @client.on_message() 146 | async def handle_files(message): 147 | if not message.attaches: 148 | return 149 | 150 | for attach in message.attaches: 151 | if attach.type == AttachType.PHOTO: 152 | print("Получено фото!") 153 | file_info = await client.get_file_by_id( 154 | chat_id=message.chat_id, 155 | message_id=message.id, 156 | file_id=attach.file_id 157 | ) 158 | if file_info: 159 | print(f"URL: {file_info.url}") 160 | 161 | @client.on_message(Filters.text("файл")) 162 | async def send_file(message): 163 | file = File(path="document.pdf") 164 | await client.send_message( 165 | chat_id=message.chat_id, 166 | text="Вот файл", 167 | attachment=file 168 | ) 169 | 170 | asyncio.run(client.start()) 171 | 172 | Message Counter 173 | --------------- 174 | 175 | Считает сообщения от каждого пользователя: 176 | 177 | .. code-block:: python 178 | 179 | import asyncio 180 | from collections import defaultdict 181 | from pymax import MaxClient 182 | from pymax.filters import Filters 183 | 184 | client = MaxClient(phone="+79001234567") 185 | user_messages = defaultdict(int) 186 | 187 | @client.on_message() 188 | async def count_messages(message): 189 | user_messages[message.sender] += 1 190 | 191 | @client.on_message(Filters.text("статистика")) 192 | async def show_stats(message): 193 | # Топ-5 активные пользователи 194 | top = sorted(user_messages.items(), key=lambda x: x[1], reverse=True)[:5] 195 | 196 | text = "📊 Топ активные:\n" 197 | for user_id, count in top: 198 | user = await client.get_user(user_id) 199 | name = user.names[0].first_name if user and user.names else "Неизвестно" 200 | text += f"{name}: {count}\n" 201 | 202 | await client.send_message( 203 | chat_id=message.chat_id, 204 | text=text 205 | ) 206 | 207 | asyncio.run(client.start()) 208 | 209 | Auto-Replier 210 | ------------ 211 | 212 | Автоматический ответ на определённые фразы: 213 | 214 | .. code-block:: python 215 | 216 | import asyncio 217 | from pymax import MaxClient 218 | from pymax.filters import Filters 219 | 220 | client = MaxClient(phone="+79001234567") 221 | 222 | auto_replies = { 223 | "привет": "И тебе привет! 👋", 224 | "как дела": "Спасибо, отлично! 😊", 225 | "спасибо": "Пожалуйста! 🙏", 226 | "пока": "До свидания! 👋", 227 | } 228 | 229 | @client.on_message() 230 | async def auto_reply(message): 231 | if not message.text: 232 | return 233 | 234 | text_lower = message.text.lower() 235 | 236 | for trigger, response in auto_replies.items(): 237 | if trigger in text_lower: 238 | await client.send_message( 239 | chat_id=message.chat_id, 240 | text=response 241 | ) 242 | return 243 | 244 | asyncio.run(client.start()) 245 | 246 | Scheduled Messages 247 | ------------------ 248 | 249 | Отправка сообщений по расписанию: 250 | 251 | .. code-block:: python 252 | 253 | import asyncio 254 | from datetime import datetime, timedelta 255 | 256 | client = MaxClient(phone="+79001234567") 257 | 258 | async def scheduled_sender(): 259 | while True: 260 | now = datetime.now() 261 | 262 | # Отправить сообщение в 12:00 263 | if now.hour == 12 and now.minute == 0: 264 | await client.send_message( 265 | chat_id=123456, 266 | text="🕐 Обеденный перерыв!", 267 | notify=False 268 | ) 269 | await asyncio.sleep(60) # Не отправлять дважды в одну минуту 270 | 271 | await asyncio.sleep(1) 272 | 273 | @client.on_start 274 | async def on_start(): 275 | asyncio.create_task(scheduled_sender()) 276 | 277 | asyncio.run(client.start()) 278 | 279 | Error Handling 280 | -------------- 281 | 282 | Правильная обработка ошибок: 283 | 284 | .. code-block:: python 285 | 286 | import asyncio 287 | import logging 288 | from pymax import MaxClient 289 | 290 | logging.basicConfig(level=logging.INFO) 291 | logger = logging.getLogger("bot") 292 | 293 | client = MaxClient(phone="+79001234567", logger=logger) 294 | 295 | @client.on_message() 296 | async def safe_handler(message): 297 | try: 298 | if not message.text: 299 | return 300 | 301 | result = await client.send_message( 302 | chat_id=message.chat_id, 303 | text=f"Получено: {message.text}" 304 | ) 305 | 306 | logger.info(f"Сообщение отправлено в чат {message.chat_id}") 307 | 308 | except Exception as e: 309 | logger.error(f"Ошибка в обработчике: {e}") 310 | 311 | asyncio.run(client.start()) 312 | 313 | Context Manager 314 | --------------- 315 | 316 | Использование клиента как контекстного менеджера: 317 | 318 | .. code-block:: python 319 | 320 | import asyncio 321 | from pymax import MaxClient 322 | 323 | client = MaxClient(phone="+79001234567") 324 | 325 | async def main(): 326 | async with client: 327 | # Клиент автоматически подключён и синхронизирован 328 | await client.send_message( 329 | chat_id=123456, 330 | text="Контекстный менеджер работает!" 331 | ) 332 | 333 | # Блокировать и ждать событий 334 | await client.idle() 335 | 336 | asyncio.run(main()) 337 | 338 | Filter Combinations 339 | ------------------- 340 | 341 | Комбинирование фильтров: 342 | 343 | .. code-block:: python 344 | 345 | import asyncio 346 | from pymax import MaxClient 347 | from pymax.filters import Filters 348 | 349 | client = MaxClient(phone="+79001234567") 350 | 351 | # AND - оба условия должны быть верны 352 | @client.on_message(Filters.chat(123456) & Filters.text("важное")) 353 | async def important_in_chat(message): 354 | await client.send_message( 355 | chat_id=message.chat_id, 356 | text="Это важно в нашем чате!" 357 | ) 358 | 359 | # OR - одно из условий должно быть верно 360 | @client.on_message(Filters.chat(123456) | Filters.chat(789012)) 361 | async def in_my_chats(message): 362 | print("Это в одном из моих чатов") 363 | 364 | # NOT - условие должно быть неверно 365 | @client.on_message(~Filters.text("реклама")) 366 | async def not_ads(message): 367 | print("Это не реклама") 368 | 369 | asyncio.run(client.start()) 370 | 371 | Дополнительно 372 | -------------- 373 | 374 | Смотрите раздел :doc:`guides` для подробных гайдов и больше примеров. 375 | -------------------------------------------------------------------------------- /src/pymax/mixins/handler.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any 3 | 4 | from pymax.filters import BaseFilter 5 | from pymax.interfaces import ClientProtocol 6 | from pymax.types import Chat, Message, ReactionInfo 7 | 8 | 9 | class HandlerMixin(ClientProtocol): 10 | def on_message( 11 | self, filter: BaseFilter[Message] | None = None 12 | ) -> Callable[ 13 | [Callable[[Any], Any | Awaitable[Any]]], 14 | Callable[[Any], Any | Awaitable[Any]], 15 | ]: 16 | """ 17 | Декоратор для регистрации обработчика входящих сообщений. 18 | 19 | Позволяет установить функцию-обработчик для всех входящих сообщений 20 | или только для сообщений, соответствующих заданному фильтру. 21 | 22 | :param filter: Фильтр для обработки сообщений. По умолчанию None. 23 | :type filter: BaseFilter[Message] | None 24 | :return: Декоратор для функции-обработчика. 25 | :rtype: Callable 26 | 27 | Example:: 28 | 29 | @client.on_message(Filter.text("hello")) 30 | async def handle_hello(message: Message): 31 | await client.send_message( 32 | chat_id=message.chat_id, 33 | text="Hello!" 34 | ) 35 | """ 36 | 37 | def decorator( 38 | handler: Callable[[Any], Any | Awaitable[Any]], 39 | ) -> Callable[[Any], Any | Awaitable[Any]]: 40 | self._on_message_handlers.append((handler, filter)) 41 | self.logger.debug(f"on_message handler set: {handler}, filter: {filter}") 42 | return handler 43 | 44 | return decorator 45 | 46 | def on_message_edit( 47 | self, filter: BaseFilter[Message] | None = None 48 | ) -> Callable[ 49 | [Callable[[Any], Any | Awaitable[Any]]], 50 | Callable[[Any], Any | Awaitable[Any]], 51 | ]: 52 | """ 53 | Декоратор для установки обработчика отредактированных сообщений. 54 | 55 | :param filter: Фильтр для обработки сообщений. По умолчанию None. 56 | :type filter: BaseFilter[Message] | None 57 | :return: Декоратор для функции-обработчика. 58 | :rtype: Callable 59 | """ 60 | 61 | def decorator( 62 | handler: Callable[[Any], Any | Awaitable[Any]], 63 | ) -> Callable[[Any], Any | Awaitable[Any]]: 64 | self._on_message_edit_handlers.append((handler, filter)) 65 | self.logger.debug( 66 | f"on_message_edit handler set: {handler}, filter: {filter}" 67 | ) 68 | return handler 69 | 70 | return decorator 71 | 72 | def on_message_delete( 73 | self, filter: BaseFilter[Message] | None = None 74 | ) -> Callable[ 75 | [Callable[[Any], Any | Awaitable[Any]]], 76 | Callable[[Any], Any | Awaitable[Any]], 77 | ]: 78 | """ 79 | Декоратор для установки обработчика удаленных сообщений. 80 | 81 | :param filter: Фильтр для обработки сообщений. По умолчанию None. 82 | :type filter: BaseFilter[Message] | None 83 | :return: Декоратор для функции-обработчика. 84 | :rtype: Callable 85 | """ 86 | 87 | def decorator( 88 | handler: Callable[[Any], Any | Awaitable[Any]], 89 | ) -> Callable[[Any], Any | Awaitable[Any]]: 90 | self._on_message_delete_handlers.append((handler, filter)) 91 | self.logger.debug( 92 | f"on_message_delete handler set: {handler}, filter: {filter}" 93 | ) 94 | return handler 95 | 96 | return decorator 97 | 98 | def on_reaction_change( 99 | self, 100 | handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]], 101 | ) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]: 102 | """ 103 | Устанавливает обработчик изменения реакций на сообщения. 104 | 105 | :param handler: Функция или coroutine с аргументами (message_id: str, chat_id: int, reaction_info: ReactionInfo). 106 | :type handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]] 107 | :return: Установленный обработчик. 108 | :rtype: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]] 109 | """ 110 | self._on_reaction_change_handlers.append(handler) 111 | self.logger.debug("on_reaction_change handler set: %r", handler) 112 | return handler 113 | 114 | def on_chat_update( 115 | self, handler: Callable[[Chat], Any | Awaitable[Any]] 116 | ) -> Callable[[Chat], Any | Awaitable[Any]]: 117 | """ 118 | Устанавливает обработчик обновления информации о чате. 119 | 120 | :param handler: Функция или coroutine с аргументом (chat: Chat). 121 | :type handler: Callable[[Chat], Any | Awaitable[Any]] 122 | :return: Установленный обработчик. 123 | :rtype: Callable[[Chat], Any | Awaitable[Any]] 124 | """ 125 | self._on_chat_update_handlers.append(handler) 126 | self.logger.debug("on_chat_update handler set: %r", handler) 127 | return handler 128 | 129 | def on_raw_receive( 130 | self, handler: Callable[[dict[str, Any]], Any | Awaitable[Any]] 131 | ) -> Callable[[dict[str, Any]], Any | Awaitable[Any]]: 132 | """ 133 | Устанавливает обработчик для получения необработанных данных от сервера. 134 | 135 | :param handler: Функция или coroutine с аргументом (data: dict). 136 | :type handler: Callable[[dict[str, Any]], Any | Awaitable[Any]] 137 | :return: Установленный обработчик. 138 | :rtype: Callable[[dict[str, Any]], Any | Awaitable[Any]] 139 | """ 140 | self._on_raw_receive_handlers.append(handler) 141 | self.logger.debug("on_raw_receive handler set: %r", handler) 142 | return handler 143 | 144 | def on_start( 145 | self, handler: Callable[[], Any | Awaitable[Any]] 146 | ) -> Callable[[], Any | Awaitable[Any]]: 147 | """ 148 | Устанавливает обработчик, вызываемый при старте клиента. 149 | 150 | :param handler: Функция или coroutine без аргументов. 151 | :type handler: Callable[[], Any | Awaitable[Any]] 152 | :return: Установленный обработчик. 153 | :rtype: Callable[[], Any | Awaitable[Any]] 154 | """ 155 | self._on_start_handler = handler 156 | self.logger.debug("on_start handler set: %r", handler) 157 | return handler 158 | 159 | def task(self, seconds: float, minutes: float = 0, hours: float = 0): 160 | """ 161 | Декоратор для планирования периодической задачи. 162 | 163 | :param seconds: Интервал выполнения в секундах. 164 | :type seconds: float 165 | :param minutes: Интервал выполнения в минутах. По умолчанию 0. 166 | :type minutes: float 167 | :param hours: Интервал выполнения в часах. По умолчанию 0. 168 | :type hours: float 169 | :return: Декоратор для функции-обработчика. 170 | :rtype: Callable[[], Any | Awaitable[Any]] 171 | 172 | Example:: 173 | 174 | @client.task(seconds=10) 175 | async def task(): 176 | await client.send_message(chat_id=123, text="Hello!") 177 | """ 178 | 179 | def decorator( 180 | handler: Callable[[], Any | Awaitable[Any]], 181 | ) -> Callable[[], Any | Awaitable[Any]]: 182 | self._scheduled_tasks.append( 183 | (handler, seconds + minutes * 60 + hours * 3600) 184 | ) 185 | self.logger.debug( 186 | f"task scheduled: {handler}, interval: {seconds + minutes * 60 + hours * 3600}s" 187 | ) 188 | return handler 189 | 190 | return decorator 191 | 192 | def add_message_handler( 193 | self, 194 | handler: Callable[[Message], Any | Awaitable[Any]], 195 | filter: BaseFilter[Message] | None = None, 196 | ) -> Callable[[Message], Any | Awaitable[Any]]: 197 | """ 198 | Добавляет обработчик входящих сообщений. 199 | 200 | :param handler: Обработчик. 201 | :type handler: Callable[[Message], Any | Awaitable[Any]] 202 | :param filter: Фильтр. По умолчанию None. 203 | :type filter: BaseFilter[Message] | None 204 | :return: Обработчик. 205 | :rtype: Callable[[Message], Any | Awaitable[Any]] 206 | """ 207 | self.logger.debug("add_message_handler (alias) used") 208 | self._on_message_handlers.append((handler, filter)) 209 | return handler 210 | 211 | def add_on_start_handler( 212 | self, handler: Callable[[], Any | Awaitable[Any]] 213 | ) -> Callable[[], Any | Awaitable[Any]]: 214 | """ 215 | Добавляет обработчик, вызываемый при старте клиента. 216 | 217 | :param handler: Функция или coroutine без аргументов. 218 | :type handler: Callable[[], Any | Awaitable[Any]] 219 | :return: Установленный обработчик. 220 | :rtype: Callable[[], Any | Awaitable[Any]] 221 | """ 222 | self.logger.debug("add_on_start_handler (alias) used") 223 | self._on_start_handler = handler 224 | return handler 225 | 226 | def add_reaction_change_handler( 227 | self, 228 | handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]], 229 | ) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]: 230 | """ 231 | Добавляет обработчик изменения реакций на сообщения. 232 | 233 | :param handler: Функция или coroutine с аргументами (message_id: str, chat_id: int, reaction_info: ReactionInfo). 234 | :type handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]] 235 | :return: Установленный обработчик. 236 | :rtype: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]] 237 | """ 238 | self.logger.debug("add_reaction_change_handler (alias) used") 239 | self._on_reaction_change_handlers.append( 240 | handler, 241 | ) 242 | return handler 243 | 244 | def add_chat_update_handler( 245 | self, handler: Callable[[Chat], Any | Awaitable[Any]] 246 | ) -> Callable[[Chat], Any | Awaitable[Any]]: 247 | """ 248 | Добавляет обработчик обновления информации о чате. 249 | 250 | :param handler: Функция или coroutine с аргументом (chat: Chat). 251 | :type handler: Callable[[Chat], Any | Awaitable[Any]] 252 | :return: Установленный обработчик. 253 | :rtype: Callable[[Chat], Any | Awaitable[Any]] 254 | """ 255 | self.logger.debug("add_chat_update_handler (alias) used") 256 | self._on_chat_update_handlers.append(handler) 257 | return handler 258 | 259 | def add_raw_receive_handler( 260 | self, handler: Callable[[dict[str, Any]], Any | Awaitable[Any]] 261 | ) -> Callable[[dict[str, Any]], Any | Awaitable[Any]]: 262 | """ 263 | Добавляет обработчик для получения необработанных данных от сервера. 264 | 265 | :param handler: Функция или coroutine с аргументом (data: dict). 266 | :type handler: Callable[[dict[str, Any]], Any | Awaitable[Any]] 267 | :return: Установленный обработчик. 268 | :rtype: Callable[[dict[str, Any]], Any | Awaitable[Any]] 269 | """ 270 | self.logger.debug("add_raw_receive_handler (alias) used") 271 | self._on_raw_receive_handlers.append(handler) 272 | return handler 273 | 274 | def add_scheduled_task( 275 | self, 276 | handler: Callable[[], Any | Awaitable[Any]], 277 | interval: float, 278 | ) -> Callable[[], Any | Awaitable[Any]]: 279 | """ 280 | Добавляет периодическую задачу. 281 | 282 | :param handler: Функция или coroutine без аргументов. 283 | :type handler: Callable[[], Any | Awaitable[Any]] 284 | :param interval: Интервал выполнения в секундах. 285 | :type interval: float 286 | :return: Установленный обработчик. 287 | :rtype: Callable[[], Any | Awaitable[Any]] 288 | """ 289 | self.logger.debug("add_scheduled_task (alias) used") 290 | self._scheduled_tasks.append((handler, interval)) 291 | return handler 292 | -------------------------------------------------------------------------------- /redocs/source/guides.rst: -------------------------------------------------------------------------------- 1 | Guides 2 | ====== 3 | 4 | Подробные гайды по использованию PyMax. 5 | 6 | Работа с сообщениями 7 | --------------------- 8 | 9 | Получение сообщений: 10 | 11 | .. code-block:: python 12 | 13 | from pymax.types import Message 14 | 15 | @client.on_message() 16 | async def handle_message(message: Message) -> None: 17 | message.id # ID сообщения 18 | message.chat_id # ID чата 19 | message.sender # ID отправителя 20 | message.text # Текст сообщения 21 | message.type # Тип (TEXT, SYSTEM, SERVICE) 22 | message.status # Статус (SENT, DELIVERED, READ) 23 | message.timestamp # Время отправки 24 | message.attaches # Вложения 25 | 26 | Отправка сообщений: 27 | 28 | .. code-block:: python 29 | 30 | from pymax.types import Message 31 | 32 | # Простое сообщение 33 | await client.send_message( 34 | chat_id=123456, 35 | text="Привет!" 36 | ) 37 | 38 | # С уведомлением 39 | await client.send_message( 40 | chat_id=123456, 41 | text="Важное!", 42 | notify=True 43 | ) 44 | 45 | # Ответить на сообщение 46 | async def reply_to_message(message: Message) -> None: 47 | await client.send_message( 48 | chat_id=message.chat_id, 49 | text="Ответ", 50 | reply_to=message.id 51 | ) 52 | 53 | Редактирование и удаление: 54 | 55 | .. code-block:: python 56 | 57 | # Отредактировать сообщение 58 | await client.edit_message( 59 | chat_id=123456, 60 | message_id=msg_id, 61 | text="Новый текст" 62 | ) 63 | 64 | # Удалить сообщение 65 | await client.delete_message( 66 | chat_id=123456, 67 | message_ids=[msg_id], 68 | for_me=False 69 | ) 70 | 71 | Работа с фильтрами 72 | ------------------- 73 | 74 | Базовые фильтры: 75 | 76 | .. code-block:: python 77 | 78 | from pymax.filters import Filters 79 | from pymax.types import Message 80 | 81 | # По чату 82 | @client.on_message(Filters.chat(123456)) 83 | async def in_chat(message: Message) -> None: 84 | pass 85 | 86 | # По пользователю 87 | @client.on_message(Filters.user(789012)) 88 | async def from_user(message: Message) -> None: 89 | pass 90 | 91 | # По тексту 92 | @client.on_message(Filters.text("привет")) 93 | async def greeting(message: Message) -> None: 94 | pass 95 | 96 | # Только личные 97 | @client.on_message(Filters.dialog()) 98 | async def private(message: Message) -> None: 99 | pass 100 | 101 | # Только группы 102 | @client.on_message(Filters.chat()) 103 | async def in_group(message: Message) -> None: 104 | pass 105 | 106 | Комбинирование фильтров: 107 | 108 | .. code-block:: python 109 | 110 | from pymax.filters import Filters 111 | from pymax.types import Message 112 | 113 | # AND (&) 114 | @client.on_message(Filters.chat(123) & Filters.text("привет")) 115 | async def specific(message: Message) -> None: 116 | pass 117 | 118 | # OR (|) 119 | @client.on_message(Filters.chat(123) | Filters.chat(456)) 120 | async def any_chat(message: Message) -> None: 121 | pass 122 | 123 | # NOT (~) 124 | @client.on_message(~Filters.text("спам")) 125 | async def no_spam(message: Message) -> None: 126 | pass 127 | 128 | Получение информации о пользователях 129 | ------------------------------------- 130 | 131 | Получить профиль: 132 | 133 | .. code-block:: python 134 | 135 | from pymax.types import User 136 | 137 | user: User | None = await client.get_user(user_id=789012) 138 | 139 | if user: 140 | user.id # ID пользователя 141 | user.names # Список имён 142 | user.account_status # Статус аккаунта 143 | user.photo_id # ID фото профиля 144 | user.description # Описание профиля 145 | user.gender # Пол 146 | user.base_url # URL для получения аватара 147 | user.options # Опции пользователя 148 | 149 | Получить имя пользователя: 150 | 151 | .. code-block:: python 152 | 153 | from pymax.types import User 154 | 155 | def get_user_name(user: User | None) -> str: 156 | if not user or not user.names: 157 | return "Неизвестно" 158 | 159 | name = user.names[0] 160 | full = f"{name.first_name} {name.last_name}" if name.last_name else name.first_name 161 | return full 162 | 163 | Информация о себе: 164 | 165 | .. code-block:: python 166 | 167 | from pymax.types import Me 168 | 169 | @client.on_start 170 | async def on_start() -> None: 171 | me: Me | None = client.me 172 | if me: 173 | print(f"Мой ID: {me.id}") 174 | print(f"Мое имя: {get_user_name(me)}") 175 | 176 | Работа с чатами и группами 177 | --------------------------- 178 | 179 | Получить чаты: 180 | 181 | .. code-block:: python 182 | 183 | from pymax.types import Chat 184 | 185 | # Все чаты 186 | chats: list[Chat] = client.chats 187 | for chat in chats: 188 | print(f"{chat.title}: {chat.id}") 189 | 190 | # Конкретный чат 191 | chat: Chat | None = await client.get_chat(chat_id=123456) 192 | 193 | # Несколько чатов 194 | chats: list[Chat] = await client.get_chats([123, 456, 789]) 195 | 196 | Информация о чате: 197 | 198 | .. code-block:: python 199 | 200 | chat.id # ID чата 201 | chat.title # Название 202 | chat.description # Описание 203 | chat.type # Тип (DIALOG, CHAT, CHANNEL) 204 | chat.participants_count # Количество участников 205 | chat.owner # ID владельца 206 | chat.admins # Список ID администраторов 207 | chat.base_icon_url # URL для получения иконки чата 208 | chat.access # Тип доступа (OPEN, CLOSED, PRIVATE) 209 | 210 | Управление чатами: 211 | 212 | .. code-block:: python 213 | 214 | from pymax.types import Chat, Message 215 | 216 | # Создать группу 217 | result: tuple[Chat, Message] | None = await client.create_group( 218 | name="Новая группа", 219 | participant_ids=[user_id1, user_id2] 220 | ) 221 | 222 | # Редактировать 223 | await client.change_group_profile( 224 | chat_id=123456, 225 | name="Новое название", 226 | description="Новое описание" 227 | ) 228 | 229 | # Добавить участников 230 | updated_chat: Chat | None = await client.invite_users_to_group( 231 | chat_id=123456, 232 | user_ids=[789012, 345678], 233 | show_history=True 234 | ) 235 | 236 | # Удалить участников 237 | removed: bool = await client.remove_users_from_group( 238 | chat_id=123456, 239 | user_ids=[789012], 240 | clean_msg_period=0 241 | ) 242 | 243 | Каналы: 244 | 245 | .. code-block:: python 246 | 247 | from pymax.types import Chat 248 | 249 | # Найти канал 250 | found: Chat | None = await client.resolve_channel_by_name("my_channel") 251 | 252 | # Присоединиться 253 | joined: bool = await client.join_channel(link="https://max.ru/my_channel") 254 | 255 | # Выйти 256 | left: bool = await client.leave_channel(chat_id=123456) 257 | 258 | История сообщений 259 | ------------------ 260 | 261 | Получить историю: 262 | 263 | .. code-block:: python 264 | 265 | from pymax.types import Message 266 | 267 | # Последние 50 сообщений 268 | history: list[Message] = await client.fetch_history( 269 | chat_id=123456, 270 | limit=50 271 | ) 272 | 273 | for msg in history: 274 | print(f"{msg.sender}: {msg.text}") 275 | 276 | Поиск в истории: 277 | 278 | .. code-block:: python 279 | 280 | from pymax.types import Message 281 | 282 | history: list[Message] = await client.fetch_history(chat_id=123456, limit=100) 283 | 284 | # Найти сообщения с текстом 285 | important: list[Message] = [m for m in history if "важно" in m.text.lower()] 286 | 287 | # Сообщения от конкретного пользователя 288 | from_user: list[Message] = [m for m in history if m.sender == user_id] 289 | 290 | Асинхронность и параллелизм 291 | ---------------------------- 292 | 293 | Параллельное выполнение: 294 | 295 | .. code-block:: python 296 | 297 | import asyncio 298 | from pymax.types import Message 299 | 300 | # Несколько операций одновременно 301 | results: tuple[Message | None, ...] = await asyncio.gather( 302 | client.send_message(chat_id=1, text="1"), 303 | client.send_message(chat_id=2, text="2"), 304 | client.send_message(chat_id=3, text="3"), 305 | ) 306 | 307 | Фоновые задачи: 308 | 309 | .. code-block:: python 310 | 311 | import asyncio 312 | 313 | async def background_task() -> None: 314 | while True: 315 | await client.send_message( 316 | chat_id=123456, 317 | text="Периодическое сообщение" 318 | ) 319 | await asyncio.sleep(3600) # Каждый час 320 | 321 | @client.on_start 322 | async def on_start(me: Me | None) -> None: 323 | asyncio.create_task(background_task()) 324 | 325 | Обработка ошибок: 326 | 327 | .. code-block:: python 328 | 329 | from pymax.types import Message 330 | 331 | try: 332 | msg: Message | None = await client.send_message(chat_id=123456, text="Сообщение") 333 | except Exception as e: 334 | print(f"Ошибка: {e}") 335 | 336 | Retry с повторными попытками: 337 | 338 | .. code-block:: python 339 | 340 | import asyncio 341 | from pymax.types import Message 342 | 343 | async def send_with_retry(chat_id: int, text: str, max_retries: int = 3) -> Message | None: 344 | for attempt in range(max_retries): 345 | try: 346 | return await client.send_message( 347 | chat_id=chat_id, 348 | text=text 349 | ) 350 | except Exception as e: 351 | if attempt == max_retries - 1: 352 | raise 353 | await asyncio.sleep(2 ** attempt) 354 | 355 | Работа с файлами и вложениями 356 | ------------------------------ 357 | 358 | Отправить файл: 359 | 360 | .. code-block:: python 361 | 362 | from pymax.types import Message 363 | from pymax.files import File 364 | 365 | file: File = File(path="document.pdf") 366 | msg: Message | None = await client.send_message( 367 | chat_id=123456, 368 | text="Вот файл", 369 | attachment=file 370 | ) 371 | 372 | Получить информацию о файле: 373 | 374 | .. code-block:: python 375 | 376 | from pymax.types import Message, File as FileInfo 377 | 378 | @client.on_message() 379 | async def handle_attachments(message: Message) -> None: 380 | if not message.attaches: 381 | return 382 | 383 | for attach in message.attaches: 384 | file_info: FileInfo | None = await client.get_file_by_id( 385 | chat_id=message.chat_id, 386 | message_id=message.id, 387 | file_id=attach.file_id 388 | ) 389 | 390 | if file_info: 391 | print(f"URL: {file_info.url}") 392 | 393 | События клиента 394 | --------------- 395 | 396 | .. code-block:: python 397 | 398 | from pymax.types import Message, Chat, Me 399 | 400 | @client.on_start 401 | async def on_start(me: Me | None) -> None: 402 | print("Клиент запущен") 403 | 404 | @client.on_message() 405 | async def on_message(message: Message) -> None: 406 | print(f"Новое сообщение: {message.text}") 407 | 408 | @client.on_message_edit() 409 | async def on_message_edit(message: Message) -> None: 410 | print(f"Сообщение отредактировано: {message.text}") 411 | 412 | @client.on_message_delete() 413 | async def on_message_delete(message: Message) -> None: 414 | print(f"Сообщение удалено: {message.id}") 415 | 416 | @client.on_chat_update() 417 | async def on_chat_update(chat: Chat) -> None: 418 | print(f"Информация о чате обновлена: {chat.title}") 419 | 420 | Периодические задачи: 421 | 422 | .. code-block:: python 423 | 424 | @client.task(seconds=3600) # Каждый час 425 | async def send_periodic_message() -> None: 426 | await client.send_message( 427 | chat_id=123456, 428 | text="Периодическое сообщение" 429 | ) 430 | 431 | Лучшие практики 432 | --------------- 433 | 434 | ✅ Всегда проверяйте None перед использованием: 435 | 436 | .. code-block:: python 437 | 438 | from pymax.types import User 439 | 440 | user: User | None = await client.get_user(user_id) 441 | if user and user.names: 442 | name: str = user.names[0].first_name 443 | 444 | ✅ Используйте фильтры вместо if-проверок: 445 | 446 | .. code-block:: python 447 | 448 | from pymax.types import Message 449 | from pymax.filters import Filters 450 | 451 | # Хорошо 452 | @client.on_message(Filters.text("привет")) 453 | async def handler(message: Message) -> None: 454 | pass 455 | 456 | # Плохо 457 | @client.on_message() 458 | async def handler(message: Message) -> None: 459 | if message.text and "привет" in message.text: 460 | pass 461 | 462 | ✅ Обрабатывайте ошибки в асинхронном коде: 463 | 464 | .. code-block:: python 465 | 466 | import asyncio 467 | from typing import Any 468 | 469 | results: list[Any] = await asyncio.gather( 470 | *tasks, 471 | return_exceptions=True 472 | ) 473 | 474 | ✅ Используйте логирование для отладки: 475 | 476 | .. code-block:: python 477 | 478 | import logging 479 | 480 | logger: logging.Logger = logging.getLogger("bot") 481 | logger.info("Сообщение") 482 | 483 | ✅ Не отправляйте слишком много сообщений сразу (rate limiting) 484 | -------------------------------------------------------------------------------- /src/pymax/mixins/group.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from pymax.exceptions import Error 4 | from pymax.interfaces import ClientProtocol 5 | from pymax.mixins.utils import MixinsUtils 6 | from pymax.payloads import ( 7 | ChangeGroupProfilePayload, 8 | ChangeGroupSettingsOptions, 9 | ChangeGroupSettingsPayload, 10 | CreateGroupAttach, 11 | CreateGroupMessage, 12 | CreateGroupPayload, 13 | FetchChatsPayload, 14 | GetChatInfoPayload, 15 | InviteUsersPayload, 16 | JoinChatPayload, 17 | LeaveChatPayload, 18 | RemoveUsersPayload, 19 | ReworkInviteLinkPayload, 20 | ) 21 | from pymax.static.enum import Opcode 22 | from pymax.types import Chat, Message 23 | 24 | 25 | class GroupMixin(ClientProtocol): 26 | async def create_group( 27 | self, 28 | name: str, 29 | participant_ids: list[int] | None = None, 30 | notify: bool = True, 31 | ) -> tuple[Chat, Message] | None: 32 | """ 33 | Создает группу 34 | 35 | Args: 36 | name (str): Название группы. 37 | participant_ids (list[int] | None, optional): Список идентификаторов участников. Defaults to None. 38 | notify (bool, optional): Флаг оповещения. Defaults to True. 39 | 40 | Returns: 41 | tuple[Chat, Message] | None: Объект Chat и Message или None при ошибке. 42 | """ 43 | payload = CreateGroupPayload( 44 | message=CreateGroupMessage( 45 | cid=int(time.time() * 1000), 46 | attaches=[ 47 | CreateGroupAttach( 48 | _type="CONTROL", 49 | title=name, 50 | user_ids=(participant_ids if participant_ids else []), 51 | ) 52 | ], 53 | ), 54 | notify=notify, 55 | ).model_dump(by_alias=True) 56 | 57 | data = await self._send_and_wait(opcode=Opcode.MSG_SEND, payload=payload) 58 | if data.get("payload", {}).get("error"): 59 | MixinsUtils.handle_error(data) 60 | 61 | chat = Chat.from_dict(data["payload"]["chat"]) 62 | message = Message.from_dict(data["payload"]) 63 | 64 | if chat: 65 | cached_chat = await self._get_chat(chat.id) 66 | if cached_chat is None: 67 | self.chats.append(chat) 68 | else: 69 | idx = self.chats.index(cached_chat) 70 | self.chats[idx] = chat 71 | 72 | return chat, message 73 | 74 | async def invite_users_to_group( 75 | self, 76 | chat_id: int, 77 | user_ids: list[int], 78 | show_history: bool = True, 79 | ) -> Chat | None: 80 | """ 81 | Приглашает пользователей в группу 82 | 83 | Args: 84 | chat_id (int): ID группы. 85 | user_ids (list[int]): Список идентификаторов пользователей. 86 | show_history (bool, optional): Флаг оповещения. Defaults to True. 87 | 88 | Returns: 89 | Chat | None: Объект Chat или None при ошибке. 90 | """ 91 | payload = InviteUsersPayload( 92 | chat_id=chat_id, 93 | user_ids=user_ids, 94 | show_history=show_history, 95 | operation="add", 96 | ).model_dump(by_alias=True) 97 | 98 | data = await self._send_and_wait( 99 | opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload 100 | ) 101 | 102 | if data.get("payload", {}).get("error"): 103 | MixinsUtils.handle_error(data) 104 | 105 | chat = Chat.from_dict(data["payload"]["chat"]) 106 | if chat: 107 | cached_chat = await self._get_chat(chat.id) 108 | if cached_chat is None: 109 | self.chats.append(chat) 110 | else: 111 | idx = self.chats.index(cached_chat) 112 | self.chats[idx] = chat 113 | 114 | return chat 115 | 116 | async def invite_users_to_channel( 117 | self, 118 | chat_id: int, 119 | user_ids: list[int], 120 | show_history: bool = True, 121 | ) -> Chat | None: 122 | """ 123 | Приглашает пользователей в канал 124 | 125 | Args: 126 | chat_id (int): ID канала. 127 | user_ids (list[int]): Список идентификаторов пользователей. 128 | show_history (bool, optional): Флаг оповещения. Defaults to True. 129 | 130 | Returns: 131 | Chat | None: Объект Chat или None при ошибке. 132 | """ 133 | return await self.invite_users_to_group(chat_id, user_ids, show_history) 134 | 135 | async def remove_users_from_group( 136 | self, 137 | chat_id: int, 138 | user_ids: list[int], 139 | clean_msg_period: int, 140 | ) -> bool: 141 | """ 142 | Удаляет пользователей из группы 143 | 144 | Args: 145 | chat_id (int): ID группы. 146 | user_ids (list[int]): Список идентификаторов пользователей. 147 | clean_msg_period (int): Период очистки сообщений. 148 | 149 | Returns: 150 | bool: True, если удаление прошло успешно, иначе False. 151 | """ 152 | payload = RemoveUsersPayload( 153 | chat_id=chat_id, 154 | user_ids=user_ids, 155 | clean_msg_period=clean_msg_period, 156 | ).model_dump(by_alias=True) 157 | 158 | data = await self._send_and_wait( 159 | opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload 160 | ) 161 | 162 | if data.get("payload", {}).get("error"): 163 | MixinsUtils.handle_error(data) 164 | 165 | chat = Chat.from_dict(data["payload"]["chat"]) 166 | if chat: 167 | cached_chat = await self._get_chat(chat.id) 168 | if cached_chat is None: 169 | self.chats.append(chat) 170 | else: 171 | idx = self.chats.index(cached_chat) 172 | self.chats[idx] = chat 173 | 174 | return True 175 | 176 | async def change_group_settings( 177 | self, 178 | chat_id: int, 179 | all_can_pin_message: bool | None = None, 180 | only_owner_can_change_icon_title: bool | None = None, 181 | only_admin_can_add_member: bool | None = None, 182 | only_admin_can_call: bool | None = None, 183 | members_can_see_private_link: bool | None = None, 184 | ) -> None: 185 | """ 186 | Изменяет настройки группы 187 | 188 | Args: 189 | chat_id (int): ID группы. 190 | all_can_pin_message (bool | None, optional): Все могут закреплять сообщения. Defaults to None. 191 | only_owner_can_change_icon_title (bool | None, optional): Только владелец может менять иконку и название. Defaults to None. 192 | only_admin_can_add_member (bool | None, optional): Только администраторы могут добавлять участников. Defaults to None. 193 | only_admin_can_call (bool | None, optional): Только администраторы могут звонить. Defaults to None. 194 | members_can_see_private_link (bool | None, optional): Участники могут видеть приватную ссылку. Defaults to None. 195 | Returns: 196 | None 197 | """ 198 | payload = ChangeGroupSettingsPayload( 199 | chat_id=chat_id, 200 | options=ChangeGroupSettingsOptions( 201 | ALL_CAN_PIN_MESSAGE=all_can_pin_message, 202 | ONLY_OWNER_CAN_CHANGE_ICON_TITLE=only_owner_can_change_icon_title, 203 | ONLY_ADMIN_CAN_ADD_MEMBER=only_admin_can_add_member, 204 | ONLY_ADMIN_CAN_CALL=only_admin_can_call, 205 | MEMBERS_CAN_SEE_PRIVATE_LINK=members_can_see_private_link, 206 | ), 207 | ).model_dump(by_alias=True, exclude_none=True) 208 | 209 | data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload) 210 | 211 | if data.get("payload", {}).get("error"): 212 | MixinsUtils.handle_error(data) 213 | 214 | chat = Chat.from_dict(data["payload"]["chat"]) 215 | if chat: 216 | cached_chat = await self._get_chat(chat.id) 217 | if cached_chat is None: 218 | self.chats.append(chat) 219 | else: 220 | idx = self.chats.index(cached_chat) 221 | self.chats[idx] = chat 222 | 223 | async def change_group_profile( 224 | self, 225 | chat_id: int, 226 | name: str | None, 227 | description: str | None = None, 228 | ) -> None: 229 | """ 230 | Изменяет профиль группы 231 | 232 | Args: 233 | chat_id (int): ID группы. 234 | name (str | None): Название группы. 235 | description (str | None, optional): Описание группы. Defaults to None. 236 | 237 | Returns: 238 | None 239 | """ 240 | payload = ChangeGroupProfilePayload( 241 | chat_id=chat_id, 242 | theme=name, 243 | description=description, 244 | ).model_dump(by_alias=True, exclude_none=True) 245 | 246 | data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload) 247 | 248 | if data.get("payload", {}).get("error"): 249 | MixinsUtils.handle_error(data) 250 | 251 | chat = Chat.from_dict(data["payload"]["chat"]) 252 | if chat: 253 | cached_chat = await self._get_chat(chat.id) 254 | if cached_chat is None: 255 | self.chats.append(chat) 256 | else: 257 | idx = self.chats.index(cached_chat) 258 | self.chats[idx] = chat 259 | 260 | def _process_chat_join_link(self, link: str) -> str | None: 261 | idx = link.find("join/") 262 | return link[idx:] if idx != -1 else None 263 | 264 | async def join_group(self, link: str) -> Chat: 265 | """ 266 | Вступает в группу по ссылке 267 | 268 | Args: 269 | link (str): Ссылка на группу. 270 | 271 | Returns: 272 | Chat: Объект чата группы 273 | """ 274 | proceed_link = self._process_chat_join_link(link) 275 | if proceed_link is None: 276 | raise ValueError("Invalid group link") 277 | 278 | payload = JoinChatPayload(link=proceed_link).model_dump(by_alias=True) 279 | 280 | data = await self._send_and_wait(opcode=Opcode.CHAT_JOIN, payload=payload) 281 | 282 | if data.get("payload", {}).get("error"): 283 | MixinsUtils.handle_error(data) 284 | 285 | chat = Chat.from_dict(data["payload"]["chat"]) 286 | if chat: 287 | cached_chat = await self._get_chat(chat.id) 288 | if cached_chat is None: 289 | self.chats.append(chat) 290 | else: 291 | idx = self.chats.index(cached_chat) 292 | self.chats[idx] = chat 293 | 294 | return chat 295 | 296 | async def rework_invite_link(self, chat_id: int) -> Chat: 297 | """ 298 | Пересоздает ссылку для приглашения в группу 299 | 300 | Args: 301 | chat_id (int): ID группы. 302 | 303 | Returns: 304 | Chat: Обновленный объект чата с новой ссылкой. 305 | """ 306 | payload = ReworkInviteLinkPayload(chat_id=chat_id).model_dump(by_alias=True) 307 | 308 | data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload) 309 | 310 | if data.get("payload", {}).get("error"): 311 | MixinsUtils.handle_error(data) 312 | 313 | chat = Chat.from_dict(data["payload"].get("chat")) 314 | if not chat: 315 | raise Error("no_chat", "Chat data missing in response", "Chat Error") 316 | 317 | return chat 318 | 319 | async def get_chats(self, chat_ids: list[int]) -> list[Chat]: 320 | """ 321 | Получает информацию о группах по их ID 322 | 323 | :param chat_ids: Список идентификаторов групп. 324 | :type chat_ids: list[int] 325 | :return: Список объектов Chat. 326 | :rtype: list[Chat] 327 | """ 328 | missed_chat_ids = [ 329 | chat_id for chat_id in chat_ids if await self._get_chat(chat_id) is None 330 | ] 331 | if missed_chat_ids: 332 | payload = GetChatInfoPayload(chat_ids=missed_chat_ids).model_dump( 333 | by_alias=True 334 | ) 335 | else: 336 | chats: list[Chat] = [ 337 | chat 338 | for chat_id in chat_ids 339 | if (chat := await self._get_chat(chat_id)) is not None 340 | ] 341 | return chats 342 | 343 | data = await self._send_and_wait(opcode=Opcode.CHAT_INFO, payload=payload) 344 | 345 | if data.get("payload", {}).get("error"): 346 | MixinsUtils.handle_error(data) 347 | 348 | chats_data = data["payload"].get("chats", []) 349 | chats: list[Chat] = [] 350 | for chat_dict in chats_data: 351 | chat = Chat.from_dict(chat_dict) 352 | chats.append(chat) 353 | cached_chat = await self._get_chat(chat.id) 354 | if cached_chat is None: 355 | self.chats.append(chat) 356 | else: 357 | idx = self.chats.index(cached_chat) 358 | self.chats[idx] = chat 359 | 360 | return chats 361 | 362 | async def get_chat(self, chat_id: int) -> Chat: 363 | """ 364 | Получает информацию о группе по ее ID 365 | 366 | Args: 367 | chat_id (int): Идентификатор группы. 368 | 369 | Returns: 370 | Chat: Объект Chat. 371 | """ 372 | chats = await self.get_chats([chat_id]) 373 | if not chats: 374 | raise Error("no_chat", "Chat not found in response", "Chat Error") 375 | return chats[0] 376 | 377 | async def leave_group(self, chat_id: int) -> None: 378 | """ 379 | Покидает группу 380 | 381 | :param chat_id: Идентификатор группы. 382 | :type chat_id: int 383 | :return: None 384 | :rtype: None 385 | """ 386 | payload = LeaveChatPayload(chat_id=chat_id).model_dump(by_alias=True) 387 | 388 | data = await self._send_and_wait(opcode=Opcode.CHAT_LEAVE, payload=payload) 389 | 390 | if data.get("payload", {}).get("error"): 391 | MixinsUtils.handle_error(data) 392 | 393 | cached_chat = await self._get_chat(chat_id) 394 | if cached_chat is not None: 395 | self.chats.remove(cached_chat) 396 | 397 | async def leave_channel(self, chat_id: int) -> None: 398 | """ 399 | Покидает канал 400 | 401 | :param chat_id: Идентификатор канала. 402 | :type chat_id: int 403 | :return: None 404 | :rtype: None 405 | """ 406 | await self.leave_group(chat_id) 407 | 408 | async def fetch_chats(self, marker: int | None = None) -> list[Chat]: 409 | """ 410 | Загружает список чатов 411 | 412 | :param marker: Маркер для пагинации, по умолчанию None 413 | :type marker: int | None 414 | :return: Список объектов Chat 415 | :rtype: list[Chat] 416 | """ 417 | if marker is None: 418 | marker = int(time.time() * 1000) 419 | 420 | payload = FetchChatsPayload(marker=marker).model_dump(by_alias=True) 421 | 422 | data = await self._send_and_wait(opcode=Opcode.CHATS_LIST, payload=payload) 423 | 424 | if data.get("payload", {}).get("error"): 425 | MixinsUtils.handle_error(data) 426 | 427 | chats_data = data["payload"].get("chats", []) 428 | chats: list[Chat] = [] 429 | for chat_dict in chats_data: 430 | chat = Chat.from_dict(chat_dict) 431 | chats.append(chat) 432 | cached_chat = await self._get_chat(chat.id) 433 | if cached_chat is None: 434 | self.chats.append(chat) 435 | else: 436 | idx = self.chats.index(cached_chat) 437 | self.chats[idx] = chat 438 | 439 | return chats 440 | --------------------------------------------------------------------------------