diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5ce00e6..da0b504 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,31 @@ ## Описание -Кратко, что делает этот PR. Например, добавляет новый метод, исправляет баг, улучшает документацию. + +Кратко, что делает этот PR для PHPMax: переносит PyMax parity, исправляет +payload/runtime behavior, обновляет документацию или tooling. ## Тип изменений + - [ ] Исправление бага - [ ] Новая функциональность - [ ] Улучшение документации - [ ] Рефакторинг +- [ ] Parity-перенос из PyMax ## Связанные задачи / Issue + Ссылка на issue, если есть: # ## Тестирование -Покажите пример кода, который проверяет изменения: -```python -import pymax +- [ ] `just pre-publish-check` +- [ ] Если менялись protocol/model/payload слои, обновлены parity fixtures. +- [ ] Если менялись исходники, документация обновлена или явно проверена. +- [ ] Если менялся release/runtime surface, проверен `just release-zip`. + +Пример PHP-кода для проверки behavior, если нужен: + +```php +> "$GITHUB_ENV" + + - name: Upload release ZIP artifact uses: actions/upload-artifact@v4 with: - name: package-distributions - path: dist/ - - publish: - name: Publish to PyPI - runs-on: ubuntu-latest - needs: [package] + name: phpmax-release-zip + path: ${{ env.archive }} - environment: - name: pypi - - permissions: - contents: read - id-token: write - - steps: - - name: Download package distributions - uses: actions/download-artifact@v4 + - name: Attach ZIP to GitHub Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 with: - name: package-distributions - path: dist/ - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Publish package to PyPI - run: uv publish + files: ${{ env.archive }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36b2715..35054f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,8 +14,38 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: Lint + phpmax: + name: PHPMax / PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ["7.4", "8.3"] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: json, openssl, zip, pdo_sqlite + coverage: none + tools: composer:v2 + + - name: Install just + uses: extractions/setup-just@v3 + + - name: Run PHPMax pre-publish gate + run: just pre-publish-check + + - name: Verify release manifest + run: just release-check + + python-lint: + name: Python Reference / Lint runs-on: ubuntu-latest steps: @@ -36,8 +66,8 @@ jobs: - name: Run Ruff linting run: uv run ruff check . - tests: - name: Tests / Python ${{ matrix.python-version }} + python-tests: + name: Python Reference / Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: @@ -63,4 +93,4 @@ jobs: uv run pytest \ --cov=src/pymax \ --cov-report=term-missing:skip-covered \ - --cov-report=markdown-append:$GITHUB_STEP_SUMMARY \ No newline at end of file + --cov-report=markdown-append:$GITHUB_STEP_SUMMARY diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..10c9197 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Varyag Nord <124573691+varyagnord@users.noreply.github.com> +Maksim Mazein <148742019+murzein@users.noreply.github.com> diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fbd7f29 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# AGENTS.md + +## Главная цель + +Мы превращаем Python-библиотеку PyMax в PHP-библиотеку PHPMax для PHP 7.4+. +Цель не в механическом переписывании файлов, а в аккуратном переносе поведения, +протоколов, payload-контрактов и developer experience с учетом ограничений PHP +и shared hosting. + +## Обязательные правила работы + +- Перед любыми существенными изменениями читать `docs/phpmax/README.md`, + `docs/phpmax/roadmap.md`, `docs/phpmax/architecture.md` и + `docs/phpmax/upstream-sync.md`. +- Для стандартных проверок использовать `just`. Перед публикацией запускать + `just pre-publish-check`; для быстрой диагностики - `just doctor`. +- `src/pymax` считать reference-реализацией и источником правды. Не менять + Python-код без отдельной причины: он нужен для сверки с upstream PyMax. +- PHP-код должен оставаться совместимым с PHP 7.4: не использовать native enum, + union types, attributes, constructor property promotion, `match`, fibers и + другие возможности PHP 8+. +- Основной runtime проектировать под shared hosting: короткие CLI/cron-запуски, + bounded execution, явное закрытие соединений, сохранение session/sync. +- Realtime-события в первой версии поддерживаются best-effort, через + ограниченные по времени `runFor()`/poll-циклы. +- Безопасность важнее удобства: токены не логировать, session-файлы держать вне + webroot, использовать atomic write/file locks, давать понятные ошибки прав. +- Архитектуру держать слоистой: public client, app runtime, services, protocol, + transport, models/hydration, session store. Не смешивать сетевой код, + сериализацию и domain helpers. +- Для UI/документации всегда искать наиболее понятное решение. Помнить о + законах Хика и Фиттса: меньше лишних выборов, очевидные действия, короткий + путь к нужному сценарию. +- Документацию вести автоматически вместе с кодом. Если изменились исходники, + перед публикацией обязательно оценить, нужно ли обновить docs. Если изменение + косметическое и docs менять не нужно, явно зафиксировать, что docs были + просмотрены и оставлены без изменений. +- `GEMINI.md` должен быть точной копией `AGENTS.md`. При изменении одного файла + обязательно синхронизировать второй и проверять `cmp AGENTS.md GEMINI.md`. + +## Обязательная проверка upstream PyMax + +Перед началом каждого крупного этапа реализации и минимум раз в 7 дней во время +активной разработки нужно проверять, вышла ли новая версия основного Python +дистрибутива PyMax. + +Порядок: + +1. Проверить GitHub upstream `MaxApiTeam/PyMax` и опубликованный Python-пакет + `maxapi-python`. +2. Сравнить текущую reference-версию в репозитории с upstream. +3. Проанализировать diff: opcodes, payloads, auth, session/sync, protocol, + events, domain models, uploads, docs/release notes. +4. Принять решение: переносить изменение сейчас, отложить, или игнорировать как + Python-only. +5. Записать результат в `docs/phpmax/upstream-sync.md`. + +Если есть новая версия с изменениями protocol/auth/session/security, нельзя +начинать новый этап PHP-реализации, пока не решено, как эти изменения повлияют +на PHPMax. + +## Основные якоря плана + +- Staged parity: проектируем полный parity, реализуем этапами. +- Python reference остается рядом для будущего upstream merge. +- Composer-пакет PHPMax живет отдельным PSR-4 namespace `PHPMax\\`. +- TCP transport является приоритетом первой версии. +- WebSocket/QR `WebClient` переносится после TCP core как optional layer. +- Модели переносятся через явный hydration/serialization слой, а не ручной + парсинг в каждом сервисе. +- Uploads и event dispatch считаются зонами повышенного риска и требуют + отдельных parity-тестов. + +## Проверки перед завершением задачи + +- `git status --short` просмотрен. +- Если менялись инструкции, `AGENTS.md` и `GEMINI.md` идентичны. +- Если менялись docs, структура ссылок в `docs/phpmax/README.md` актуальна. +- Если менялись исходники, принято решение по документации: docs обновлены или + осознанно не изменены как ненужные для данного изменения. +- Если менялся PHP-код, добавлены или обновлены PHPUnit/parity-тесты. +- Если менялся protocol/payload/model слой, сверены fixtures с Python reference. +- Перед публикацией выполнен `just pre-publish-check` либо явно указано, почему + он не мог быть выполнен. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..fbd7f29 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,84 @@ +# AGENTS.md + +## Главная цель + +Мы превращаем Python-библиотеку PyMax в PHP-библиотеку PHPMax для PHP 7.4+. +Цель не в механическом переписывании файлов, а в аккуратном переносе поведения, +протоколов, payload-контрактов и developer experience с учетом ограничений PHP +и shared hosting. + +## Обязательные правила работы + +- Перед любыми существенными изменениями читать `docs/phpmax/README.md`, + `docs/phpmax/roadmap.md`, `docs/phpmax/architecture.md` и + `docs/phpmax/upstream-sync.md`. +- Для стандартных проверок использовать `just`. Перед публикацией запускать + `just pre-publish-check`; для быстрой диагностики - `just doctor`. +- `src/pymax` считать reference-реализацией и источником правды. Не менять + Python-код без отдельной причины: он нужен для сверки с upstream PyMax. +- PHP-код должен оставаться совместимым с PHP 7.4: не использовать native enum, + union types, attributes, constructor property promotion, `match`, fibers и + другие возможности PHP 8+. +- Основной runtime проектировать под shared hosting: короткие CLI/cron-запуски, + bounded execution, явное закрытие соединений, сохранение session/sync. +- Realtime-события в первой версии поддерживаются best-effort, через + ограниченные по времени `runFor()`/poll-циклы. +- Безопасность важнее удобства: токены не логировать, session-файлы держать вне + webroot, использовать atomic write/file locks, давать понятные ошибки прав. +- Архитектуру держать слоистой: public client, app runtime, services, protocol, + transport, models/hydration, session store. Не смешивать сетевой код, + сериализацию и domain helpers. +- Для UI/документации всегда искать наиболее понятное решение. Помнить о + законах Хика и Фиттса: меньше лишних выборов, очевидные действия, короткий + путь к нужному сценарию. +- Документацию вести автоматически вместе с кодом. Если изменились исходники, + перед публикацией обязательно оценить, нужно ли обновить docs. Если изменение + косметическое и docs менять не нужно, явно зафиксировать, что docs были + просмотрены и оставлены без изменений. +- `GEMINI.md` должен быть точной копией `AGENTS.md`. При изменении одного файла + обязательно синхронизировать второй и проверять `cmp AGENTS.md GEMINI.md`. + +## Обязательная проверка upstream PyMax + +Перед началом каждого крупного этапа реализации и минимум раз в 7 дней во время +активной разработки нужно проверять, вышла ли новая версия основного Python +дистрибутива PyMax. + +Порядок: + +1. Проверить GitHub upstream `MaxApiTeam/PyMax` и опубликованный Python-пакет + `maxapi-python`. +2. Сравнить текущую reference-версию в репозитории с upstream. +3. Проанализировать diff: opcodes, payloads, auth, session/sync, protocol, + events, domain models, uploads, docs/release notes. +4. Принять решение: переносить изменение сейчас, отложить, или игнорировать как + Python-only. +5. Записать результат в `docs/phpmax/upstream-sync.md`. + +Если есть новая версия с изменениями protocol/auth/session/security, нельзя +начинать новый этап PHP-реализации, пока не решено, как эти изменения повлияют +на PHPMax. + +## Основные якоря плана + +- Staged parity: проектируем полный parity, реализуем этапами. +- Python reference остается рядом для будущего upstream merge. +- Composer-пакет PHPMax живет отдельным PSR-4 namespace `PHPMax\\`. +- TCP transport является приоритетом первой версии. +- WebSocket/QR `WebClient` переносится после TCP core как optional layer. +- Модели переносятся через явный hydration/serialization слой, а не ручной + парсинг в каждом сервисе. +- Uploads и event dispatch считаются зонами повышенного риска и требуют + отдельных parity-тестов. + +## Проверки перед завершением задачи + +- `git status --short` просмотрен. +- Если менялись инструкции, `AGENTS.md` и `GEMINI.md` идентичны. +- Если менялись docs, структура ссылок в `docs/phpmax/README.md` актуальна. +- Если менялись исходники, принято решение по документации: docs обновлены или + осознанно не изменены как ненужные для данного изменения. +- Если менялся PHP-код, добавлены или обновлены PHPUnit/parity-тесты. +- Если менялся protocol/payload/model слой, сверены fixtures с Python reference. +- Перед публикацией выполнен `just pre-publish-check` либо явно указано, почему + он не мог быть выполнен. diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..5179dce --- /dev/null +++ b/Justfile @@ -0,0 +1,82 @@ +set shell := ["bash", "-uc"] + +default: + @just --list + +list: + @just --list + +status: + @git status --short --branch + +agents-check: + @cmp AGENTS.md GEMINI.md + @printf 'AGENTS.md and GEMINI.md are identical.\n' + +docs-list: + @find docs/phpmax -maxdepth 1 -type f | sort + +contract-check: + @php tools/contract-manifest.php check + +release-zip: + @php tools/build-release.php + +release-check: + @php tools/build-release.php --check + +docs-guard: + @changed="$(git status --porcelain | sed 's/^...//')"; \ + source_changed="$(printf '%s\n' "$changed" | grep -E '^(src/PHPMax/|tests-php/|composer\.json|composer\.lock|phpunit\.xml|phpstan\.neon|Justfile|\.github/workflows/|\.github/pull_request_template\.md|src/pymax/|tests/)' || true)"; \ + docs_changed="$(printf '%s\n' "$changed" | grep -E '^(docs/phpmax/|AGENTS\.md|GEMINI\.md|README\.md)' || true)"; \ + if [ -n "$source_changed" ] && [ -z "$docs_changed" ] && [ "${DOCS_REVIEWED:-0}" != "1" ]; then \ + printf 'Source files changed, but docs did not change.\n'; \ + printf 'Review whether documentation must be updated.\n'; \ + printf 'If docs are truly unnecessary, rerun with DOCS_REVIEWED=1.\n'; \ + printf '%s\n' "$source_changed"; \ + exit 1; \ + fi; \ + if [ -n "$source_changed" ] && [ -z "$docs_changed" ]; then \ + printf 'Source files changed; docs were reviewed and intentionally left unchanged.\n'; \ + elif [ -n "$source_changed" ]; then \ + printf 'Source and docs changes are both present.\n'; \ + else \ + printf 'No source changes requiring documentation review.\n'; \ + fi + +upstream-reminder: + @printf 'Before a milestone or protocol/auth/session/security work, run the upstream PyMax audit from docs/phpmax/upstream-sync.md.\n' + +tool-versions: + @printf 'PHP: '; php -r 'echo PHP_VERSION, PHP_EOL;' || true + @printf 'just: '; just --version || true + @printf 'Composer: '; if command -v composer >/dev/null 2>&1; then composer --version; else printf 'not installed\n'; fi + @printf 'uv: '; if command -v uv >/dev/null 2>&1; then uv --version; else printf 'not installed\n'; fi + +php-check: + @find src/PHPMax tests-php tools -name '*.php' -print0 | xargs -0 -n1 php -l >/dev/null + @php tools/php74-compat-check.php + @php tools/contract-manifest.php check + @if [ -f composer.json ] && command -v composer >/dev/null 2>&1; then \ + composer validate --strict; \ + else \ + printf 'Composer is not installed; skipping composer validate.\n'; \ + fi + @php tools/run-php-tests.php + +integration-check: + @php tools/integration-check.php + +integration-plan: + @php tools/integration-check.php --plan + +python-baseline: + @if command -v uv >/dev/null 2>&1; then \ + uv run pytest; \ + else \ + printf 'uv is not installed; skipping Python reference baseline tests.\n'; \ + fi + +doctor: agents-check docs-guard contract-check tool-versions upstream-reminder + +pre-publish-check: agents-check docs-guard php-check release-check status diff --git a/README.md b/README.md index 173e0a0..3fcd78a 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,244 @@ -# PyMax +# PHPMax -Python-библиотека для Max API. - -[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) -[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Package](https://img.shields.io/badge/package-maxapi--python-orange.svg)](https://pypi.org/project/maxapi-python/) +PHPMax is a PHP 7.4+ SDK port of PyMax for the unofficial Max internal API. +The goal is behavioral parity with PyMax while keeping the runtime practical +for shared hosting, cron jobs and short CLI scripts. > [!WARNING] -> PyMax использует неофициальный внутренний API Max. API может измениться без -> предупреждения, а использование библиотеки может нарушать условия сервиса. -> Вы используете PyMax на свой риск; авторы и контрибьюторы не несут -> ответственности за блокировки аккаунтов, потерю данных или другие последствия. +> PHPMax uses an unofficial internal Max API. The API can change without +> notice, and using this library can violate service terms. Use it at your own +> risk. + +## Status + +This repository is an active PHP port. The Python implementation in `src/pymax` +is kept as the reference implementation for contract checks and upstream sync. -## Что это +Implemented foundations include: -**PyMax** - асинхронная Python-библиотека для внутреннего API Max. Она умеет -авторизоваться в аккаунте, слушать события, отправлять сообщения, работать с -чатами, пользователями, файлами, сессиями и доменными типами Max через TCP или -WebSocket. +- PHP 7.4-compatible Composer package with PSR-4 namespace `PHPMax\\`; +- TCP client runtime, bounded lifecycle, reconnect and heartbeat; +- JSON and optional SQLite session stores; +- auth/session flows, token login, SMS auth, 2FA helpers and QR auth + foundation; +- messages, chats, users, account/folders, bots and domain-bound helpers; +- typed events, router, raw fallback, error scopes and disconnect callbacks; +- file/photo/video upload foundations with bounded processing waits; +- WebSocket `WebClient` scaffold, proxy adapters and explicit telemetry; +- release ZIP workflow for shared hosting without shell Composer install. -## Возможности +Real-account integration checks are intentionally opt-in and require your own +token/account configuration. -- Авторизация по телефону и SMS-коду через `Client`. -- QR-авторизация web-клиента через `WebClient`. -- Роутеры, фильтры, `on_start`, raw-события и typed events. -- Сообщения: отправка, ответы, reply, реакции, pin, read, delete и история. -- Чаты, группы, участники, invite-ссылки и настройки групп. -- Пользователи, контакты, профиль, папки, активные сессии и 2FA. -- Вложения: `Photo`, `File`, `Video`. -- SQLite-сессии, sync-state, reconnect и debug-логи. -- Pydantic-модели и удобные domain-объекты. +## Requirements -## Установка +- PHP 7.4 or newer; +- `ext-json`; +- `ext-openssl`; +- optional `ext-curl` for streaming file/video uploads and uploads through a + proxy; +- optional `ext-pdo_sqlite` for SQLite session storage; +- optional `ext-zstd` for Zstd-compressed TCP payloads. -Требуется Python 3.10 или новее. +## Installation + +When the package is installed through Composer: ```bash -pip install -U maxapi-python +composer require varyagnord/phpmax ``` -Через `uv`: +Until the package is published on Packagist, add this repository as a VCS +source first: ```bash -uv add -U maxapi-python +composer config repositories.phpmax vcs https://github.com/varyagnord/PHPMax +composer require varyagnord/phpmax:dev-main ``` -Напрямую из репозитория: +For shared hosting without shell access, build a runtime archive locally and +upload the ZIP contents: ```bash -pip install git+https://github.com/MaxApiTeam/PyMax.git +just release-zip ``` -## Быстрый старт +The release archive contains `autoload.php`, `src/PHPMax`, `docs/phpmax`, +`composer.json` and optional `vendor/` if runtime Composer packages are added +later. -`Client` использует TCP-соединение. При первом запуске PyMax попросит SMS-код -и сохранит сессию в SQLite-файл; дальше этот файл используется автоматически. +## Quick Start -```python -import asyncio +```php + None: - print("Клиент запущен") - print("Ваш ID:", client.me.contact.id if client.me else "unknown") +$client = new Client(new ClientOptions([ + 'phone' => '+79990000000', + 'workDir' => __DIR__ . '/var/phpmax', + 'sessionName' => 'main.json', +])); +$client->onStart(static function (Client $client): void { + $profile = $client->me(); + $userId = $profile !== null && $profile->contact !== null + ? $profile->contact->id + : null; -@client.on_message() -async def on_message(message: Message, client: Client) -> None: - print(message.chat_id, message.sender, message.text) + echo 'Client started, user id: ' . ($userId !== null ? $userId : 'unknown') . PHP_EOL; +}); - if message.chat_id is not None and message.text: - await message.answer("Привет от PyMax") +$client->onMessage(static function (Message $message, Client $client): void { + echo $message->chatId . ': ' . $message->text . PHP_EOL; + if ($message->text === '/start') { + $message->reply('PHPMax is running'); + } +}); -async def main() -> None: - await client.start() +$client->withOpenSession(static function (Client $client): void { + $client->runFor(25); +}); +``` +`Client::withOpenSession()` is the preferred short-script shape: it opens the +connection, runs bounded work and always closes transport/session resources. -if __name__ == "__main__": - asyncio.run(main()) -``` +## Token Login -## WebClient +If you already have a valid token: -`WebClient` использует WebSocket и QR-авторизацию: +```php + getenv('PHPMAX_TOKEN') ?: null, + 'workDir' => __DIR__ . '/var/phpmax', + 'sessionName' => 'token-session.json', +])); -client = WebClient(work_dir="cache", session_name="web.db") +$client->withOpenSession(static function (Client $client): void { + $client->sendMessage(123456789, 'Hello from PHPMax'); +}); +``` +Do not log tokens. Keep `workDir` and session files outside the webroot. -@client.on_start() -async def on_start(client: WebClient) -> None: - print("Web-клиент запущен") +## Sending Files +```php + getenv('PHPMAX_TOKEN') ?: null, + 'workDir' => __DIR__ . '/var/phpmax', +])); -Обработчики можно регистрировать на клиенте или вынести в отдельный роутер. -Handler всегда принимает событие и клиента: `(event, client)`. +$client->withOpenSession(static function (Client $client): void { + $photo = Photo::fromPath(__DIR__ . '/avatar.png'); + $attachment = $client->uploadPhoto($photo); -```python -from pymax import Client, ClientRouter, Message + $client->sendMessage(123456789, 'Photo uploaded', null, [$attachment]); +}); +``` -router = ClientRouter() +For large file/video uploads, install `ext-curl`; PHPMax streams those uploads +through a read callback instead of buffering the entire request body. +## WebClient and QR Auth -def is_start(message: Message) -> bool: - return message.text == "/start" +`WebClient` uses the shared bounded lifecycle, WebSocket transport and QR auth +flow: +```php + None: - await message.answer("Готово") +use PHPMax\Config\ClientOptions; +use PHPMax\WebClient; +$client = new WebClient(new ClientOptions([ + 'workDir' => __DIR__ . '/var/phpmax', + 'sessionName' => 'web.json', +])); -client = Client(phone="+79990000000", work_dir="cache") -client.include_router(router) +$client->withOpenSession(static function (WebClient $client): void { + $client->runFor(25); +}); ``` -## Куда дальше +The default QR handler prints the QR payload in CLI. Production code can pass a +custom `QrHandlerInterface` implementation to `WebClient`. -- [Getting Started](docs/getting-started.rst) - первый запуск и сессии. -- [Client](docs/client.rst) - жизненный цикл клиента, reconnect и sync-state. -- [Router](docs/router.rst) - роутеры, фильтры и raw events. -- [Messages](docs/messages.rst) - сообщения, реакции, история и вложения. -- [Files](docs/files.rst) - отправка и скачивание файлов. -- [FAQ](docs/faq.rst) и [Troubleshooting](docs/troubleshooting.rst) - частые - проблемы. +## Integration Checks -Опубликованная документация: +Local deterministic gates: -- [docs.pymax.org](https://docs.pymax.org/) -- [DeepWiki](https://deepwiki.com/MaxApiTeam/PyMax) +```bash +just doctor +just php-check +just pre-publish-check +``` -## Разработка +Real-account checks are disabled by default: ```bash -uv sync --all-groups -uv run pre-commit install -uv run pre-commit run --all-files -uv run pytest -uv run python -c "import pymax; print(pymax.__all__)" -uv run sphinx-build -b html docs docs/_build/html +just integration-plan +PHPMAX_INTEGRATION=1 PHPMAX_TOKEN=... just integration-check ``` -## Ссылки +To verify first login by phone, SMS-code prompting, local session persistence +and login reuse from the saved session: -- [GitHub](https://github.com/MaxApiTeam/PyMax) -- [PyPI](https://pypi.org/project/maxapi-python/) -- [Telegram](https://t.me/pymax_news) +```bash +PHPMAX_INTEGRATION=1 PHPMAX_AUTH_SMS=1 PHPMAX_PHONE=+79990000000 just integration-check +``` + +Optional environment flags enable upload/download/WebSocket/proxy/telemetry +paths. The integration harness performs a secret-safe preflight and does not +print token or proxy credentials. + +## Documentation + +PHPMax-specific documentation lives in `docs/phpmax`: -## Лицензия +- `docs/phpmax/README.md` - documentation hub; +- `docs/phpmax/architecture.md` - architecture and layer boundaries; +- `docs/phpmax/roadmap.md` - staged parity plan; +- `docs/phpmax/upstream-sync.md` - mandatory PyMax upstream sync log; +- `docs/phpmax/testing.md` - test and parity strategy; +- `docs/phpmax/implementation-status.md` - current implementation status. + +The original PyMax RST documentation remains as reference material for the +Python package and parity work. + +## Development + +```bash +just list +just contract-check +just php-check +just release-check +just pre-publish-check +``` -Проект распространяется под лицензией MIT. Подробности см. в [LICENSE](LICENSE). +Before publishing to git, source changes must be reviewed together with +documentation changes. If a source change is cosmetic and does not require docs, +run `DOCS_REVIEWED=1 just docs-guard` after that decision is made. -## Авторы +## License -- [ink](https://github.com/ink-developer) - основной разработчик, исследование - API и документация. -- [noxzion](https://github.com/noxzion) - оригинальный автор проекта. +MIT. See `LICENSE`. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..332c1ed --- /dev/null +++ b/composer.json @@ -0,0 +1,41 @@ +{ + "name": "varyagnord/phpmax", + "description": "PHP 7.4+ SDK port of PyMax for the unofficial Max internal API.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=7.4", + "ext-json": "*", + "ext-openssl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.12" + }, + "suggest": { + "ext-msgpack": "Use native MessagePack encoding/decoding when available.", + "ext-zstd": "Decode Zstd-compressed TCP payloads when available.", + "ext-curl": "Stream file/video uploads without buffering large request bodies into strings.", + "ext-pdo_sqlite": "Use the optional SQLite session store for durable multi-session persistence." + }, + "autoload": { + "psr-4": { + "PHPMax\\": "src/PHPMax/" + } + }, + "autoload-dev": { + "psr-4": { + "PHPMax\\Tests\\": "tests-php/" + } + }, + "scripts": { + "test": "php tools/run-php-tests.php", + "phpstan": "phpstan analyse src/PHPMax tests-php" + }, + "config": { + "platform": { + "php": "7.4.33" + }, + "sort-packages": true + } +} diff --git a/docs/phpmax/README.md b/docs/phpmax/README.md new file mode 100644 index 0000000..5928075 --- /dev/null +++ b/docs/phpmax/README.md @@ -0,0 +1,124 @@ +# PHPMax Documentation Hub + +Этот раздел описывает именно PHP-порт. Существующая RST-документация в `docs/` +остается reference-документацией PyMax и помогает сверять поведение. + +## Быстрая навигация + +- `architecture.md` - целевая архитектура PHPMax и границы слоев. +- `roadmap.md` - этапы реализации staged parity. +- `shared-hosting-runtime.md` - правила runtime под cron/short CLI и shared + hosting. +- `upstream-sync.md` - обязательная проверка новых версий PyMax и принятие + решений о переносе изменений. +- `documentation.md` - правила автоматического ведения документации и проверки + перед публикацией. +- `contracts.md` - назначение и порядок обновления contract manifest. +- `contracts.json` - машинно проверяемый manifest opcodes/commands/events/ + payload anchors из Python reference. +- `release.md` - сборка release ZIP для shared hosting без composer install. +- `testing.md` - parity, unit и shared-hosting test matrix. +- `implementation-status.md` - что уже реализовано в PHPMax и что остается + следующим. +- `decision-log.md` - зафиксированные архитектурные решения. +- Корневой `README.md` - публичный quick start PHPMax, который входит в + release ZIP. + +## Основные якоря + +- PHPMax должен быть Composer-пакетом для PHP 7.4+. +- Python reference сохраняется рядом, чтобы регулярно подтягивать upstream + PyMax и сверять контракты. +- Первая рабочая цель - TCP core: protocol, session, auth, messages, bounded + event polling. +- Bounded runtime теперь включает PyMax-like heartbeat: `Client::runFor()` + отправляет `Opcode::PING` по `ClientOptions::pingInterval`, а `0.0` + отключает heartbeat явно. +- Public client state расширен до PyMax BaseClient anchors: + `me()`, `chats()`, `contacts()`, `messages()`, `stop()` и `relogin()`. +- Runtime state теперь живет в `App`: login/fetch service results обновляют + профиль и общие chat/user caches, а `Client` только отдает этот state наружу. +- Runtime dispatch включает internal typed listeners на уровне `App`: они + получают события до пользовательского `Router` и без raw fallback. +- Lifecycle close теперь идет через `App::close()`: закрываются и transport, и + session store. +- Message public shortcuts расширены до PyMax MessageMixin surface: + forward/edit/history/delete/pin/reactions/read/download helpers доступны + напрямую на `Client`. +- Files/uploads слой начат: `Photo`/`File`/`Video`, typed upload payload + shortcuts, multipart/stream HTTP adapter и bounded `NOTIF_ATTACH` wait. +- Optional telemetry слой начат: явная отправка `Opcode::LOG`, без скрытого + background loop, с выключенным по умолчанию автологином и bounded navigation + planner для явной отправки NAV/PERF batches. +- QR auth foundation добавлен в auth layer: request/check/confirm/approve QR и + bounded `QrAuthFlow`. +- WebSocket foundation начат: `WsProtocol`, message reader, + hardened `WebSocketTransport` и `WebClient` scaffold поверх shared bounded + runtime. +- Proxy foundation начат: `ClientOptions::proxy` прокидывается в TCP, + WebSocket и HTTP uploads; поддержаны HTTP CONNECT и SOCKS5 adapters. +- Transport/runtime configuration fail-fast отклоняет empty endpoint host и + port вне `1..65535`. +- Persistence foundation расширен: JSON store остается простым default, а + `SQLiteSessionStore` добавлен как optional backend при наличии `pdo_sqlite`. + Оба built-in store принимают только plain `sessionName`, без path segments. +- Release ZIP workflow добавлен: `tools/build-release.php`, `just release-zip` + и fallback `autoload.php` внутри архива. +- Корневой `README.md` теперь описывает PHPMax, а не Python PyMax, и является + частью release ZIP developer experience. +- Shared hosting - обязательное ограничение, поэтому long-running daemon не + является единственным режимом работы. +- Модели, payloads и attachments переносить через общий hydration/serialization + слой. +- Contract manifest фиксирует PyMax opcodes/commands/event map/domain enum + values/API enum values/payload/domain/event anchors/serialized payload + keys/service method surface/client mixin shortcuts, включая public parameter + names/order, и проверяется через `just contract-check`. +- API error frames сохраняются как `ApiException` с opcode, error code, + title/message/localizedMessage и исходным payload. +- Любое изменение upstream PyMax сначала анализируется, затем переносится или + осознанно откладывается. +- Документация поддерживается вместе с кодом; при изменении исходников всегда + проверяется, нужна ли актуализация docs. +- `just` является стандартной точкой входа для проверок: `just doctor`, + `just pre-publish-check`, `just docs-guard`, `just integration-plan`, + `just integration-check`. + +## Как начинать новую задачу + +1. Прочитать `AGENTS.md`. +2. Проверить `upstream-sync.md`: не просрочена ли обязательная upstream-проверка. +3. Найти нужный этап в `roadmap.md`. +4. Проверить архитектурные границы в `architecture.md`. +5. После изменения кода обновить тесты и документацию, если это влияет на + поведение, API, архитектуру, установку, runtime или workflow. +6. Перед публикацией запустить `just pre-publish-check`. + +## Just recipes + +Основные рецепты: + +- `just` или `just list` - показать доступные рецепты. +- `just doctor` - быстрые проверки инструкций, документационного guard-а и + доступных инструментов. +- `just contract-check` - проверить `docs/phpmax/contracts.json` против + `src/pymax` и PHP constants/event resolver. +- `just docs-guard` - проверить, что изменения исходников сопровождены + документацией или осознанным решением ее не менять. +- `just php-check` - PHP lint, PHP 7.4 compatibility scan, contract manifest + check, optional Composer validation и lightweight tests. +- `just release-check` - проверить release manifest/vendor policy без сборки + ZIP. +- `just release-zip` - собрать `dist/phpmax-dev.zip` для shared hosting. +- `just integration-plan` - показать безопасный план real-account проверок и + нужные env-переменные без сетевых запросов и без вывода секретов. +- `just integration-check` - optional real-account checks. По умолчанию + безопасно пропускается; реальные TCP/WebSocket/upload/download/bot/telemetry + проверки запускаются только при `PHPMAX_INTEGRATION=1` и нужных + env-параметрах. Token login использует `PHPMAX_TOKEN`; first phone login + проверяется через `PHPMAX_AUTH_SMS=1` и `PHPMAX_PHONE`, после чего harness + проверяет сохраненную local session и повторный вход из нее. Перед сетью + harness выполняет secret-safe preflight конфигурации. +- `DOCS_REVIEWED=1 just docs-guard` - разрешить публикацию мелкого изменения + исходников без изменения docs после ручной проверки. +- `just pre-publish-check` - обязательная проверка перед публикацией в git. diff --git a/docs/phpmax/architecture.md b/docs/phpmax/architecture.md new file mode 100644 index 0000000..33d9193 --- /dev/null +++ b/docs/phpmax/architecture.md @@ -0,0 +1,168 @@ +# PHPMax Architecture + +## Целевая форма пакета + +PHPMax проектируется как чистый Composer-пакет с PSR-4 namespace `PHPMax\\`. +Python-код в `src/pymax` остается reference implementation и не является частью +PHP autoload. + +Release ZIP для shared hosting является build artifact: он содержит PHP runtime +слой, docs и optional `vendor`, но не включает Python reference, tests или +tooling. + +## Слои + +1. Public API: + `Client`, `WebClient`, `Router`, domain models, file helpers. +2. Runtime: + `App`, lifecycle, bounded execution, reconnect policy, heartbeat ping, + dispatcher. +3. Services: + auth, session, messages, chats, users, self/account, uploads, bots, + telemetry. +4. Files and HTTP upload: + `File`/`Photo`/`Video` value objects, upload payloads, + `HttpUploaderInterface`, bounded processing wait. +5. Protocol: + frames, commands, opcodes, TCP framing, MessagePack, compression adapters, + WebSocket JSON protocol, contract manifest checks. +6. Transport: + blocking TLS TCP streams first, blocking WebSocket transport, proxy adapters. +7. Models: + hydration, aliases, defaults, domain-only unknown extra fields, attachment + discriminator. +8. Persistence: + session store adapters, sync state, token update, safe file permissions. + +## Что переносить буквально + +- Opcode values. +- Command values. +- TCP header layout. +- Sync/session semantics. +- Payload keys and aliases. +- Mobile/web user-agent generation anchors: app versions, Android device + profiles, locale/timezone list, web app version and header user-agent. +- Hydrator input compatibility: PyMax/Pydantic snake_case field names, + protocol camelCase keys and explicit aliases должны приниматься одинаково. +- Domain enum export parity: PyMax enum-like values должны иметь PHP 7.4 + constant classes, даже если runtime хранит их как строки. +- Optional field nullability: если PyMax default равен `None`, PHP model не + должен подменять отсутствующее поле пустой коллекцией без явной причины. +- Attachment discriminator parity: каждый известный `_type` должен иметь + concrete PHP model с PyMax fields; discriminator принимает `_type` и `type`; + `UnknownAttachment` предназначен только для future/unknown types. +- Event resolution rules. +- Error payload shape. +- Contract anchors from `docs/phpmax/contracts.json`, including protocol values, + event routing, domain enum values, API enum values, response payload key + values, payload/domain/event model fields, serialized payload keys and public + API service method/public client shortcut surface, including public + parameter names/order. + +## Что переносить PHP-идиоматично + +- Python decorators заменить явными `Router::onMessage($handler, ...$filters)`. +- Pydantic заменить schema-driven hydrator. +- Schema-driven hydrator сериализует canonical protocol keys, но на входе + принимает и PHP property names, и PyMax snake_case field names. Специальные + `normalizeInput()` wrappers не должны затирать вложенные значения `null`-ом, + если outer field реально отсутствует. +- Explicit model input must be validated like Pydantic: `fromArray([])` fails + for models with required fields, and explicit scalar values for array/list/ + map-list fields fail instead of being silently converted to empty + collections. Defaults apply only to absent optional fields. PHP arrays used + for `list<...>` fields must be list-like (`0..n` integer keys); associative + arrays remain maps and must not be accepted where PyMax expects a list. + Primitive list schemas (`list`, `list`, `list`, + `list`, `list`) validate every item through the same guarded + casting rules as scalar fields. `Profile::profileOptions` stays a flexible + array/map exception because live profile fixtures may arrive as option maps + instead of PyMax's annotated list form. + Scalar coercion must also stay Pydantic-like: numeric integer strings and + integer-compatible floats are accepted for `int`, common boolean strings are + accepted for `bool`, but malformed scalar values must fail instead of using + PHP's permissive casts. +- Unknown fields are preserved only for `PHPMax\Domain\*` models, matching + PyMax `domain/base.py` `extra="allow"`. `PHPMax\Api\*` payload models and + `PHPMax\Session\*` storage models ignore unknown keys like PyMax default + Pydantic models, so future or malformed input does not leak back into + outgoing protocol payloads or persisted session rows. +- Async/await заменить bounded blocking runtime с timeouts. +- Python mixins заменить thin methods на `Client`, делегирующие в services. +- PyMax BaseClient properties переносить как явные PHP methods: + `me()`, `chats()`, `contacts()`, `messages()`. Объекты из login response + должны быть bound к services и seeded в `App` runtime state. +- `App` является владельцем runtime state (`me`, `chats`, `users`, `contacts`, + `messages`). Services не должны держать независимые долгоживущие caches, + которые могут разойтись с login/sync/profile state. +- PyMax internal dispatcher переносится как internal typed listeners в `App`: + `ConnectionManager` вызывает internal listeners перед пользовательским + `Router`, а raw fallback остается только во внешнем router-е. +- `Client::close()` делегирует в `App::close()`: runtime layer закрывает + connection и session store как единый lifecycle boundary. +- Saved session continuity must win over fresh config ids: if a loaded session + has `mtInstanceId`, handshake/login must reuse it and update runtime + options before `SESSION_INIT`; only sessions without `mtInstanceId` use the + current `ClientOptions::mtInstanceId`. +- Native Python enums заменить PHP 7.4-compatible constant classes. +- PyMax background telemetry заменить explicit/bounded `TelemetryService`, + который не влияет на успешность основного API-вызова. +- QR auth polling держать внутри bounded `QrAuthFlow`; WebSocket transport + добавлять отдельным optional слоем, не смешивая его с TCP auth service. +- Общий runtime читать кадры через `FrameReaderInterface`: TCP использует header + framing, WebSocket получает уже собранные text messages. +- WebSocket transport валидирует HTTP Upgrade и fail-fast отклоняет + protocol-invalid server frames: masked server frames, RSV bits без extensions, + fragmented/oversized control frames, unexpected continuation frames и + interleaved data frames внутри fragmentation chain. Так как `WsProtocol` + переносит Max JSON frames, server data frames должны быть text; binary + messages отклоняются на transport boundary, а собранная text message должна + быть валидным UTF-8. +- Proxy URL хранить в `ClientOptions` и применять на transport/upload boundary, + не протаскивая его в services или domain models. +- Session persistence держать за `SessionStoreInterface`: JSON store для + простого shared hosting, SQLite store как optional backend при наличии + `pdo_sqlite`, без изменения public client/service API. Store interface + сохраняет PyMax parity для `save`, `load`, lookup by device/phone, + token update, delete by token и full local cleanup through + `deleteAllSessions()`. +- Release packaging держать в `tools/build-release.php` и `just release-zip`, + не добавляя runtime-зависимость от build tooling. +- `Command::ERROR` превращать в `ApiException`, сохраняя opcode, error code, + title/message/localizedMessage и raw payload. +- PyMax ping loop держать внутри bounded `Client::runFor()`: отправлять + `Opcode::PING` с `interactive=true` по `ClientOptions::pingInterval`, без + отдельного daemon/background loop. +- `relogin()` должен удалять текущую local session из `SessionStoreInterface`, + сбрасывать in-memory session/login state и опционально очищать config token + перед повторным `open()`. + +## Зоны повышенного риска + +- TCP MessagePack/framing/compression. +- MessagePack fallback обязан сохранять 64-bit timestamps/ids; усечение до + 32-bit ломает QR expiry, events и часть protocol payloads. +- Auth/login/session token refresh. +- API errors: нельзя терять server error payload или превращать его только в + generic string exception. +- Upload video/file: HTTP upload, streaming constraints, bounded ожидание + `NOTIF_ATTACH`, включая события, пришедшие во время HTTP upload до входа в + blocking wait. +- Event dispatch: raw frames, filters, error scopes, internal-before-user + handler order. +- Domain bound methods: `Message::answer()`, `Chat::history()` и похожие. +- Telemetry: не допустить скрытого long-running loop или падения основного + login/runtime из-за диагностического `Opcode::LOG`. +- WebSocket: корректный HTTP Upgrade, Origin `https://web.max.ru`, masked + client frames, unmasked server frames, ping/pong, strict fragmentation rules + и bounded message reads. +- Proxy: HTTP CONNECT/SOCKS5 handshake, TLS после CONNECT, учет credentials без + логирования и одинаковое поведение TCP/WebSocket/upload. +- Persistence: session store не должен терять token refresh или sync markers; + SQLite schema должна оставаться совместимой с PyMax session columns. +- Contract drift: изменения `src/pymax` в opcodes/commands/event map/payload + anchors, domain/response/attachment anchors, typed event anchors, + serialized payload keys, public service methods, public client mixin methods + или parameter names/order должны сначала пройти `just contract-check`, затем + получить PHP перенос или осознанное backlog-решение. diff --git a/docs/phpmax/contracts.json b/docs/phpmax/contracts.json new file mode 100644 index 0000000..83cfb59 --- /dev/null +++ b/docs/phpmax/contracts.json @@ -0,0 +1,3678 @@ +{ + "generated_from": { + "pymax_version": "2.3.1", + "pymax_commit": "8c40b71e57db1e6d6c95fa345000c2a07f8ed161", + "reference_paths": [ + "src/pymax/protocol/enums.py", + "src/pymax/dispatch/enums.py", + "src/pymax/dispatch/mapping.py", + "src/pymax/api/*/enums.py", + "src/pymax/types/domain/enums.py", + "src/pymax/types/domain/attachments/enums.py", + "src/pymax/api/*/payloads.py", + "src/pymax/api/*/service.py", + "src/pymax/infra/*.py", + "src/pymax/types/domain/*.py", + "src/pymax/types/domain/attachments/**/*.py", + "src/pymax/types/events/*.py" + ] + }, + "commands": { + "ERROR": 3, + "EVENT": 2, + "REQUEST": 0, + "RESPONSE": 1 + }, + "opcodes": { + "ASSETS_ADD": 29, + "ASSETS_GET": 26, + "ASSETS_GET_BY_IDS": 28, + "ASSETS_LIST_MODIFY": 261, + "ASSETS_MOVE": 260, + "ASSETS_REMOVE": 259, + "ASSETS_UPDATE": 27, + "AUDIO_PLAY": 301, + "AUTH": 18, + "AUTH_2FA_DETAILS": 104, + "AUTH_CHECK_EMAIL": 110, + "AUTH_CHECK_PASSWORD": 113, + "AUTH_CONFIRM": 23, + "AUTH_CREATE_TRACK": 112, + "AUTH_LOGIN_CHECK_PASSWORD": 115, + "AUTH_LOGIN_PROFILE_DELETE": 116, + "AUTH_LOGIN_RESTORE_PASSWORD": 101, + "AUTH_QR_APPROVE": 290, + "AUTH_REQUEST": 17, + "AUTH_SET_2FA": 111, + "AUTH_VALIDATE_HINT": 108, + "AUTH_VALIDATE_PASSWORD": 107, + "AUTH_VERIFY_EMAIL": 109, + "BANNERS_GET": 302, + "BOT_INFO": 145, + "CALLS_TOKEN": 158, + "CHATS_LIST": 53, + "CHAT_BOT_COMMANDS": 144, + "CHAT_CHECK_LINK": 56, + "CHAT_CLEAR": 54, + "CHAT_COMPLAIN": 117, + "CHAT_CREATE": 63, + "CHAT_DELETE": 52, + "CHAT_HIDE": 196, + "CHAT_HISTORY": 49, + "CHAT_INFO": 48, + "CHAT_JOIN": 57, + "CHAT_LEAVE": 58, + "CHAT_LIVESTREAM_INFO": 62, + "CHAT_MARK": 50, + "CHAT_MEDIA": 51, + "CHAT_MEMBERS": 59, + "CHAT_MEMBERS_UPDATE": 77, + "CHAT_PERSONAL_CONFIG": 61, + "CHAT_PIN_SET_VISIBILITY": 86, + "CHAT_REACTIONS_SETTINGS_SET": 257, + "CHAT_SEARCH": 68, + "CHAT_SEARCH_COMMON_PARTICIPANTS": 198, + "CHAT_SUBSCRIBE": 75, + "CHAT_SUGGEST": 300, + "CHAT_UPDATE": 55, + "COMPLAIN": 161, + "COMPLAIN_REASONS_GET": 162, + "CONFIG": 22, + "CONTACT_ADD": 33, + "CONTACT_INFO": 32, + "CONTACT_INFO_BY_PHONE": 46, + "CONTACT_LIST": 36, + "CONTACT_MUTUAL": 38, + "CONTACT_PHOTOS": 39, + "CONTACT_PRESENCE": 35, + "CONTACT_SEARCH": 37, + "CONTACT_SORT": 40, + "CONTACT_UPDATE": 34, + "CONTACT_VERIFY": 42, + "DEBUG": 2, + "DRAFT_DISCARD": 177, + "DRAFT_SAVE": 176, + "EXTERNAL_CALLBACK": 105, + "FILE_DOWNLOAD": 88, + "FILE_UPLOAD": 87, + "FOLDERS_DELETE": 276, + "FOLDERS_GET": 272, + "FOLDERS_GET_BY_ID": 273, + "FOLDERS_REORDER": 275, + "FOLDERS_UPDATE": 274, + "GET_INBOUND_CALLS": 103, + "GET_LAST_MENTIONS": 127, + "GET_POLL_UPDATES": 306, + "GET_QR": 288, + "GET_QR_STATUS": 289, + "LINK_INFO": 89, + "LOCATION_REQUEST": 126, + "LOCATION_SEND": 125, + "LOCATION_STOP": 124, + "LOG": 5, + "LOGIN": 19, + "LOGIN_BY_QR": 291, + "LOGOUT": 20, + "MSG_CANCEL_REACTION": 179, + "MSG_DELETE": 66, + "MSG_DELETE_RANGE": 92, + "MSG_DELIVERY": 303, + "MSG_EDIT": 67, + "MSG_GET": 71, + "MSG_GET_DETAILED_REACTIONS": 181, + "MSG_GET_REACTIONS": 180, + "MSG_GET_STAT": 74, + "MSG_REACTION": 178, + "MSG_SEARCH": 73, + "MSG_SEARCH_TOUCH": 72, + "MSG_SEND": 64, + "MSG_SEND_CALLBACK": 118, + "MSG_SHARE_PREVIEW": 70, + "MSG_TYPING": 65, + "NOTIF_ASSETS_UPDATE": 150, + "NOTIF_ATTACH": 136, + "NOTIF_BANNERS": 292, + "NOTIF_CALLBACK_ANSWER": 143, + "NOTIF_CALL_START": 137, + "NOTIF_CHAT": 135, + "NOTIF_CONFIG": 134, + "NOTIF_CONTACT": 131, + "NOTIF_CONTACT_SORT": 139, + "NOTIF_DRAFT": 152, + "NOTIF_DRAFT_DISCARD": 153, + "NOTIF_FOLDERS": 277, + "NOTIF_LOCATION": 147, + "NOTIF_LOCATION_REQUEST": 148, + "NOTIF_MARK": 130, + "NOTIF_MESSAGE": 128, + "NOTIF_MSG_DELAYED": 154, + "NOTIF_MSG_DELETE": 142, + "NOTIF_MSG_DELETE_RANGE": 140, + "NOTIF_MSG_REACTIONS_CHANGED": 155, + "NOTIF_MSG_YOU_REACTED": 156, + "NOTIF_PRESENCE": 132, + "NOTIF_PROFILE": 159, + "NOTIF_TRANSCRIPTION": 293, + "NOTIF_TYPING": 129, + "ORG_INFO": 256, + "PHONE_BIND_CONFIRM": 99, + "PHONE_BIND_REQUEST": 98, + "PHOTO_UPLOAD": 80, + "PING": 1, + "PRESET_AVATARS": 25, + "PROFILE": 16, + "PROFILE_DELETE": 199, + "PROFILE_DELETE_TIME": 200, + "PUBLIC_SEARCH": 60, + "REACTIONS_SETTINGS_GET_BY_CHAT_ID": 258, + "RECONNECT": 3, + "REMOVE_CONTACT_PHOTO": 43, + "SEARCH_FEEDBACK": 31, + "SEND_VOTE": 304, + "SESSIONS_CLOSE": 97, + "SESSIONS_INFO": 96, + "SESSION_INIT": 6, + "STICKER_CREATE": 193, + "STICKER_SUGGEST": 194, + "STICKER_UPLOAD": 81, + "SUSPEND_BOT": 119, + "SYNC": 21, + "TRANSCRIBE_MEDIA": 202, + "VIDEO_CHAT_CREATE_JOIN_LINK": 84, + "VIDEO_CHAT_HISTORY": 79, + "VIDEO_CHAT_JOIN": 166, + "VIDEO_CHAT_MEMBERS": 195, + "VIDEO_CHAT_START": 76, + "VIDEO_CHAT_START_ACTIVE": 78, + "VIDEO_PLAY": 83, + "VIDEO_UPLOAD": 82, + "VOTERS_LIST_BY_ANSWER": 305, + "WEB_APP_INIT_DATA": 160 + }, + "event_types": { + "CHAT_UPDATE": "chat_update", + "FILE_READY": "file_ready", + "MESSAGE_DELETE": "message_delete", + "MESSAGE_EDIT": "message_edit", + "MESSAGE_NEW": "message_new", + "MESSAGE_READ": "message_read", + "ON_START": "on_start", + "PRESENCE": "presence", + "RAW": "raw", + "REACTION_UPDATE": "reaction_update", + "TYPING": "typing", + "USER_UPDATE": "user_update", + "VIDEO_READY": "video_ready" + }, + "domain_enums": { + "AccessType": { + "PRIVATE": "PRIVATE", + "PUBLIC": "PUBLIC", + "SECRET": "SECRET" + }, + "AttachmentType": { + "AUDIO": "AUDIO", + "CALL": "CALL", + "CONTACT": "CONTACT", + "CONTROL": "CONTROL", + "FILE": "FILE", + "INLINE_KEYBOARD": "INLINE_KEYBOARD", + "PHOTO": "PHOTO", + "SHARE": "SHARE", + "STICKER": "STICKER", + "UNKNOWN": "UNKNOWN", + "VIDEO": "VIDEO" + }, + "ChatType": { + "CHANNEL": "CHANNEL", + "CHAT": "CHAT", + "DIALOG": "DIALOG" + }, + "MessageStatus": { + "EDITED": "EDITED", + "REMOVED": "REMOVED" + }, + "TranscriptionStatus": { + "FAILED": "FAILED", + "MEDIA_NOT_READY": "MEDIA_NOT_READY", + "NOT_SUPPORTED": "NOT_SUPPORTED", + "PROCESSING": "PROCESSING", + "SUCCESS": "SUCCESS", + "UNKNOWN": "UNKNOWN" + } + }, + "api_enums": { + "auth.AuthType": { + "CHECK_CODE": "CHECK_CODE", + "REGISTER": "REGISTER", + "RESEND": "RESEND", + "START_AUTH": "START_AUTH" + }, + "auth.ProfileOptions": { + "ESIA_VERIFIED_FLAG": 1, + "SECOND_FACTOR_HAS_EMAIL": 3, + "SECOND_FACTOR_HAS_HINT": 4, + "SECOND_FACTOR_PASSWORD_ENABLED": 2 + }, + "auth.TwoFactorAction": { + "EMAIL": 4, + "HINT": 3, + "REMOVE_2FA": 5, + "RESTORE_PASSWORD": 2, + "SET_PASSWORD": 0, + "UPDATE_PASSWORD": 1 + }, + "chats.ChatLinkPrefix": { + "JOIN": "join/" + }, + "chats.ChatMemberOperation": { + "ADD": "add", + "REMOVE": "remove" + }, + "chats.ChatOption": { + "ALL_CAN_PIN_MESSAGE": "ALL_CAN_PIN_MESSAGE", + "MEMBERS_CAN_SEE_PRIVATE_LINK": "MEMBERS_CAN_SEE_PRIVATE_LINK", + "ONLY_ADMIN_CAN_ADD_MEMBER": "ONLY_ADMIN_CAN_ADD_MEMBER", + "ONLY_ADMIN_CAN_CALL": "ONLY_ADMIN_CAN_CALL", + "ONLY_OWNER_CAN_CHANGE_ICON_TITLE": "ONLY_OWNER_CAN_CHANGE_ICON_TITLE" + }, + "chats.ChatPayloadKey": { + "CHAT": "chat", + "CHATS": "chats", + "MEMBERS": "members" + }, + "chats.ControlEvent": { + "NEW": "new" + }, + "messages.ItemType": { + "DELAYED": "DELAYED", + "REGULAR": "REGULAR" + }, + "messages.MessagePayloadKey": { + "MESSAGE": "message", + "MESSAGES": "messages", + "MESSAGES_REACTIONS": "messagesReactions", + "REACTION_INFO": "reactionInfo" + }, + "messages.ReadAction": { + "READ_MESSAGE": "READ_MESSAGE", + "READ_REACTION": "READ_REACTION" + }, + "self.AvatarType": { + "USER_AVATAR": "USER_AVATAR" + }, + "self.SelfPayloadKey": { + "PROFILE": "profile", + "TOKEN": "token", + "URL": "url" + }, + "session.DeviceType": { + "ANDROID": "ANDROID", + "DESKTOP": "DESKTOP", + "IOS": "IOS", + "WEB": "WEB" + }, + "users.ContactAction": { + "ADD": "ADD", + "REMOVE": "REMOVE" + }, + "users.UserPayloadKey": { + "CONTACT": "contact", + "CONTACTS": "contacts", + "SESSIONS": "sessions" + } + }, + "event_map": { + "MSG_EDIT": "resolve_message", + "NOTIF_ATTACH": "resolve_attach", + "NOTIF_CHAT": "resolve_chat", + "NOTIF_MARK": "resolve_message_read", + "NOTIF_MESSAGE": "resolve_message", + "NOTIF_MSG_DELETE": "resolve_message_delete", + "NOTIF_MSG_REACTIONS_CHANGED": "resolve_reaction_update", + "NOTIF_PRESENCE": "resolve_presence", + "NOTIF_TYPING": "resolve_typing" + }, + "payload_models": { + "auth.ApproveQrLoginPayload": { + "fields": [ + "qr_link" + ], + "payload_keys": { + "qr_link": "qrLink" + } + }, + "auth.CheckPasswordChallengePayload": { + "fields": [ + "track_id", + "password" + ], + "payload_keys": { + "track_id": "trackId", + "password": "password" + } + }, + "auth.CheckQrPayload": { + "fields": [ + "track_id" + ], + "payload_keys": { + "track_id": "trackId" + } + }, + "auth.ConfirmQrPayload": { + "fields": [ + "track_id" + ], + "payload_keys": { + "track_id": "trackId" + } + }, + "auth.ConfirmRegistrationPayload": { + "fields": [ + "first_name", + "last_name", + "token", + "token_type" + ], + "payload_keys": { + "first_name": "firstName", + "last_name": "lastName", + "token": "token", + "token_type": "tokenType" + } + }, + "auth.CreateAuthTrackPayload": { + "fields": [ + "type" + ], + "payload_keys": { + "type": "type" + } + }, + "auth.Exp": { + "fields": [ + "chats_count_groups" + ], + "payload_keys": { + "chats_count_groups": "chatsCountGroups" + } + }, + "auth.RemoveTwoFactorPayload": { + "fields": [ + "track_id", + "remove2fa", + "expected_capabilities" + ], + "payload_keys": { + "track_id": "trackId", + "remove2fa": "remove2fa", + "expected_capabilities": "expectedCapabilities" + } + }, + "auth.RequestCodePayload": { + "fields": [ + "phone", + "type", + "language" + ], + "payload_keys": { + "phone": "phone", + "type": "type", + "language": "language" + } + }, + "auth.RequestEmailCodePayload": { + "fields": [ + "track_id", + "email" + ], + "payload_keys": { + "track_id": "trackId", + "email": "email" + } + }, + "auth.SendCodePayload": { + "fields": [ + "token", + "verify_code", + "auth_token_type" + ], + "payload_keys": { + "token": "token", + "verify_code": "verifyCode", + "auth_token_type": "authTokenType" + } + }, + "auth.SendEmailCodePayload": { + "fields": [ + "track_id", + "verify_code" + ], + "payload_keys": { + "track_id": "trackId", + "verify_code": "verifyCode" + } + }, + "auth.SetHintPayload": { + "fields": [ + "track_id", + "hint" + ], + "payload_keys": { + "track_id": "trackId", + "hint": "hint" + } + }, + "auth.SetPasswordPayload": { + "fields": [ + "track_id", + "password" + ], + "payload_keys": { + "track_id": "trackId", + "password": "password" + } + }, + "auth.SetTwoFactorPayload": { + "fields": [ + "expected_capabilities", + "track_id", + "password", + "hint" + ], + "payload_keys": { + "expected_capabilities": "expectedCapabilities", + "track_id": "trackId", + "password": "password", + "hint": "hint" + } + }, + "auth.SyncPayload": { + "fields": [ + "user_agent", + "token", + "chat_hash_fingerprint", + "chats_count", + "chats_sync", + "contacts_sync", + "drafts_sync", + "interactive", + "presence_sync", + "exp", + "config_hash" + ], + "payload_keys": { + "user_agent": "userAgent", + "token": "token", + "chat_hash_fingerprint": "chatHashFingerprint", + "chats_count": "chatsCount", + "chats_sync": "chatsSync", + "contacts_sync": "contactsSync", + "drafts_sync": "draftsSync", + "interactive": "interactive", + "presence_sync": "presenceSync", + "exp": "exp", + "config_hash": "configHash" + } + }, + "auth.WebSyncPayload": { + "fields": [ + "token", + "chats_count", + "interactive", + "chats_sync", + "contacts_sync", + "presence_sync", + "drafts_sync" + ], + "payload_keys": { + "token": "token", + "chats_count": "chatsCount", + "interactive": "interactive", + "chats_sync": "chatsSync", + "contacts_sync": "contactsSync", + "presence_sync": "presenceSync", + "drafts_sync": "draftsSync" + } + }, + "bots.RequestInitDataPayload": { + "fields": [ + "bot_id", + "chat_id", + "start_param" + ], + "payload_keys": { + "bot_id": "botId", + "chat_id": "chatId", + "start_param": "startParam" + } + }, + "chats.ChangeGroupProfilePayload": { + "fields": [ + "chat_id", + "theme", + "description" + ], + "payload_keys": { + "chat_id": "chatId", + "theme": "theme", + "description": "description" + } + }, + "chats.ChangeGroupSettingsOptions": { + "fields": [ + "only_owner_can_change_icon_title", + "all_can_pin_message", + "only_admin_can_add_member", + "only_admin_can_call", + "members_can_see_private_link" + ], + "payload_keys": { + "only_owner_can_change_icon_title": "ONLY_OWNER_CAN_CHANGE_ICON_TITLE", + "all_can_pin_message": "ALL_CAN_PIN_MESSAGE", + "only_admin_can_add_member": "ONLY_ADMIN_CAN_ADD_MEMBER", + "only_admin_can_call": "ONLY_ADMIN_CAN_CALL", + "members_can_see_private_link": "MEMBERS_CAN_SEE_PRIVATE_LINK" + } + }, + "chats.ChangeGroupSettingsPayload": { + "fields": [ + "chat_id", + "options" + ], + "payload_keys": { + "chat_id": "chatId", + "options": "options" + } + }, + "chats.CreateGroupAttach": { + "fields": [ + "type", + "event", + "chat_type", + "title", + "user_ids" + ], + "payload_keys": { + "type": "_type", + "event": "event", + "chat_type": "chatType", + "title": "title", + "user_ids": "userIds" + } + }, + "chats.CreateGroupMessage": { + "fields": [ + "cid", + "attaches" + ], + "payload_keys": { + "cid": "cid", + "attaches": "attaches" + } + }, + "chats.CreateGroupPayload": { + "fields": [ + "message", + "notify" + ], + "payload_keys": { + "message": "message", + "notify": "notify" + } + }, + "chats.DeleteChatPayload": { + "fields": [ + "chat_id", + "last_event_time", + "for_all" + ], + "payload_keys": { + "chat_id": "chatId", + "last_event_time": "lastEventTime", + "for_all": "forAll" + } + }, + "chats.FetchChatsPayload": { + "fields": [ + "marker" + ], + "payload_keys": { + "marker": "marker" + } + }, + "chats.FetchJoinRequests": { + "fields": [ + "chat_id", + "type", + "count" + ], + "payload_keys": { + "chat_id": "chatId", + "type": "type", + "count": "count" + } + }, + "chats.GetChatInfoPayload": { + "fields": [ + "chat_ids" + ], + "payload_keys": { + "chat_ids": "chatIds" + } + }, + "chats.InviteUsersPayload": { + "fields": [ + "chat_id", + "user_ids", + "show_history", + "operation" + ], + "payload_keys": { + "chat_id": "chatId", + "user_ids": "userIds", + "show_history": "showHistory", + "operation": "operation" + } + }, + "chats.JoinChatPayload": { + "fields": [ + "link" + ], + "payload_keys": { + "link": "link" + } + }, + "chats.JoinRequestActionPayload": { + "fields": [ + "chat_id", + "user_ids", + "type", + "show_history", + "operation" + ], + "payload_keys": { + "chat_id": "chatId", + "user_ids": "userIds", + "type": "type", + "show_history": "showHistory", + "operation": "operation" + } + }, + "chats.LeaveChatPayload": { + "fields": [ + "chat_id" + ], + "payload_keys": { + "chat_id": "chatId" + } + }, + "chats.LinkInfoPayload": { + "fields": [ + "link" + ], + "payload_keys": { + "link": "link" + } + }, + "chats.RemoveUsersPayload": { + "fields": [ + "chat_id", + "user_ids", + "operation", + "clean_msg_period" + ], + "payload_keys": { + "chat_id": "chatId", + "user_ids": "userIds", + "operation": "operation", + "clean_msg_period": "cleanMsgPeriod" + } + }, + "chats.ReworkInviteLinkPayload": { + "fields": [ + "revoke_private_link", + "chat_id" + ], + "payload_keys": { + "revoke_private_link": "revokePrivateLink", + "chat_id": "chatId" + } + }, + "messages.AddReactionPayload": { + "fields": [ + "chat_id", + "message_id", + "reaction" + ], + "payload_keys": { + "chat_id": "chatId", + "message_id": "messageId", + "reaction": "reaction" + } + }, + "messages.ChatHistoryPayload": { + "fields": [ + "chat_id", + "forward", + "backward", + "backward_time", + "forward_time", + "get_chat", + "from_", + "item_type", + "get_messages", + "interactive" + ], + "payload_keys": { + "chat_id": "chatId", + "forward": "forward", + "backward": "backward", + "backward_time": "backwardTime", + "forward_time": "forwardTime", + "get_chat": "getChat", + "from_": "from", + "item_type": "itemType", + "get_messages": "getMessages", + "interactive": "interactive" + } + }, + "messages.DeleteMessagePayload": { + "fields": [ + "chat_id", + "message_ids", + "for_me" + ], + "payload_keys": { + "chat_id": "chatId", + "message_ids": "messageIds", + "for_me": "forMe" + } + }, + "messages.EditMessagePayload": { + "fields": [ + "chat_id", + "message_id", + "text", + "elements", + "attachments" + ], + "payload_keys": { + "chat_id": "chatId", + "message_id": "messageId", + "text": "text", + "elements": "elements", + "attachments": "attachments" + } + }, + "messages.ForwardLink": { + "fields": [ + "type", + "message_id", + "chat_id" + ], + "payload_keys": { + "type": "type", + "message_id": "messageId", + "chat_id": "chatId" + } + }, + "messages.ForwardMessagePayload": { + "fields": [ + "chat_id", + "message", + "notify" + ], + "payload_keys": { + "chat_id": "chatId", + "message": "message", + "notify": "notify" + } + }, + "messages.ForwardMessagePayloadMessage": { + "fields": [ + "cid", + "link", + "attaches" + ], + "payload_keys": { + "cid": "cid", + "link": "link", + "attaches": "attaches" + } + }, + "messages.GetFilePayload": { + "fields": [ + "chat_id", + "message_id", + "file_id" + ], + "payload_keys": { + "chat_id": "chatId", + "message_id": "messageId", + "file_id": "fileId" + } + }, + "messages.GetMessagesPayload": { + "fields": [ + "chat_id", + "message_ids" + ], + "payload_keys": { + "chat_id": "chatId", + "message_ids": "messageIds" + } + }, + "messages.GetReactionsPayload": { + "fields": [ + "chat_id", + "message_ids" + ], + "payload_keys": { + "chat_id": "chatId", + "message_ids": "messageIds" + } + }, + "messages.GetVideoPayload": { + "fields": [ + "chat_id", + "message_id", + "video_id" + ], + "payload_keys": { + "chat_id": "chatId", + "message_id": "messageId", + "video_id": "videoId" + } + }, + "messages.PinMessagePayload": { + "fields": [ + "chat_id", + "notify_pin", + "pin_message_id" + ], + "payload_keys": { + "chat_id": "chatId", + "notify_pin": "notifyPin", + "pin_message_id": "pinMessageId" + } + }, + "messages.ReactionInfoPayload": { + "fields": [ + "reaction_type", + "id" + ], + "payload_keys": { + "reaction_type": "reactionType", + "id": "id" + } + }, + "messages.ReadMessagesPayload": { + "fields": [ + "type", + "chat_id", + "message_id", + "mark" + ], + "payload_keys": { + "type": "type", + "chat_id": "chatId", + "message_id": "messageId", + "mark": "mark" + } + }, + "messages.RemoveReactionPayload": { + "fields": [ + "chat_id", + "message_id" + ], + "payload_keys": { + "chat_id": "chatId", + "message_id": "messageId" + } + }, + "messages.ReplyLink": { + "fields": [ + "type", + "message_id" + ], + "payload_keys": { + "type": "type", + "message_id": "messageId" + } + }, + "messages.SendMessagePayload": { + "fields": [ + "chat_id", + "message", + "notify" + ], + "payload_keys": { + "chat_id": "chatId", + "message": "message", + "notify": "notify" + } + }, + "messages.SendMessagePayloadMessage": { + "fields": [ + "text", + "cid", + "elements", + "attaches", + "link" + ], + "payload_keys": { + "text": "text", + "cid": "cid", + "elements": "elements", + "attaches": "attaches", + "link": "link" + } + }, + "self.ChangeProfilePayload": { + "fields": [ + "first_name", + "last_name", + "description", + "photo_token", + "avatar_type" + ], + "payload_keys": { + "first_name": "firstName", + "last_name": "lastName", + "description": "description", + "photo_token": "photoToken", + "avatar_type": "avatarType" + } + }, + "self.CreateFolderPayload": { + "fields": [ + "id", + "title", + "include", + "filters" + ], + "payload_keys": { + "id": "id", + "title": "title", + "include": "include", + "filters": "filters" + } + }, + "self.DeleteFolderPayload": { + "fields": [ + "folder_ids" + ], + "payload_keys": { + "folder_ids": "folderIds" + } + }, + "self.GetFolderPayload": { + "fields": [ + "folder_sync" + ], + "payload_keys": { + "folder_sync": "folderSync" + } + }, + "self.UpdateFolderPayload": { + "fields": [ + "id", + "title", + "include", + "filters", + "options" + ], + "payload_keys": { + "id": "id", + "title": "title", + "include": "include", + "filters": "filters", + "options": "options" + } + }, + "self.UploadPayload": { + "fields": [ + "count", + "profile" + ], + "payload_keys": { + "count": "count", + "profile": "profile" + } + }, + "session.MobileHandshakePayload": { + "fields": [ + "mt_instance_id", + "user_agent", + "client_session_id", + "device_id" + ], + "payload_keys": { + "mt_instance_id": "mt_instanceid", + "user_agent": "userAgent", + "client_session_id": "clientSessionId", + "device_id": "deviceId" + } + }, + "session.MobileUserAgentPayload": { + "fields": [ + "device_type", + "app_version", + "os_version", + "timezone", + "screen", + "push_device_type", + "arch", + "locale", + "build_number", + "device_name", + "device_locale", + "release", + "header_user_agent" + ], + "payload_keys": { + "device_type": "deviceType", + "app_version": "appVersion", + "os_version": "osVersion", + "timezone": "timezone", + "screen": "screen", + "push_device_type": "pushDeviceType", + "arch": "arch", + "locale": "locale", + "build_number": "buildNumber", + "device_name": "deviceName", + "device_locale": "deviceLocale", + "release": "release", + "header_user_agent": "headerUserAgent" + } + }, + "session.WebHandshakePayload": { + "fields": [ + "user_agent", + "device_id" + ], + "payload_keys": { + "user_agent": "userAgent", + "device_id": "deviceId" + } + }, + "uploads.AttachFilePayload": { + "fields": [ + "type", + "file_id" + ], + "payload_keys": { + "type": "_type", + "file_id": "fileId" + } + }, + "uploads.AttachPhotoPayload": { + "fields": [ + "type", + "photo_token" + ], + "payload_keys": { + "type": "_type", + "photo_token": "photoToken" + } + }, + "uploads.UploadPayload": { + "fields": [ + "count", + "profile" + ], + "payload_keys": { + "count": "count", + "profile": "profile" + } + }, + "uploads.VideoAttachPayload": { + "fields": [ + "type", + "video_id", + "token" + ], + "payload_keys": { + "type": "_type", + "video_id": "videoId", + "token": "token" + } + }, + "users.ContactActionPayload": { + "fields": [ + "contact_id", + "action" + ], + "payload_keys": { + "contact_id": "contactId", + "action": "action" + } + }, + "users.FetchContactsPayload": { + "fields": [ + "contact_ids" + ], + "payload_keys": { + "contact_ids": "contactIds" + } + }, + "users.ImportContactsPayload": { + "fields": [ + "contact_list" + ], + "payload_keys": { + "contact_list": "contactList" + } + }, + "users.SearchByPhonePayload": { + "fields": [ + "phone" + ], + "payload_keys": { + "phone": "phone" + } + }, + "users._ContactPayload": { + "fields": [ + "first_name" + ], + "payload_keys": { + "first_name": "firstName" + } + } + }, + "service_methods": { + "auth": { + "authorize_qr_login": "authorizeQrLogin", + "change_password": "changePassword", + "check_2fa": "checkTwoFactor", + "check_password": "checkPassword", + "check_qr": "checkQr", + "confirm_qr": "confirmQr", + "confirm_registration": "confirmRegistration", + "login": "login", + "mobile_login": "mobileLogin", + "remove_2fa": "removeTwoFactor", + "request_code": "requestCode", + "request_qr": "requestQr", + "send_code": "sendCode", + "set_2fa": "setTwoFactor", + "web_login": "webLogin" + }, + "bots": { + "get_init_data": "getInitData" + }, + "chats": { + "change_group_profile": "changeGroupProfile", + "change_group_settings": "changeGroupSettings", + "confirm_join_request": "confirmJoinRequest", + "confirm_join_requests": "confirmJoinRequests", + "create_group": "createGroup", + "decline_join_request": "declineJoinRequest", + "decline_join_requests": "declineJoinRequests", + "delete_chat": "deleteChat", + "fetch_chats": "fetchChats", + "get_chat": "getChat", + "get_chats": "getChats", + "get_join_requests": "getJoinRequests", + "invite_users_to_channel": "inviteUsersToChannel", + "invite_users_to_group": "inviteUsersToGroup", + "join_channel": "joinChannel", + "join_group": "joinGroup", + "leave_channel": "leaveChannel", + "leave_group": "leaveGroup", + "remove_users_from_group": "removeUsersFromGroup", + "resolve_group_by_link": "resolveGroupByLink", + "rework_invite_link": "reworkInviteLink" + }, + "messages": { + "add_reaction": "addReaction", + "delete_message": "deleteMessage", + "edit_message": "editMessage", + "fetch_history": "fetchHistory", + "forward_message": "forwardMessage", + "get_file_by_id": "getFileById", + "get_message": "getMessage", + "get_messages": "getMessages", + "get_reactions": "getReactions", + "get_video_by_id": "getVideoById", + "pin_message": "pinMessage", + "read_message": "readMessage", + "remove_reaction": "removeReaction", + "send_message": "sendMessage" + }, + "self": { + "change_profile": "changeProfile", + "close_all_sessions": "closeAllSessions", + "create_folder": "createFolder", + "delete_folder": "deleteFolder", + "get_folders": "getFolders", + "logout": "logout", + "request_profile_photo_upload_url": "requestProfilePhotoUploadUrl", + "update_folder": "updateFolder" + }, + "session": { + "handshake": "handshake", + "mobile_handshake": "mobileHandshake", + "web_handshake": "webHandshake" + }, + "uploads": { + "on_file_attach": "onFileAttach", + "on_video_attach": "onVideoAttach", + "upload_file": "uploadFile", + "upload_photo": "uploadPhoto", + "upload_video": "uploadVideo" + }, + "users": { + "add_contact": "addContact", + "fetch_users": "fetchUsers", + "get_cached_user": "getCachedUser", + "get_chat_id": "getChatId", + "get_sessions": "getSessions", + "get_user": "getUser", + "get_users": "getUsers", + "import_contacts": "importContacts", + "remove_contact": "removeContact", + "search_by_phone": "searchByPhone" + } + }, + "client_methods": { + "add_contact": "addContact", + "add_reaction": "addReaction", + "authorize_qr_login": "authorizeQrLogin", + "change_group_profile": "changeGroupProfile", + "change_group_settings": "changeGroupSettings", + "change_password": "changePassword", + "change_profile": "changeProfile", + "check_2fa": "checkTwoFactor", + "close_all_sessions": "closeAllSessions", + "confirm_join_request": "confirmJoinRequest", + "confirm_join_requests": "confirmJoinRequests", + "create_folder": "createFolder", + "create_group": "createGroup", + "decline_join_request": "declineJoinRequest", + "decline_join_requests": "declineJoinRequests", + "delete_chat": "deleteChat", + "delete_folder": "deleteFolder", + "delete_message": "deleteMessage", + "edit_message": "editMessage", + "fetch_chats": "fetchChats", + "fetch_history": "fetchHistory", + "fetch_users": "fetchUsers", + "forward_message": "forwardMessage", + "get_bot_init_data": "getBotInitData", + "get_cached_user": "getCachedUser", + "get_chat": "getChat", + "get_chat_id": "getChatId", + "get_chats": "getChats", + "get_file_by_id": "getFileById", + "get_folders": "getFolders", + "get_join_requests": "getJoinRequests", + "get_message": "getMessage", + "get_messages": "getMessages", + "get_reactions": "getReactions", + "get_sessions": "getSessions", + "get_user": "getUser", + "get_users": "getUsers", + "get_video_by_id": "getVideoById", + "import_contacts": "importContacts", + "invite_users_to_channel": "inviteUsersToChannel", + "invite_users_to_group": "inviteUsersToGroup", + "join_channel": "joinChannel", + "join_group": "joinGroup", + "leave_channel": "leaveChannel", + "leave_group": "leaveGroup", + "logout": "logout", + "pin_message": "pinMessage", + "read_message": "readMessage", + "remove_2fa": "removeTwoFactor", + "remove_contact": "removeContact", + "remove_reaction": "removeReaction", + "remove_users_from_group": "removeUsersFromGroup", + "request_profile_photo_upload_url": "requestProfilePhotoUploadUrl", + "resolve_group_by_link": "resolveGroupByLink", + "rework_invite_link": "reworkInviteLink", + "search_by_phone": "searchByPhone", + "send_message": "sendMessage", + "set_2fa": "setTwoFactor", + "update_folder": "updateFolder" + }, + "service_method_params": { + "auth": { + "authorize_qr_login": { + "php_method": "authorizeQrLogin", + "params": [ + "qr_link" + ], + "php_params": [ + "qrLink" + ] + }, + "change_password": { + "php_method": "changePassword", + "params": [ + "password_old", + "password_new" + ], + "php_params": [ + "passwordOld", + "passwordNew" + ] + }, + "check_2fa": { + "php_method": "checkTwoFactor", + "params": [], + "php_params": [] + }, + "check_password": { + "php_method": "checkPassword", + "params": [ + "track_id", + "password" + ], + "php_params": [ + "trackId", + "password" + ] + }, + "check_qr": { + "php_method": "checkQr", + "params": [ + "track_id" + ], + "php_params": [ + "trackId" + ] + }, + "confirm_qr": { + "php_method": "confirmQr", + "params": [ + "track_id" + ], + "php_params": [ + "trackId" + ] + }, + "confirm_registration": { + "php_method": "confirmRegistration", + "params": [ + "first_name", + "last_name", + "token" + ], + "php_params": [ + "firstName", + "lastName", + "token" + ] + }, + "login": { + "php_method": "login", + "params": [ + "user_agent" + ], + "php_params": [ + "userAgent" + ] + }, + "mobile_login": { + "php_method": "mobileLogin", + "params": [], + "php_params": [] + }, + "remove_2fa": { + "php_method": "removeTwoFactor", + "params": [ + "password" + ], + "php_params": [ + "password" + ] + }, + "request_code": { + "php_method": "requestCode", + "params": [ + "phone" + ], + "php_params": [ + "phone" + ] + }, + "request_qr": { + "php_method": "requestQr", + "params": [], + "php_params": [] + }, + "send_code": { + "php_method": "sendCode", + "params": [ + "token", + "verify_code" + ], + "php_params": [ + "token", + "verifyCode" + ] + }, + "set_2fa": { + "php_method": "setTwoFactor", + "params": [ + "password", + "email", + "hint", + "email_code_provider" + ], + "php_params": [ + "password", + "email", + "hint", + "emailCodeProvider" + ] + }, + "web_login": { + "php_method": "webLogin", + "params": [], + "php_params": [] + } + }, + "bots": { + "get_init_data": { + "php_method": "getInitData", + "params": [ + "bot_id", + "chat_id", + "start_param" + ], + "php_params": [ + "botId", + "chatId", + "startParam" + ] + } + }, + "chats": { + "change_group_profile": { + "php_method": "changeGroupProfile", + "params": [ + "chat_id", + "name", + "description" + ], + "php_params": [ + "chatId", + "name", + "description" + ] + }, + "change_group_settings": { + "php_method": "changeGroupSettings", + "params": [ + "chat_id", + "all_can_pin_message", + "only_owner_can_change_icon_title", + "only_admin_can_add_member", + "only_admin_can_call", + "members_can_see_private_link" + ], + "php_params": [ + "chatId", + "allCanPinMessage", + "onlyOwnerCanChangeIconTitle", + "onlyAdminCanAddMember", + "onlyAdminCanCall", + "membersCanSeePrivateLink" + ] + }, + "confirm_join_request": { + "php_method": "confirmJoinRequest", + "params": [ + "chat_id", + "user_id", + "show_history" + ], + "php_params": [ + "chatId", + "userId", + "showHistory" + ] + }, + "confirm_join_requests": { + "php_method": "confirmJoinRequests", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "create_group": { + "php_method": "createGroup", + "params": [ + "name", + "participant_ids", + "notify" + ], + "php_params": [ + "name", + "participantIds", + "notify" + ] + }, + "decline_join_request": { + "php_method": "declineJoinRequest", + "params": [ + "chat_id", + "user_id" + ], + "php_params": [ + "chatId", + "userId" + ] + }, + "decline_join_requests": { + "php_method": "declineJoinRequests", + "params": [ + "chat_id", + "user_ids" + ], + "php_params": [ + "chatId", + "userIds" + ] + }, + "delete_chat": { + "php_method": "deleteChat", + "params": [ + "chat_id", + "last_event_time", + "for_all" + ], + "php_params": [ + "chatId", + "lastEventTime", + "forAll" + ] + }, + "fetch_chats": { + "php_method": "fetchChats", + "params": [ + "marker" + ], + "php_params": [ + "marker" + ] + }, + "get_chat": { + "php_method": "getChat", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "get_chats": { + "php_method": "getChats", + "params": [ + "chat_ids" + ], + "php_params": [ + "chatIds" + ] + }, + "get_join_requests": { + "php_method": "getJoinRequests", + "params": [ + "chat_id", + "count" + ], + "php_params": [ + "chatId", + "count" + ] + }, + "invite_users_to_channel": { + "php_method": "inviteUsersToChannel", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "invite_users_to_group": { + "php_method": "inviteUsersToGroup", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "join_channel": { + "php_method": "joinChannel", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "join_group": { + "php_method": "joinGroup", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "leave_channel": { + "php_method": "leaveChannel", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "leave_group": { + "php_method": "leaveGroup", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "remove_users_from_group": { + "php_method": "removeUsersFromGroup", + "params": [ + "chat_id", + "user_ids", + "clean_msg_period" + ], + "php_params": [ + "chatId", + "userIds", + "cleanMsgPeriod" + ] + }, + "resolve_group_by_link": { + "php_method": "resolveGroupByLink", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "rework_invite_link": { + "php_method": "reworkInviteLink", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + } + }, + "messages": { + "add_reaction": { + "php_method": "addReaction", + "params": [ + "chat_id", + "message_id", + "reaction" + ], + "php_params": [ + "chatId", + "messageId", + "reaction" + ] + }, + "delete_message": { + "php_method": "deleteMessage", + "params": [ + "chat_id", + "message_ids", + "for_me" + ], + "php_params": [ + "chatId", + "messageIds", + "forMe" + ] + }, + "edit_message": { + "php_method": "editMessage", + "params": [ + "chat_id", + "message_id", + "text", + "attachments" + ], + "php_params": [ + "chatId", + "messageId", + "text", + "attachments" + ] + }, + "fetch_history": { + "php_method": "fetchHistory", + "params": [ + "chat_id", + "forward", + "backward", + "backward_time", + "forward_time", + "from_", + "item_type", + "get_chat", + "get_messages", + "interactive" + ], + "php_params": [ + "chatId", + "forward", + "backward", + "backwardTime", + "forwardTime", + "from", + "itemType", + "getChat", + "getMessages", + "interactive" + ] + }, + "forward_message": { + "php_method": "forwardMessage", + "params": [ + "chat_id", + "message_id", + "source_chat_id", + "notify" + ], + "php_params": [ + "chatId", + "messageId", + "sourceChatId", + "notify" + ] + }, + "get_file_by_id": { + "php_method": "getFileById", + "params": [ + "chat_id", + "message_id", + "file_id" + ], + "php_params": [ + "chatId", + "messageId", + "fileId" + ] + }, + "get_message": { + "php_method": "getMessage", + "params": [ + "chat_id", + "message_id" + ], + "php_params": [ + "chatId", + "messageId" + ] + }, + "get_messages": { + "php_method": "getMessages", + "params": [ + "chat_id", + "message_ids" + ], + "php_params": [ + "chatId", + "messageIds" + ] + }, + "get_reactions": { + "php_method": "getReactions", + "params": [ + "chat_id", + "message_ids" + ], + "php_params": [ + "chatId", + "messageIds" + ] + }, + "get_video_by_id": { + "php_method": "getVideoById", + "params": [ + "chat_id", + "message_id", + "video_id" + ], + "php_params": [ + "chatId", + "messageId", + "videoId" + ] + }, + "pin_message": { + "php_method": "pinMessage", + "params": [ + "chat_id", + "message_id", + "notify_pin" + ], + "php_params": [ + "chatId", + "messageId", + "notifyPin" + ] + }, + "read_message": { + "php_method": "readMessage", + "params": [ + "message_id", + "chat_id" + ], + "php_params": [ + "messageId", + "chatId" + ] + }, + "remove_reaction": { + "php_method": "removeReaction", + "params": [ + "chat_id", + "message_id" + ], + "php_params": [ + "chatId", + "messageId" + ] + }, + "send_message": { + "php_method": "sendMessage", + "params": [ + "chat_id", + "text", + "reply_to", + "attachments", + "notify" + ], + "php_params": [ + "chatId", + "text", + "replyTo", + "attachments", + "notify" + ] + } + }, + "self": { + "change_profile": { + "php_method": "changeProfile", + "params": [ + "first_name", + "last_name", + "description", + "photo", + "photo_token" + ], + "php_params": [ + "firstName", + "lastName", + "description", + "photo", + "photoToken" + ] + }, + "close_all_sessions": { + "php_method": "closeAllSessions", + "params": [], + "php_params": [] + }, + "create_folder": { + "php_method": "createFolder", + "params": [ + "title", + "chat_include", + "filters" + ], + "php_params": [ + "title", + "chatInclude", + "filters" + ] + }, + "delete_folder": { + "php_method": "deleteFolder", + "params": [ + "folder_id" + ], + "php_params": [ + "folderId" + ] + }, + "get_folders": { + "php_method": "getFolders", + "params": [ + "folder_sync" + ], + "php_params": [ + "folderSync" + ] + }, + "logout": { + "php_method": "logout", + "params": [], + "php_params": [] + }, + "request_profile_photo_upload_url": { + "php_method": "requestProfilePhotoUploadUrl", + "params": [], + "php_params": [] + }, + "update_folder": { + "php_method": "updateFolder", + "params": [ + "folder_id", + "title", + "chat_include", + "filters", + "options" + ], + "php_params": [ + "folderId", + "title", + "chatInclude", + "filters", + "options" + ] + } + }, + "session": { + "handshake": { + "php_method": "handshake", + "params": [ + "mt_instance_id", + "user_agent", + "device_id" + ], + "php_params": [ + "mtInstanceId", + "userAgent", + "deviceId" + ] + }, + "mobile_handshake": { + "php_method": "mobileHandshake", + "params": [ + "mt_instance_id", + "user_agent", + "device_id" + ], + "php_params": [ + "mtInstanceId", + "userAgent", + "deviceId" + ] + }, + "web_handshake": { + "php_method": "webHandshake", + "params": [ + "user_agent", + "device_id" + ], + "php_params": [ + "userAgent", + "deviceId" + ] + } + }, + "uploads": { + "on_file_attach": { + "php_method": "onFileAttach", + "params": [ + "attach" + ], + "php_params": [ + "attach" + ] + }, + "on_video_attach": { + "php_method": "onVideoAttach", + "params": [ + "attach" + ], + "php_params": [ + "attach" + ] + }, + "upload_file": { + "php_method": "uploadFile", + "params": [ + "file" + ], + "php_params": [ + "file" + ] + }, + "upload_photo": { + "php_method": "uploadPhoto", + "params": [ + "photo", + "profile" + ], + "php_params": [ + "photo", + "profile" + ] + }, + "upload_video": { + "php_method": "uploadVideo", + "params": [ + "video" + ], + "php_params": [ + "video" + ] + } + }, + "users": { + "add_contact": { + "php_method": "addContact", + "params": [ + "contact_id" + ], + "php_params": [ + "contactId" + ] + }, + "fetch_users": { + "php_method": "fetchUsers", + "params": [ + "user_ids" + ], + "php_params": [ + "userIds" + ] + }, + "get_cached_user": { + "php_method": "getCachedUser", + "params": [ + "user_id" + ], + "php_params": [ + "userId" + ] + }, + "get_chat_id": { + "php_method": "getChatId", + "params": [ + "first_user_id", + "second_user_id" + ], + "php_params": [ + "firstUserId", + "secondUserId" + ] + }, + "get_sessions": { + "php_method": "getSessions", + "params": [], + "php_params": [] + }, + "get_user": { + "php_method": "getUser", + "params": [ + "user_id" + ], + "php_params": [ + "userId" + ] + }, + "get_users": { + "php_method": "getUsers", + "params": [ + "user_ids" + ], + "php_params": [ + "userIds" + ] + }, + "import_contacts": { + "php_method": "importContacts", + "params": [ + "contacts" + ], + "php_params": [ + "contacts" + ] + }, + "remove_contact": { + "php_method": "removeContact", + "params": [ + "contact_id" + ], + "php_params": [ + "contactId" + ] + }, + "search_by_phone": { + "php_method": "searchByPhone", + "params": [ + "phone" + ], + "php_params": [ + "phone" + ] + } + } + }, + "client_method_params": { + "add_contact": { + "php_method": "addContact", + "params": [ + "contact_id" + ], + "php_params": [ + "contactId" + ] + }, + "add_reaction": { + "php_method": "addReaction", + "params": [ + "chat_id", + "message_id", + "reaction" + ], + "php_params": [ + "chatId", + "messageId", + "reaction" + ] + }, + "authorize_qr_login": { + "php_method": "authorizeQrLogin", + "params": [ + "qr_link" + ], + "php_params": [ + "qrLink" + ] + }, + "change_group_profile": { + "php_method": "changeGroupProfile", + "params": [ + "chat_id", + "name", + "description" + ], + "php_params": [ + "chatId", + "name", + "description" + ] + }, + "change_group_settings": { + "php_method": "changeGroupSettings", + "params": [ + "chat_id", + "all_can_pin_message", + "only_owner_can_change_icon_title", + "only_admin_can_add_member", + "only_admin_can_call", + "members_can_see_private_link" + ], + "php_params": [ + "chatId", + "allCanPinMessage", + "onlyOwnerCanChangeIconTitle", + "onlyAdminCanAddMember", + "onlyAdminCanCall", + "membersCanSeePrivateLink" + ] + }, + "change_password": { + "php_method": "changePassword", + "params": [ + "password_old", + "password_new" + ], + "php_params": [ + "passwordOld", + "passwordNew" + ] + }, + "change_profile": { + "php_method": "changeProfile", + "params": [ + "first_name", + "last_name", + "description", + "photo", + "photo_token" + ], + "php_params": [ + "firstName", + "lastName", + "description", + "photo", + "photoToken" + ] + }, + "check_2fa": { + "php_method": "checkTwoFactor", + "params": [], + "php_params": [] + }, + "close_all_sessions": { + "php_method": "closeAllSessions", + "params": [], + "php_params": [] + }, + "confirm_join_request": { + "php_method": "confirmJoinRequest", + "params": [ + "chat_id", + "user_id", + "show_history" + ], + "php_params": [ + "chatId", + "userId", + "showHistory" + ] + }, + "confirm_join_requests": { + "php_method": "confirmJoinRequests", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "create_folder": { + "php_method": "createFolder", + "params": [ + "title", + "chat_include", + "filters" + ], + "php_params": [ + "title", + "chatInclude", + "filters" + ] + }, + "create_group": { + "php_method": "createGroup", + "params": [ + "name", + "participant_ids", + "notify" + ], + "php_params": [ + "name", + "participantIds", + "notify" + ] + }, + "decline_join_request": { + "php_method": "declineJoinRequest", + "params": [ + "chat_id", + "user_id" + ], + "php_params": [ + "chatId", + "userId" + ] + }, + "decline_join_requests": { + "php_method": "declineJoinRequests", + "params": [ + "chat_id", + "user_ids" + ], + "php_params": [ + "chatId", + "userIds" + ] + }, + "delete_chat": { + "php_method": "deleteChat", + "params": [ + "chat_id", + "last_event_time", + "for_all" + ], + "php_params": [ + "chatId", + "lastEventTime", + "forAll" + ] + }, + "delete_folder": { + "php_method": "deleteFolder", + "params": [ + "folder_id" + ], + "php_params": [ + "folderId" + ] + }, + "delete_message": { + "php_method": "deleteMessage", + "params": [ + "chat_id", + "message_ids", + "for_me" + ], + "php_params": [ + "chatId", + "messageIds", + "forMe" + ] + }, + "edit_message": { + "php_method": "editMessage", + "params": [ + "chat_id", + "message_id", + "text", + "attachments" + ], + "php_params": [ + "chatId", + "messageId", + "text", + "attachments" + ] + }, + "fetch_chats": { + "php_method": "fetchChats", + "params": [ + "marker" + ], + "php_params": [ + "marker" + ] + }, + "fetch_history": { + "php_method": "fetchHistory", + "params": [ + "chat_id", + "forward", + "backward", + "backward_time", + "forward_time", + "from_time", + "item_type", + "get_chat", + "get_messages", + "interactive" + ], + "php_params": [ + "chatId", + "forward", + "backward", + "backwardTime", + "forwardTime", + "fromTime", + "itemType", + "getChat", + "getMessages", + "interactive" + ] + }, + "fetch_users": { + "php_method": "fetchUsers", + "params": [ + "user_ids" + ], + "php_params": [ + "userIds" + ] + }, + "forward_message": { + "php_method": "forwardMessage", + "params": [ + "chat_id", + "message_id", + "source_chat_id", + "notify" + ], + "php_params": [ + "chatId", + "messageId", + "sourceChatId", + "notify" + ] + }, + "get_bot_init_data": { + "php_method": "getBotInitData", + "params": [ + "bot_id", + "chat_id", + "start_param" + ], + "php_params": [ + "botId", + "chatId", + "startParam" + ] + }, + "get_cached_user": { + "php_method": "getCachedUser", + "params": [ + "user_id" + ], + "php_params": [ + "userId" + ] + }, + "get_chat": { + "php_method": "getChat", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "get_chat_id": { + "php_method": "getChatId", + "params": [ + "first_user_id", + "second_user_id" + ], + "php_params": [ + "firstUserId", + "secondUserId" + ] + }, + "get_chats": { + "php_method": "getChats", + "params": [ + "chat_ids" + ], + "php_params": [ + "chatIds" + ] + }, + "get_file_by_id": { + "php_method": "getFileById", + "params": [ + "chat_id", + "message_id", + "file_id" + ], + "php_params": [ + "chatId", + "messageId", + "fileId" + ] + }, + "get_folders": { + "php_method": "getFolders", + "params": [ + "folder_sync" + ], + "php_params": [ + "folderSync" + ] + }, + "get_join_requests": { + "php_method": "getJoinRequests", + "params": [ + "chat_id", + "count" + ], + "php_params": [ + "chatId", + "count" + ] + }, + "get_message": { + "php_method": "getMessage", + "params": [ + "chat_id", + "message_id" + ], + "php_params": [ + "chatId", + "messageId" + ] + }, + "get_messages": { + "php_method": "getMessages", + "params": [ + "chat_id", + "message_ids" + ], + "php_params": [ + "chatId", + "messageIds" + ] + }, + "get_reactions": { + "php_method": "getReactions", + "params": [ + "chat_id", + "message_ids" + ], + "php_params": [ + "chatId", + "messageIds" + ] + }, + "get_sessions": { + "php_method": "getSessions", + "params": [], + "php_params": [] + }, + "get_user": { + "php_method": "getUser", + "params": [ + "user_id" + ], + "php_params": [ + "userId" + ] + }, + "get_users": { + "php_method": "getUsers", + "params": [ + "user_ids" + ], + "php_params": [ + "userIds" + ] + }, + "get_video_by_id": { + "php_method": "getVideoById", + "params": [ + "chat_id", + "message_id", + "video_id" + ], + "php_params": [ + "chatId", + "messageId", + "videoId" + ] + }, + "import_contacts": { + "php_method": "importContacts", + "params": [ + "contacts" + ], + "php_params": [ + "contacts" + ] + }, + "invite_users_to_channel": { + "php_method": "inviteUsersToChannel", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "invite_users_to_group": { + "php_method": "inviteUsersToGroup", + "params": [ + "chat_id", + "user_ids", + "show_history" + ], + "php_params": [ + "chatId", + "userIds", + "showHistory" + ] + }, + "join_channel": { + "php_method": "joinChannel", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "join_group": { + "php_method": "joinGroup", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "leave_channel": { + "php_method": "leaveChannel", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "leave_group": { + "php_method": "leaveGroup", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "logout": { + "php_method": "logout", + "params": [], + "php_params": [] + }, + "pin_message": { + "php_method": "pinMessage", + "params": [ + "chat_id", + "message_id", + "notify_pin" + ], + "php_params": [ + "chatId", + "messageId", + "notifyPin" + ] + }, + "read_message": { + "php_method": "readMessage", + "params": [ + "message_id", + "chat_id" + ], + "php_params": [ + "messageId", + "chatId" + ] + }, + "remove_2fa": { + "php_method": "removeTwoFactor", + "params": [ + "password" + ], + "php_params": [ + "password" + ] + }, + "remove_contact": { + "php_method": "removeContact", + "params": [ + "contact_id" + ], + "php_params": [ + "contactId" + ] + }, + "remove_reaction": { + "php_method": "removeReaction", + "params": [ + "chat_id", + "message_id" + ], + "php_params": [ + "chatId", + "messageId" + ] + }, + "remove_users_from_group": { + "php_method": "removeUsersFromGroup", + "params": [ + "chat_id", + "user_ids", + "clean_msg_period" + ], + "php_params": [ + "chatId", + "userIds", + "cleanMsgPeriod" + ] + }, + "request_profile_photo_upload_url": { + "php_method": "requestProfilePhotoUploadUrl", + "params": [], + "php_params": [] + }, + "resolve_group_by_link": { + "php_method": "resolveGroupByLink", + "params": [ + "link" + ], + "php_params": [ + "link" + ] + }, + "rework_invite_link": { + "php_method": "reworkInviteLink", + "params": [ + "chat_id" + ], + "php_params": [ + "chatId" + ] + }, + "search_by_phone": { + "php_method": "searchByPhone", + "params": [ + "phone" + ], + "php_params": [ + "phone" + ] + }, + "send_message": { + "php_method": "sendMessage", + "params": [ + "chat_id", + "text", + "reply_to", + "attachments", + "notify" + ], + "php_params": [ + "chatId", + "text", + "replyTo", + "attachments", + "notify" + ] + }, + "set_2fa": { + "php_method": "setTwoFactor", + "params": [ + "password", + "email", + "hint", + "email_code_provider" + ], + "php_params": [ + "password", + "email", + "hint", + "emailCodeProvider" + ] + }, + "update_folder": { + "php_method": "updateFolder", + "params": [ + "folder_id", + "title", + "chat_include", + "filters", + "options" + ], + "php_params": [ + "folderId", + "title", + "chatInclude", + "filters", + "options" + ] + } + }, + "domain_models": { + "AudioAttachment": { + "fields": [ + "duration", + "audio_id", + "wave", + "transcription_status", + "url", + "type", + "token" + ], + "payload_keys": { + "duration": "duration", + "audio_id": "audioId", + "wave": "wave", + "transcription_status": "transcriptionStatus", + "url": "url", + "type": "_type", + "token": "token" + } + }, + "CallAttachment": { + "fields": [ + "type", + "duration", + "conversation_id", + "contact_ids" + ], + "payload_keys": { + "type": "_type", + "duration": "duration", + "conversation_id": "conversationId", + "contact_ids": "contactIds" + } + }, + "Chat": { + "fields": [ + "id", + "type", + "status", + "owner", + "participants", + "title", + "base_raw_icon_url", + "base_icon_url", + "last_message", + "last_event_time", + "last_delayed_update_time", + "last_fire_delayed_error_time", + "created", + "new_messages", + "link", + "access", + "restrictions", + "pinned_message", + "participants_count", + "description", + "options", + "join_time", + "invited_by", + "modified", + "messages_count", + "has_bots", + "prev_message_id", + "admin_participants", + "admins", + "cid" + ], + "payload_keys": { + "id": "id", + "type": "type", + "status": "status", + "owner": "owner", + "participants": "participants", + "title": "title", + "base_raw_icon_url": "baseRawIconUrl", + "base_icon_url": "baseIconUrl", + "last_message": "lastMessage", + "last_event_time": "lastEventTime", + "last_delayed_update_time": "lastDelayedUpdateTime", + "last_fire_delayed_error_time": "lastFireDelayedErrorTime", + "created": "created", + "new_messages": "newMessages", + "link": "link", + "access": "access", + "restrictions": "restrictions", + "pinned_message": "pinnedMessage", + "participants_count": "participantsCount", + "description": "description", + "options": "options", + "join_time": "joinTime", + "invited_by": "invitedBy", + "modified": "modified", + "messages_count": "messagesCount", + "has_bots": "hasBots", + "prev_message_id": "prevMessageId", + "admin_participants": "adminParticipants", + "admins": "admins", + "cid": "cid" + } + }, + "CheckCodeResponse": { + "fields": [ + "token_attrs", + "password_challenge" + ], + "payload_keys": { + "token_attrs": "tokenAttrs", + "password_challenge": "passwordChallenge" + } + }, + "CheckPasswordResponse": { + "fields": [ + "token_attrs", + "error" + ], + "payload_keys": { + "token_attrs": "tokenAttrs", + "error": "error" + } + }, + "CheckQrResponse": { + "fields": [ + "status" + ], + "payload_keys": { + "status": "status" + } + }, + "ConfirmRegistrationResponse": { + "fields": [ + "user_token", + "profile", + "token_type", + "token" + ], + "payload_keys": { + "user_token": "userToken", + "profile": "profile", + "token_type": "tokenType", + "token": "token" + } + }, + "ContactAttachment": { + "fields": [ + "contact_id", + "first_name", + "last_name", + "name", + "photo_url", + "type" + ], + "payload_keys": { + "contact_id": "contactId", + "first_name": "firstName", + "last_name": "lastName", + "name": "name", + "photo_url": "photoUrl", + "type": "_type" + } + }, + "ContactInfo": { + "fields": [ + "phone", + "first_name", + "last_name" + ], + "payload_keys": { + "phone": "phone", + "first_name": "firstName", + "last_name": "lastName" + } + }, + "ControlAttachment": { + "fields": [ + "type", + "event" + ], + "payload_keys": { + "type": "_type", + "event": "event" + } + }, + "Element": { + "fields": [ + "type", + "from_", + "length", + "attributes" + ], + "payload_keys": { + "type": "type", + "from_": "from", + "length": "length", + "attributes": "attributes" + } + }, + "ElementAttributes": { + "fields": [ + "url" + ], + "payload_keys": { + "url": "url" + } + }, + "FileAttachment": { + "fields": [ + "file_id", + "name", + "size", + "token", + "type" + ], + "payload_keys": { + "file_id": "fileId", + "name": "name", + "size": "size", + "token": "token", + "type": "_type" + } + }, + "FileRequest": { + "fields": [ + "unsafe", + "url" + ], + "payload_keys": { + "unsafe": "unsafe", + "url": "url" + } + }, + "Folder": { + "fields": [ + "source_id", + "include", + "options", + "update_time", + "id", + "filters", + "title" + ], + "payload_keys": { + "source_id": "sourceId", + "include": "include", + "options": "options", + "update_time": "updateTime", + "id": "id", + "filters": "filters", + "title": "title" + } + }, + "FolderList": { + "fields": [ + "folders_order", + "folders", + "all_filter_exclude_folders", + "folder_sync" + ], + "payload_keys": { + "folders_order": "foldersOrder", + "folders": "folders", + "all_filter_exclude_folders": "allFilterExcludeFolders", + "folder_sync": "folderSync" + } + }, + "FolderUpdate": { + "fields": [ + "folders_order", + "folder", + "folder_sync" + ], + "payload_keys": { + "folders_order": "foldersOrder", + "folder": "folder", + "folder_sync": "folderSync" + } + }, + "InitData": { + "fields": [ + "query_id", + "url" + ], + "payload_keys": { + "query_id": "queryId", + "url": "url" + } + }, + "InlineKeyboardAttachment": { + "fields": [ + "type", + "keyboard" + ], + "payload_keys": { + "type": "_type", + "keyboard": "keyboard" + } + }, + "LoginConfig": { + "fields": [ + "hash" + ], + "payload_keys": { + "hash": "hash" + } + }, + "LoginResponse": { + "fields": [ + "chats", + "profile", + "messages", + "contacts", + "token", + "time", + "config" + ], + "payload_keys": { + "chats": "chats", + "profile": "profile", + "messages": "messages", + "contacts": "contacts", + "token": "token", + "time": "time", + "config": "config" + } + }, + "MaxApiError": { + "fields": [ + "error", + "message", + "title", + "localized_message" + ], + "payload_keys": { + "error": "error", + "message": "message", + "title": "title", + "localized_message": "localizedMessage" + } + }, + "Member": { + "fields": [ + "contact", + "presence" + ], + "payload_keys": { + "contact": "contact", + "presence": "presence" + } + }, + "Message": { + "fields": [ + "id", + "chat_id", + "sender", + "text", + "time", + "type", + "cid", + "attaches", + "stats", + "status", + "reaction_info", + "options", + "prev_message_id", + "ttl", + "unread", + "mark", + "elements" + ], + "payload_keys": { + "id": "id", + "chat_id": "chatId", + "sender": "sender", + "text": "text", + "time": "time", + "type": "type", + "cid": "cid", + "attaches": "attaches", + "stats": "stats", + "status": "status", + "reaction_info": "reactionInfo", + "options": "options", + "prev_message_id": "prevMessageId", + "ttl": "ttl", + "unread": "unread", + "mark": "mark", + "elements": "elements" + } + }, + "Name": { + "fields": [ + "name", + "first_name", + "last_name", + "type" + ], + "payload_keys": { + "name": "name", + "first_name": "firstName", + "last_name": "lastName", + "type": "type" + } + }, + "PasswordChallenge": { + "fields": [ + "track_id", + "hint" + ], + "payload_keys": { + "track_id": "trackId", + "hint": "hint" + } + }, + "PhotoAttachment": { + "fields": [ + "base_url", + "height", + "width", + "photo_id", + "photo_token", + "preview_data", + "type" + ], + "payload_keys": { + "base_url": "baseUrl", + "height": "height", + "width": "width", + "photo_id": "photoId", + "photo_token": "photoToken", + "preview_data": "previewData", + "type": "_type" + } + }, + "Presence": { + "fields": [ + "seen", + "status" + ], + "payload_keys": { + "seen": "seen", + "status": "status" + } + }, + "Profile": { + "fields": [ + "contact", + "profile_options" + ], + "payload_keys": { + "contact": "contact", + "profile_options": "profileOptions" + } + }, + "QrStatus": { + "fields": [ + "expires_at", + "login_available" + ], + "payload_keys": { + "expires_at": "expiresAt", + "login_available": "loginAvailable" + } + }, + "ReactionCounter": { + "fields": [ + "count", + "reaction" + ], + "payload_keys": { + "count": "count", + "reaction": "reaction" + } + }, + "ReactionInfo": { + "fields": [ + "total_count", + "counters", + "your_reaction" + ], + "payload_keys": { + "total_count": "totalCount", + "counters": "counters", + "your_reaction": "yourReaction" + } + }, + "ReadState": { + "fields": [ + "unread", + "mark" + ], + "payload_keys": { + "unread": "unread", + "mark": "mark" + } + }, + "RequestQrResponse": { + "fields": [ + "expires_at", + "polling_interval", + "qr_link", + "track_id", + "ttl" + ], + "payload_keys": { + "expires_at": "expiresAt", + "polling_interval": "pollingInterval", + "qr_link": "qrLink", + "track_id": "trackId", + "ttl": "ttl" + } + }, + "Session": { + "fields": [ + "id", + "device_id", + "current", + "user_agent", + "app_version", + "device_name", + "device_type", + "platform", + "ip", + "location", + "created", + "updated", + "last_activity", + "options" + ], + "payload_keys": { + "id": "id", + "device_id": "deviceId", + "current": "current", + "user_agent": "userAgent", + "app_version": "appVersion", + "device_name": "deviceName", + "device_type": "deviceType", + "platform": "platform", + "ip": "ip", + "location": "location", + "created": "created", + "updated": "updated", + "last_activity": "lastActivity", + "options": "options" + } + }, + "ShareAttachment": { + "fields": [ + "type", + "url", + "title", + "description", + "image" + ], + "payload_keys": { + "type": "_type", + "url": "url", + "title": "title", + "description": "description", + "image": "image" + } + }, + "StartAuthResponse": { + "fields": [ + "token", + "code_length", + "request_max_duration", + "request_count_left", + "alt_action_duration" + ], + "payload_keys": { + "token": "token", + "code_length": "codeLength", + "request_max_duration": "requestMaxDuration", + "request_count_left": "requestCountLeft", + "alt_action_duration": "altActionDuration" + } + }, + "StickerAttachment": { + "fields": [ + "author_type", + "lottie_url", + "url", + "sticker_id", + "tags", + "width", + "set_id", + "time", + "sticker_type", + "audio", + "height", + "type" + ], + "payload_keys": { + "author_type": "authorType", + "lottie_url": "lottieUrl", + "url": "url", + "sticker_id": "stickerId", + "tags": "tags", + "width": "width", + "set_id": "setId", + "time": "time", + "sticker_type": "stickerType", + "audio": "audio", + "height": "height", + "type": "_type" + } + }, + "SyncOverrides": { + "fields": [ + "chats_sync", + "contacts_sync", + "drafts_sync", + "presence_sync", + "config_hash" + ], + "payload_keys": { + "chats_sync": "chats_sync", + "contacts_sync": "contacts_sync", + "drafts_sync": "drafts_sync", + "presence_sync": "presence_sync", + "config_hash": "config_hash" + } + }, + "SyncState": { + "fields": [ + "chats_sync", + "contacts_sync", + "drafts_sync", + "presence_sync", + "config_hash" + ], + "payload_keys": { + "chats_sync": "chats_sync", + "contacts_sync": "contacts_sync", + "drafts_sync": "drafts_sync", + "presence_sync": "presence_sync", + "config_hash": "config_hash" + } + }, + "Token": { + "fields": [ + "token" + ], + "payload_keys": { + "token": "token" + } + }, + "TokenAttrs": { + "fields": [ + "login", + "register_token" + ], + "payload_keys": { + "login": "LOGIN", + "register_token": "REGISTER" + } + }, + "UnknownAttachment": { + "fields": [ + "type" + ], + "payload_keys": { + "type": "_type" + } + }, + "User": { + "fields": [ + "id", + "account_status", + "registration_time", + "country", + "base_raw_url", + "base_url", + "names", + "options", + "photo_id", + "update_time", + "phone", + "status", + "description", + "gender", + "link", + "web_app", + "menu_button" + ], + "payload_keys": { + "id": "id", + "account_status": "accountStatus", + "registration_time": "registrationTime", + "country": "country", + "base_raw_url": "baseRawUrl", + "base_url": "baseUrl", + "names": "names", + "options": "options", + "photo_id": "photoId", + "update_time": "updateTime", + "phone": "phone", + "status": "status", + "description": "description", + "gender": "gender", + "link": "link", + "web_app": "webApp", + "menu_button": "menuButton" + } + }, + "VideoAttachment": { + "fields": [ + "height", + "width", + "video_id", + "duration", + "preview_data", + "type", + "thumbnail", + "token", + "video_type" + ], + "payload_keys": { + "height": "height", + "width": "width", + "video_id": "videoId", + "duration": "duration", + "preview_data": "previewData", + "type": "_type", + "thumbnail": "thumbnail", + "token": "token", + "video_type": "videoType" + } + }, + "VideoRequest": { + "fields": [ + "external", + "cache", + "url" + ], + "payload_keys": { + "external": "EXTERNAL", + "cache": "cache", + "url": "url" + } + } + }, + "event_models": { + "FileUploadSignal": { + "fields": [ + "file_id" + ], + "payload_keys": { + "file_id": "fileId" + } + }, + "MessageDeleteEvent": { + "fields": [ + "message_ids", + "chat_id", + "chat", + "message", + "ttl" + ], + "payload_keys": { + "message_ids": "messageIds", + "chat_id": "chatId", + "chat": "chat", + "message": "message", + "ttl": "ttl" + } + }, + "MessageReadEvent": { + "fields": [ + "set_as_unread", + "chat_id", + "user_id", + "mark" + ], + "payload_keys": { + "set_as_unread": "setAsUnread", + "chat_id": "chatId", + "user_id": "userId", + "mark": "mark" + } + }, + "PresenceEvent": { + "fields": [ + "presence", + "user_id" + ], + "payload_keys": { + "presence": "presence", + "user_id": "userId" + } + }, + "ReactionUpdateEvent": { + "fields": [ + "message_id", + "chat_id", + "counters", + "total_count" + ], + "payload_keys": { + "message_id": "messageId", + "chat_id": "chatId", + "counters": "counters", + "total_count": "totalCount" + } + }, + "TypingEvent": { + "fields": [ + "chat_id", + "user_id" + ], + "payload_keys": { + "chat_id": "chatId", + "user_id": "userId" + } + }, + "VideoUploadSignal": { + "fields": [ + "video_id" + ], + "payload_keys": { + "video_id": "videoId" + } + } + } +} diff --git a/docs/phpmax/contracts.md b/docs/phpmax/contracts.md new file mode 100644 index 0000000..3aefa12 --- /dev/null +++ b/docs/phpmax/contracts.md @@ -0,0 +1,97 @@ +# PHPMax Contract Manifest + +`docs/phpmax/contracts.json` - машинно проверяемый manifest контрактов, +снятый с локального Python reference `src/pymax`. + +## Что покрывает manifest + +- `commands` - значения `Command` из `src/pymax/protocol/enums.py`. +- `opcodes` - значения `Opcode` из `src/pymax/protocol/enums.py`. +- `event_types` - значения `EventType` из `src/pymax/dispatch/enums.py`. +- `event_map` - opcodes, которые PyMax route-ит через dispatch resolver. +- `domain_enums` - значения PyMax domain enum-like classes: + `ChatType`, `AccessType`, `MessageStatus`, `AttachmentType`, + `TranscriptionStatus`. +- `api_enums` - значения PyMax service/API enums из `src/pymax/api/*/enums.py`, + включая auth/chat/message/session/user constants и payload key anchors. +- `payload_models` - поля payload classes из `src/pymax/api/*/payloads.py` + и карта Python field name -> serialized payload key после PyMax + `to_camel`/`Field(alias=...)`/`Field(serialization_alias=...)`. +- `domain_models` - domain/response/attachment model fields/serialized keys + для перенесенных моделей из `src/pymax/types/domain`, включая auth + responses, sync/session/folder state, text elements, attachments и download + response objects. +- `event_models` - typed event model fields/serialized keys для dispatcher + событий и upload processing signals. +- `service_methods` - публичные methods из `src/pymax/api/*/service.py` и + ожидаемые PHP method names для service surface parity. +- `client_methods` - публичные methods из PyMax `src/pymax/infra/*Mixin` и + ожидаемые PHP `Client` shortcut names. +- `service_method_params` - параметры публичных PyMax service methods и + ожидаемые PHP parameter names/order после snake_case -> camelCase. +- `client_method_params` - параметры публичных PyMax client mixin methods и + ожидаемые PHP `Client` parameter names/order. + +На текущей reference-версии `maxapi-python 2.3.1` manifest фиксирует: + +- 4 command values; +- 164 opcodes; +- 13 event types; +- 9 dispatch event map entries; +- 5 domain enum groups, 25 enum values; +- 16 API enum groups, 47 enum values; +- 71 payload model anchors with field and serialized payload key metadata. +- 46 domain/response/attachment model anchors with field and serialized + payload key metadata. +- 7 typed event model anchors with field and serialized payload key metadata. +- 8 API service domains, 77 public service method/name mappings and 77 public + service parameter mappings. +- 59 public client mixin method/name mappings and 59 public client parameter + mappings. + +## Как обновлять + +После обновления `src/pymax` или изменения PHP constants/event resolver/domain, +API constant classes, payload/domain schemas, service/client method names, +service/client method parameters или public client shortcuts: + +```bash +php tools/contract-manifest.php write +just contract-check +``` + +`just php-check` и `just pre-publish-check` запускают `contract-check` +автоматически. Если manifest устарел, gate должен падать до публикации. + +## Проверка contract parity + +`just contract-check` теперь не только сверяет JSON с Python reference, но и: + +- через reflection проверяет, что перенесённые PHP payload classes сериализуют + тот же набор top-level ключей, что PyMax; +- проверяет, что покрытые PHP domain/response/attachment models сериализуют + тот же набор top-level ключей, что PyMax; +- проверяет, что typed PHP event models сериализуют тот же набор top-level + ключей, что PyMax; +- проверяет, что публичные PyMax API service methods имеют PHP equivalents. +- проверяет, что публичные PyMax client mixin methods доступны как PHP + `Client` shortcuts. +- проверяет, что параметры public service methods совпадают с PyMax по + имени и порядку после PHP-идиоматичного camelCase. +- проверяет, что параметры public `Client` shortcuts совпадают с PyMax mixins + по имени и порядку после PHP-идиоматичного camelCase. + +Это ловит drift в aliases вроде `_type`, `mt_instanceid`, `from`, `LOGIN`, +chat settings option keys, domain keys вроде `firstName`/`lastName`, +storage-state keys вроде `chats_sync`/`config_hash` и появление новых upstream +event/service/client methods. Теперь gate также ловит signature drift вроде +`password_old` -> `passwordOld` и client wrapper names вроде +`from_time` -> `fromTime`. + +## Границы проверки + +Manifest не заменяет focused parity-тесты для вложенных payload значений, +defaults, type casting, signatures и service/client behavior. Он фиксирует +reference anchors и ловит drift в низкоуровневых enum/event/payload-key/service/ +client surface контрактах. Для protocol/model/payload/service/client изменений +нужны focused tests и fixtures в дополнение к `contract-check`. diff --git a/docs/phpmax/decision-log.md b/docs/phpmax/decision-log.md new file mode 100644 index 0000000..d7b5470 --- /dev/null +++ b/docs/phpmax/decision-log.md @@ -0,0 +1,1043 @@ +# PHPMax Decision Log + +## 2026-07-03 - Integration harness can verify phone auth and session reuse + +Решение: `tools/integration-check.php` теперь поддерживает два базовых +real-account входа: token mode через `PHPMAX_TOKEN` и интерактивный phone/SMS +mode через `PHPMAX_AUTH_SMS=1` + `PHPMAX_PHONE`. После успешного TCP login +harness проверяет, что local session сохранена с token/device/mtInstanceId и, +если задан телефон, находится по phone. Затем он открывает второй `Client` с +тем же `workDir/sessionName`, но без token и без SMS auth flow, чтобы доказать +session reuse без повторного запроса кода. + +Причина: для проверки “привязывается ли телефон, показывается ли session, +запрашиваются ли коды” нужен управляемый сценарий до внедрения в реальный +проект. Token-only smoke test не доказывает first-login UX и не проверяет, +что сохраненная session сама достаточна для следующего запуска. + +## 2026-07-03 - Root README documents PHPMax release usage + +Решение: заменить корневой `README.md` с Python/PyMax описания на PHPMax +quick start: требования PHP 7.4+, Composer/release ZIP установка, bounded +`Client::withOpenSession()` сценарий, token login, upload example, +`WebClient`, integration checks и ссылки на `docs/phpmax`. + +Причина: `README.md` входит в shared-hosting release ZIP и является публичным +входом для пользователя PHP-библиотеки. Если там остается Python-инструкция, +release artifact вводит в заблуждение и ломает developer experience, даже если +runtime-код и tests уже готовы. + +## 2026-07-03 - Direct proxy negative timeouts use a bounded fallback + +Решение: `ProxyConnector::connect()` при прямом неположительном timeout +использует `1.0` second вместо `0.001`. Положительные subsecond timeout values +сохраняются и clamp-ятся снизу до `0.001`, как раньше. Loopback HTTP CONNECT +fixture теперь задерживает ответ proxy и запускается с negative timeout, чтобы +проверить, что invalid direct input не превращается в race. + +Причина: proxy handshake состоит из connect, request write, response headers и, +для SOCKS5, нескольких round trips. Нормализация `-5.0` в 1 ms формально +защищала stream API от отрицательного значения, но на практике делала +boundary flaky даже на локальном loopback. `1.0` second остается bounded для +shared-hosting runtime и дает понятное поведение при прямом использовании +низкоуровневого adapter-а без `ClientOptions`. + +## 2026-07-03 - Extra fields are preserved only for domain models + +Решение: `PHPMax\Support\Model` сохраняет unknown fields только для +`PHPMax\Domain\*` subclasses. `PHPMax\Api\*` payload models и +`PHPMax\Session\*` storage models продолжают принимать входной массив через тот +же hydrator, но unknown keys отбрасываются и не попадают в `extra()`/`toArray()`. + +Причина: PyMax domain models наследуются от base model with `extra="allow"`, +а API/session models используют стандартное Pydantic поведение и игнорируют +лишние поля. Если PHP payload models сохраняют unknown keys, future или +malformed поля могут случайно уйти обратно в protocol request; для session +storage это еще и риск сохранить отладочные/чужие значения рядом с token state. + +## 2026-07-03 - Release preflight validates vendor policy without building ZIP + +Решение: добавить `tools/build-release.php --check` и `just release-check`. +`just pre-publish-check` теперь запускает release preflight после PHP gates: +manifest и vendor policy валидируются без создания `dist/phpmax-dev.zip`. +`--dry-run` остается JSON manifest mode, а `--check` является validation mode; +совместное использование этих режимов считается ошибкой конфигурации. + +Причина: `--dry-run` удобен для просмотра состава архива, но сам по себе не +является release gate. Если в будущем появятся runtime Composer packages без +собранного `vendor/`, публикация должна падать до release build, а не только +в момент создания ZIP. + +## 2026-07-03 - PHP 7.4 compatibility is checked without Composer + +Решение: добавить `tools/php74-compat-check.php` и запускать его внутри +`just php-check`. Checker сканирует `src/PHPMax`, `tests-php` и `tools`, +игнорирует строки/docblocks и fail-fast ловит PHP 8+ constructs: native enum, +union types, attributes, constructor property promotion, `match`, nullsafe +operator, PHP 8-only return/property/parameter types и helper calls вроде +`str_contains()`. + +Причина: локальный `php -l` проверяет синтаксис текущим установленным PHP. +Если разработчик работает на PHP 8.x, случайное использование PHP 8-синтаксиса +может пройти локально, но сломать shared-hosting PHP 7.4 runtime. Отдельный +lightweight checker сохраняет требование PHP 7.4+ даже без Composer/PHPStan. + +## 2026-07-03 - HTTP upload endpoints are restricted to HTTP(S) + +Решение: `NativeHttpUploader` принимает только absolute `http`/`https` URL с +host и валидным port `1..65535` для multipart и streaming uploads. `file://`, +`ftp://`, scheme-relative, relative endpoints и port `0` отклоняются как +`UploadException` до запуска cURL или PHP stream fallback. + +Причина: upload URL приходит из server payload и находится на security +boundary. cURL и PHP stream wrappers поддерживают больше схем, чем нужно +PHPMax, включая локальные файлы и не-HTTP transport. Fail-fast validation +сохраняет expected HTTP-only contract и не дает broken или malicious endpoint +уйти в сетевой/файловый слой. + +## 2026-07-03 - URL source diagnostics redact signed URL secrets + +Решение: `File`/`Photo`/`Video` URL-source errors больше не включают полный +URL. В диагностике остается только scheme, host, optional port и marker +`/`; query, fragment и userinfo не печатаются. + +Причина: upload/download source URLs часто бывают signed URLs с token в query +или credentials в userinfo. Ошибка чтения, HEAD-запроса или HTTP status не +должна превращать такие значения в logs, CLI output или integration failure +messages. + +## 2026-07-03 - Upload init responses are semantically validated + +Решение: `UploadService` отклоняет video/file init responses до HTTP upload, +если `url` пустой, token пустой или `fileId`/`videoId` не положительный. +Ошибка завершается как `UploadException`, waiter state не создается, HTTP +upload не стартует. + +Причина: schema validation гарантирует наличие полей, но не их смысловую +пригодность для streaming upload и ожидания `NOTIF_ATTACH`. Пустой URL, token +или id `0` означает broken server payload; продолжать HTTP upload в таком +состоянии опасно и усложняет диагностику real-account integration checks. + +## 2026-07-03 - Transport endpoints fail fast before socket work + +Решение: `ClientOptions::host`/`port`, `TcpTransport`, +`WebSocketTransport` parsed URL endpoint и `ProxyConnector` target endpoint +отклоняют empty host и port вне `1..65535` до socket connect, HTTP Upgrade, +HTTP CONNECT или SOCKS5 request. + +Причина: bounded shared-hosting runtime должен получать диагностичную ошибку +конфигурации до сетевой операции. Некорректные endpoints не должны превращаться +в неясные PHP stream warnings, зависания connect path или malformed proxy +handshake bytes. + +## 2026-07-03 - Runtime and transport timeouts are lower-bound normalized + +Решение: `ClientOptions::requestTimeout`, `connectTimeout`, +`uploadHttpTimeout` и `uploadProcessingTimeout` нормализуются до минимума +`0.001`, а `executionSafetyMargin` - до минимума `0.0`. Отрицательные значения +больше не проходят в `App::invoke()`, TCP/WebSocket connect path, upload HTTP +path или расчет execution budget. Direct usage `TcpTransport`, +`WebSocketTransport`, `ProxyConnector` и `NativeHttpUploader` нормализует +timeout на своем boundary, если эти классы используются без `ClientOptions`. + +Причина: PHP stream APIs и bounded runtime должны получать предсказуемые +положительные timeouts. `runFor()` уже защищал часть read-timeout paths, но +прямые service calls и connect logic использовали значения из options +напрямую. Дополнительная нормализация на transport/upload boundary защищает +низкоуровневые классы от прямого использования и делает shared-hosting +поведение стабильным для всех public lifecycle сценариев. + +## 2026-07-03 - Built-in session stores reject path-like file names + +Решение: `JsonFileSessionStore` и `SQLiteSessionStore` принимают только plain +file names для session storage. Empty name, `.`/`..`, `/`, `\` и null byte +отклоняются до создания рабочей директории и до открытия SQLite/JSON файла. + +Причина: `ClientOptions::workDir` должен быть единственной границей, внутри +которой built-in stores создают local session files. Path-like `sessionName` +может вывести token/sync storage за пределы ожидаемой директории или случайно +попасть в webroot, что противоречит shared-hosting security model. + +## 2026-07-03 - WebSocket runtime accepts only JSON text messages + +Решение: `WebSocketTransport` отклоняет binary data frames и валидирует +собранные text messages как UTF-8. Max WebSocket runtime в PHPMax использует +`WsProtocol`, который кодирует и декодирует JSON text frames, поэтому binary +server message или invalid UTF-8 считаются protocol drift и завершаются +`ProtocolException`. + +Причина: silent acceptance of binary frames would send arbitrary bytes into the +JSON protocol decoder and hide transport/server contract drift. UTF-8 +проверяется только после полной reassembly, чтобы корректно принимать multibyte +символы, разделенные между continuation frames. Fail-fast на transport boundary +делает WebSocket layer предсказуемее для shared-hosting runtime и +parity-тестов. + +## 2026-07-03 - Startup failures close runtime resources + +Решение: `Client::open()` закрывает `App` runtime, если auth startup или +`onStart` завершается необработанным исключением. Это закрывает transport и +configured session store перед тем, как ошибка вернется пользователю. + +Причина: shared-hosting/cron сценарии не должны оставлять открытые TCP +connections, SQLite/file handles или lock-related state после неудачного +старта. Успешно обработанные `onStart` ошибки по-прежнему остаются внутри +router error handling, а необработанные ошибки пробрасываются после cleanup. + +## 2026-07-03 - Session stores support full local cleanup + +Решение: `SessionStoreInterface` получил `deleteAllSessions()`, а JSON и SQLite +backend-и реализуют полную очистку локальных PHPMax sessions. Метод повторяет +PyMax `SessionStore.delete_all_sessions()` как store-level операцию и не +подменяет серверный `Client::closeAllSessions()`. + +Причина: shared-hosting пользователю нужен безопасный способ очистить локальное +хранилище без ручного удаления файлов/SQLite rows. Это также закрывает parity +gap с Python store и сохраняет одинаковое поведение JSON/SQLite backend-ов. + +## 2026-07-03 - Integration preflight is secret-free + +Решение: `tools/integration-check.php --plan` и `just integration-plan` +показывают список real-account проверок и required env без сетевых запросов, +без требования `PHPMAX_TOKEN` и без вывода значений token/proxy. + +Причина: реальные Milestone 6/7 проверки требуют аккаунта, внешней сети и +секретов, поэтому перед запуском нужен безопасный preflight. Он помогает +подготовить env для upload/download/WebSocket/proxy/telemetry сценариев, но не +создает риска случайно залогировать токен или proxy credentials. + +## 2026-07-03 - Proxy handshakes are covered by loopback tunnels + +Решение: `ProxyConnector` покрыт loopback-тестами для HTTP CONNECT, SOCKS5 +без авторизации и SOCKS5 username/password. Тесты проверяют handshake bytes, +`Proxy-Authorization` header, target host/port и то, что после handshake tunnel +реально пропускает данные. + +Причина: proxy слой находится на transport/security boundary. URL parsing и +constructor validation недостаточны: ошибка в CONNECT/SOCKS5 framing проявится +только при реальном подключении. Loopback proxy сохраняет deterministic CI gate +без внешнего proxy и без логирования credentials. + +## 2026-07-03 - WebSocket transport fails fast on invalid server frames + +Решение: `WebSocketTransport` строго валидирует HTTP Upgrade response и +серверные frames. Handshake требует настоящий HTTP `101`, `Upgrade: +websocket`, `Connection: Upgrade` token и корректный `Sec-WebSocket-Accept`. +Серверные frames не могут быть masked, не могут использовать RSV bits без +extensions, control frames не могут быть fragmented или длиннее 125 bytes, а +новый data frame запрещен до завершения текущей fragmentation chain. +Дополнительно введен bounded лимит payload frame-а, чтобы transport не пытался +читать произвольно большой WebSocket message в память. + +Причина: WebSocket является optional, но security-sensitive transport boundary. +Мягкое принятие protocol-invalid frames скрывает drift, может нарушить сборку +JSON-сообщений и опасно для shared-hosting runtime, где memory/time budget +должны быть ограничены. + +## 2026-07-03 - Saved session mtInstanceId wins during handshake + +Решение: если `SessionStoreInterface::loadSession()` возвращает session с +непустым `mtInstanceId`, `Client` синхронизирует этот id в runtime options и +использует его в `SESSION_INIT`. Новый `ClientOptions::mtInstanceId` +используется только для новой session или для старой session без сохраненного +`mtInstanceId`. + +Причина: PyMax при старте переиспользует `session_data.mt_instance_id`, если он +есть, и только иначе оставляет текущий config id. Это часть device/session +continuity: случайная замена `mt_instance_id` при каждом запуске с сохраненным +token может ломать login fingerprint и sync continuity. + +## 2026-07-03 - Real integration checks stay opt-in + +Решение: реальные проверки MAX не входят в `just php-check` и CI. Для них +добавлен отдельный `just integration-check`, который без +`PHPMAX_INTEGRATION=1` безопасно пропускается. При наличии `PHPMAX_TOKEN` он +сначала выполняет preflight без сетевых запросов: валидирует session names, +numeric env parsing/bounds, upload paths, writable workdir, +`Client`/`WebClient` construction и proxy config, не печатая token/proxy +credentials. После preflight harness +проверяет TCP login/profile-state, а дополнительные сценарии включаются +точечными env-параметрами: chat/session reads, bot init data, telemetry login/ +navigation, photo/file/video uploads, file/video temporary URL checks, +WebSocket login и proxy path. + +Причина: real-account сценарии требуют секретов, могут менять session token, +используют внешнюю сеть и не должны ломать deterministic local/CI gate. +Harness при этом нужен, чтобы закрывать незавершенные Milestone 6/7 проверки +одинаковой воспроизводимой процедурой, не логируя токены и proxy credentials. +Preflight нужен, чтобы malformed env не запускал real network path и не +превращался в частичный integration run. + +## 2026-07-03 - Hydrator validates explicit empty and malformed collections + +Решение: PHPMax `Model` validates required fields even when input is an +explicit empty array. Explicit malformed `array`, `list<...>` and +`map-list<...>` values throw `ValidationException` instead of being converted +to empty collections. `LoginResponse::contacts` keeps PyMax `list[User | None]` +semantics: `null` items are allowed, scalar items are not. Custom factories for +`Message::attaches` and `MessageDeleteEvent::messageIds` follow the same rule: +explicit malformed attachment items or delete ids fail instead of being kept or +turned into an empty list. List fields also require PHP list-like arrays with +`0..n` integer keys; associative arrays are treated as maps and rejected where +PyMax expects a list, including upload `info` payloads and primitive request +lists such as message ids, user ids, folder ids/include values and filters. +Primitive `list`, `list`, `list`, `list` and +`list` schemas validate every item through the same hydrator path. +Scalar casts are guarded as well: integer-compatible numeric strings/floats +and common boolean strings remain accepted, while malformed scalar payloads +fail instead of using PHP's permissive `(int)`, `(string)` or `(bool)` casts. +`Profile::profileOptions` remains a flexible array/map compatibility exception +because current profile fixtures can arrive as option maps. + +Причина: PyMax/Pydantic applies defaults to absent optional fields, but still +fails explicit malformed payload. The previous PHP hydrator blurred this +difference and PHP arrays blur list-vs-map shape unless checked explicitly. +Without this guard, protocol objects could be accepted as lists in login state, +profile options, upload init responses, message lists and event/domain models; +scalar drift like `"abc" -> 0` would be even harder to detect after hydration. + +## 2026-07-03 - Auth response models reject empty payloads + +Решение: `AuthService` treats `null` and empty response payloads as missing for +all methods that mirror PyMax `require_payload_model`. Empty `AUTH_REQUEST`, +`AUTH`, password, QR, registration and `LOGIN` responses must fail before model +hydration; failed `LOGIN` must not update session state or session store. + +Причина: PHP `Model::fromArray([])` applies defaults, while PyMax +`require_payload_model()` rejects falsey payload before Pydantic validation. +Without the guard, auth and login flows could accept an invalid server response +as a default model and then perform session side effects. + +## 2026-07-03 - Chat update event mapping is strict for truthy payloads + +Решение: known events with falsey payloads keep PyMax raw-frame fallback, но +`CHAT_UPDATE` с непустым payload обязан содержать non-empty nested `chat` +object. Wrapperless chat-like payload, scalar `chat` и пустой nested `chat` +падают до handler dispatch и до raw fallback. + +Причина: PyMax `EventMapper` вызывает `Chat.model_validate(frame.payload["chat"])`. +PHPMax ранее принимал wrapperless payload как `Chat` или мог вернуть raw frame +для malformed scalar, что скрывало protocol drift в известном событии. + +## 2026-07-03 - Reaction responses keep optional falsey semantics + +Решение: empty optional `reactionInfo` возвращает `null`, malformed truthy +`reactionInfo` падает, а `messagesReactions` должен быть map по message id с +валидными reaction payload values. + +Причина: PyMax проверяет `if reaction_info` перед `ReactionInfo.model_validate` +для add/remove, а `get_reactions()` ожидает dict и валидирует каждое значение. +PHPMax не должен превращать `{reactionInfo: {}}` в default `ReactionInfo` или +молча игнорировать scalar/map drift. + +## 2026-07-03 - User and message list parsing fails fast on malformed items + +Решение: `UserService` и `MessageService` сохраняют empty/missing list behavior, +но если response list присутствует и содержит malformed item, PHPMax +выбрасывает ошибку вместо тихого пропуска элемента. + +Причина: PyMax `parse_payload_list()` валидирует каждый item через Pydantic. +Тихий skip в PHP давал бы частичный результат для `contacts`, `sessions` или +`messages`, скрывая protocol/model drift от caller-а и от cache layer. + +## 2026-07-03 - Chat list parsing fails fast on malformed items + +Решение: `ChatService` сохраняет nullable behavior для отсутствующего +`chats` list, но если list присутствует и содержит malformed item, PHPMax +выбрасывает ошибку вместо тихого пропуска элемента. + +Причина: PyMax `parse_payload_list()` передает каждый item в Pydantic model +validation. Некорректный элемент списка не должен исчезать незаметно, иначе +cache и caller увидят частичный результат без сигнала о protocol drift. + +## 2026-07-03 - Optional empty chat item is missing, not an empty chat + +Решение: optional chat response paths используют PyMax-like behavior: +`{chat: {}}` считается отсутствующим `chat` item и возвращает `null` или no-op. +Такой payload не создает `Chat` с пустыми/default полями и не перезаписывает +существующий cached chat. + +Причина: PyMax `parse_payload_item_model()` проверяет truthiness item-а перед +model validation. Empty dict в optional path превращается в `None`, а не в +domain object. PHPMax должен сохранять эту разницу между optional parse path и +strict `require_payload_item_model` path. + +## 2026-07-03 - Contact update validates response before cache side effects + +Решение: `UserService::addContact()` и `removeContact()` проверяют, что +`CONTACT_UPDATE` вернул dict-like payload, до чтения `contact` и до удаления +пользователя из cache. Empty payload остается допустимым для remove, потому что +PHP decode не различает пустой MessagePack map и list. + +Причина: PyMax `_contact_action()` вызывает `require_payload_dict()` для add и +remove. Без такого guard-а PHPMax мог удалить пользователя из локального cache +даже если сервер вернул protocol-invalid list-shaped response. + +## 2026-07-03 - Photo MIME keeps PyMax source-specific behavior + +Решение: для `Photo` из `raw` или `path` PHPMax возвращает MIME как +`image/`, как PyMax `validate_photo()`. Для URL-источника PHPMax +использует MIME map/guess behavior, поэтому `.jpg` остается `image/jpeg`. + +Причина: это значение попадает в multipart `Content-Type` при `PHOTO_UPLOAD`. +Нормализация `.jpg` в `image/jpeg` для всех источников выглядит аккуратнее, но +ломает byte-level/developer parity с PyMax для raw/path upload fixtures. + +## 2026-07-03 - Upload waiters track only active ids + +Решение: file/video upload waiters хранят ready state только для ids, которые +прямо ожидаются текущим upload-вызовом. `NOTIF_ATTACH` с чужим id проходит +через dispatcher, но не остается в `UploadService`; active expected/ready state +очищается через `finally` после успеха и после HTTP upload failure. + +Причина: PyMax хранит futures по активным ids и извлекает их через `pop`. +Если PHPMax будет накапливать все пришедшие attach-события, короткие +shared-hosting запуски могут держать устаревшее состояние между upload +операциями и случайно завершать не тот waiter. + +## 2026-07-03 - Required response models fail fast on empty payload + +Решение: service methods, которые в PyMax используют `require_payload_model`, +не должны молча гидрировать default PHP model из пустого response payload. +Пустой payload считается ошибкой service boundary до model hydration. Methods, +которые в PyMax используют `parse_payload_model`, сохраняют nullable behavior. + +Причина: многие domain models имеют PyMax defaults. Если PHP вызывает +`Model::fromArray([])` без проверки payload, ошибка сервера превращается в +валидный объект с пустыми/default полями. Это уже было возможно для folder +responses, `sendMessage()`/`forwardMessage()`/`editMessage()`/ +`readMessage()` state и могло скрыть некорректный `WEB_APP_INIT_DATA`, +upload-init или HTTP upload ответ. + +## 2026-07-03 - Payload keys are part of the contract manifest + +Решение: `docs/phpmax/contracts.json` хранит не только Python field names для +payload models, но и serialized payload keys после PyMax alias rules. +`just contract-check` через reflection сверяет эти keys с PHP payload schemas. + +Причина: protocol drift часто происходит не в имени PHP/Python property, а в +ключе на wire-уровне: `_type`, `mt_instanceid`, `from`, chat option aliases. +Эти расхождения должны падать до публикации так же, как opcode или enum drift. +Глубокие defaults/value fixtures остаются отдельными тестами, потому что +manifest проверяет только top-level key contract. + +## 2026-07-03 - Public service methods are contract anchors + +Решение: `docs/phpmax/contracts.json` хранит публичные methods из +`src/pymax/api/*/service.py` и ожидаемые PHP method names. `contract-check` +разрешает PHP-only helper methods, но падает, если PyMax public service method +не имеет PHP equivalent. + +Причина: пользовательский developer experience ломается не только от opcode или +payload drift, но и от тихо появившегося upstream API action. Surface check +дает ранний сигнал после upstream sync. Для идиоматичных PHP имен используются +явные mappings, например `check_2fa -> checkTwoFactor`. + +## 2026-07-03 - Client mixin shortcuts are checked separately + +Решение: manifest хранит публичные PyMax methods из `src/pymax/infra/*Mixin` +и проверяет, что они доступны как shortcuts на `PHPMax\Client`. + +Причина: service parity не гарантирует developer experience parity. Метод может +быть реализован внутри service, но отсутствовать на публичном `Client`, что +ломает привычный PyMax-style API. Отдельный client surface check ловит такие +расхождения без запрета на PHP-only helpers вроде telemetry shortcuts. + +## 2026-07-03 - Core domain models enter contract manifest + +Решение: `contracts.json` фиксирует top-level field/key anchors для core +domain models `Chat`, `Message`, `User`, `Profile`, `LoginResponse`, `Name` и +связанных small value objects. `contract-check` сверяет эти anchors с PHP +schemas через reflection. + +Причина: service/payload parity недостаточен, если response model теряет поле +после hydration. Первый domain manifest pass сразу выявил drift в `Name`: +PyMax поддерживает `first_name`/`last_name`, а PHP модель их не хранила. +Проверка остаётся top-level; вложенные значения и behavior подтверждаются +focused fixtures. + +## 2026-07-03 - Typed event models enter contract manifest + +Решение: manifest фиксирует top-level field/key anchors для PyMax typed events: +message delete/read, typing, presence, reaction update и file/video upload +signals. PHP event schemas сверяются через reflection. + +Причина: event payload drift ломает dispatcher, пользовательские handlers и +bounded upload wait. Event map уже проверял opcode -> resolver mapping, но не +shape typed event models. Новый слой закрывает этот gap, оставляя behavior +проверку за focused dispatch fixtures. + +## 2026-07-03 - User-agent generation mirrors PyMax config anchors + +Решение: держать app versions, Android device profiles, locale/timezone anchors +и web header user-agent в `MobileUserAgentPayload`, а `ClientOptions` и +`WebClient` получать готовый payload через existing default methods. + +Причина: user-agent участвует в handshake/login fingerprint. Статический +payload повышает риск drift с PyMax и хуже отражает реальное поведение +`ExtraConfig.generate_user_agent()`/`generate_web_user_agent()`. Размещение +генератора в payload-слое сохраняет `ClientOptions` простым и не смешивает +конфигурацию с transport/auth logic. + +## 2026-07-03 - Internal dispatch is an App-level typed hook + +Решение: перенести PyMax `internal_router` как internal typed listeners в +`App`, вызываемые `ConnectionManager` перед пользовательским event handler. +Internal dispatch не получает raw fallback; raw остается частью внешнего +`Router`. + +Причина: PyMax использует internal handlers для service-level runtime hooks, +например upload processing notifications. В PHPMax это нужно для bounded +upload wait: `NOTIF_ATTACH`, пришедший во время HTTP upload до начала blocking +wait, должен быть запомнен и не потерян. + +## 2026-07-03 - Staged parity + +Решение: проектировать полный parity с PyMax, но реализовывать этапами. + +Причина: пакет большой, включает protocol, auth, events, uploads и domain +models. Один большой перенос повышает риск ошибок и затрудняет проверку. + +## 2026-07-03 - Shared hosting first + +Решение: основной runtime первой версии должен поддерживать cron/short CLI и +bounded execution. + +Причина: целевое окружение - shared hosting с ограничениями `max_execution_time`. +Постоянный daemon может быть optional, но не базовым требованием. + +## 2026-07-03 - Keep Python reference in repository + +Решение: не удалять `src/pymax` на старте PHP-порта. + +Причина: нужно регулярно подтягивать upstream PyMax, сравнивать изменения и +принимать решения о переносе в PHPMax. + +## 2026-07-03 - TCP first, WebSocket later + +Решение: сначала переносить TCP `Client`, затем optional `WebClient`. + +Причина: TCP core нужен для основного сценария, а WebSocket/QR добавляет +зависимости и long-running ограничения. + +## 2026-07-03 - Schema-driven models + +Решение: заменить Pydantic общим PHP hydrator/serializer, а не ручным парсингом +в каждом service. + +Причина: PyMax содержит много моделей, aliases, вложенных списков, attachment +discriminators и extra fields. Ручной парсинг быстро станет ненадежным. + +## 2026-07-03 - Hydrator accepts PyMax field names + +Решение: PHP `Model` принимает на вход и protocol/PHP camelCase keys, и +PyMax/Pydantic snake_case field names. Сериализация остается canonical: +`toArray()` возвращает protocol keys из schema/payload. Special-case wrappers, +например `Message::{message: ...}`, не должны добавлять отсутствующие outer +fields как `null`, чтобы не затирать вложенные значения. + +Причина: PyMax `CamelModel` использует aliases и `populate_by_name=True`, из-за +чего реальные fixtures могут приходить как `photoToken`, `photo_token` или +field-name variants. Если поддерживать это точечно в services, parity будет +дрейфовать. Общий hydrator сохраняет единое поведение для domain models, +payloads и attachment discriminator. + +## 2026-07-03 - Optional fields preserve PyMax nullability + +Решение: optional поля, у которых в PyMax default равен `None`, в PHP-моделях +остаются `null`, если поле отсутствует во входном payload. Пустые коллекции +используются только там, где PyMax задает `default_factory=list/dict` или где +это явно нужно для runtime state. + +Причина: искусственная замена `null` на `[]` меняет смысл ответа API и +сериализацию модели. Для `Profile::profileOptions` это особенно важно: +отсутствующие флаги профиля и явно полученный пустой список должны оставаться +различимыми, а проверка 2FA уже умеет безопасно обрабатывать оба случая. + +## 2026-07-03 - Attachment models mirror known PyMax types + +Решение: каждый известный PyMax attachment `_type` получает concrete PHP model +с явными fields из reference. Attachment discriminator принимает и `_type`, и +`type`. `UnknownAttachment` остается только для будущих или неизвестных типов +и отклоняет известный тип при прямой hydration. + +Причина: extra fields полезны для forward compatibility, но они не заменяют +domain parity. Разработчику нужны typed свойства для audio/contact/sticker/ +share/call/control/inline keyboard так же, как для photo/video/file. Иначе +часть входящих сообщений формально парсится, но теряет developer experience и +явные payload contracts. + +## 2026-07-03 - just as task runner + +Решение: использовать `just` как стандартную точку входа для проверок и +pre-publish guard. + +Причина: проект будет включать PHP, Python reference, parity fixtures и +документационные проверки. Единый `Justfile` снижает когнитивную нагрузку и +делает workflow одинаковым для агента и разработчика. + +## 2026-07-03 - Documentation is maintained with code + +Решение: документация обновляется автоматически вместе с кодом. Перед +публикацией всегда проверяется, требует ли изменение исходников обновления docs. + +Причина: PHPMax является портом с долгой синхронизацией upstream PyMax. Если +документация отстанет, будущие переносы protocol/auth/session изменений станут +рискованными. + +## 2026-07-03 - Lightweight PHP test runner before Composer install + +Решение: добавить `tools/run-php-tests.php` и подключить его к `just php-check`. + +Причина: в текущем окружении Composer отсутствует, но реализация должна +проверяться уже сейчас. Runner не отменяет PHPUnit, а дает ранний локальный gate +для protocol/model/runtime foundation. + +## 2026-07-03 - Message attachments accept prepared payloads first + +Решение: на этапе message service принимать attachments как уже подготовленные +payload arrays или `Model` instances, а полноценные file/photo/video upload +helpers перенести отдельным Milestone 6. + +Причина: отправка сообщений и upload pipeline имеют разные риски. Message +payload parity можно проверить сейчас через fake transport, а upload требует +HTTP streaming, multipart, ожидания processing notifications и отдельных +bounded event-loop тестов. + +## 2026-07-03 - Markdown formatter owns UTF-16 protocol offsets + +Решение: держать расчет markdown elements в отдельном `Formatter`, который +возвращает очищенный текст и protocol elements с UTF-16 offsets. + +Причина: MAX protocol считает positions в UTF-16 code units. Если считать их +обычной byte/string длиной PHP, emoji и символы за пределами BMP ломают offsets +и форматирование сообщений. + +## 2026-07-03 - Typed dispatch before error scopes + +Решение: сначала перенести `EventResolver`, `EventMapper`, typed event models и +typed `Router` helpers, а error/disconnect scopes вынести в следующий scoped +шаг. + +Причина: resolver/mapper определяет protocol parity для входящих событий и +нужен upload/chat/message runtime. Error scopes меняют политику исполнения +handlers и должны проверяться отдельными тестами, чтобы не смешивать mapping +ошибки с callback error handling. + +## 2026-07-03 - Chat cache is service-local for now + +Решение: на первом этапе chat service держит cache внутри `ChatService` +instance, без отдельного глобального client state и без domain-bound methods. + +Причина: PyMax cache behavior нужен для `get_chats`, но полноценный binding +`Chat::answer()`, `Chat::history()` и shared client state затрагивают messages, +chats и users одновременно. Service-local cache дает полезное поведение сейчас +и оставляет место для отдельного binding слоя. + +## 2026-07-03 - Error scopes are synchronous router behavior + +Решение: перенести PyMax `global`/`local` error scopes в синхронный PHP +`Router`: handler/filter/start ошибки проходят через `ErrorContext`, а наличие +хотя бы одного подходящего error handler считается обработкой ошибки. + +Причина: PHPMax runtime блокирующий и bounded, поэтому async task policy PyMax +не переносится буквально. Но семантика области видимости ошибок важна для +совместимости developer experience и для безопасной обработки user callbacks. + +## 2026-07-03 - Disconnect callbacks do not swallow network failures + +Решение: `onDisconnect()` callbacks вызываются при нетаймаутных +protocol/network ошибках `runFor()`, ошибки внутри disconnect callbacks +подавляются, но исходная network/protocol ошибка пробрасывается дальше. + +Причина: для shared hosting важно явно завершать короткий запуск и не создавать +скрытый reconnect-loop. Callback дает место для логирования/метрик, а решение о +повторном запуске остается внешнему cron/process supervisor слою. + +## 2026-07-03 - Domain helpers delegate through binding + +Решение: `Message` и `Chat` получают domain-bound helpers только после явной +привязки через `PHPMax\Api\Binding`; сами модели не знают transport/protocol и +делегируют действия в `MessageService`/`ChatService`. + +Причина: PyMax developer experience строится вокруг `message.answer()` и +`chat.history()`, но сетевой код нельзя смешивать с моделями. Binding сохраняет +слоистую архитектуру и дает понятную ошибку, если разработчик пытается вызвать +helper на непривязанной модели. + +## 2026-07-03 - Upload-backed helper attachments remain prepared payloads + +Решение: на этом этапе domain helpers для `answer()`/`reply()`/`edit()` +принимали тот же временный формат attachments, что и `MessageService`: +подготовленные payload arrays или `Model` instances. + +Причина: полноценные `Photo`/`File`/`Video` helpers зависят от Milestone 6: +HTTP upload, streaming, processing notifications и bounded event-loop ожидания. +До переноса upload pipeline нельзя обещать file-object attachments. + +Статус: временное ограничение снято началом Milestone 6. `MessageService` +теперь принимает `Photo`/`File`/`Video`, а bound helpers используют этот же +путь через service delegation. + +## 2026-07-03 - User cache is service-local before shared App state + +Решение: `UserService` держит cache пользователей внутри service instance и +выдает bound `User` models; account profile update явно кладет `profile.contact` +в этот cache. + +Причина: PyMax хранит пользователей в `app.users`, но PHP runtime пока не +переносит весь stateful App surface. Service-local cache покрывает `getUsers` +cache-miss behavior и не мешает позже добавить общий state, если он понадобится +для sync/login parity. + +## 2026-07-03 - Account profile photo object waits for UploadService + +Решение: на этом этапе `AccountService::changeProfile()` принимал готовый +`photoToken`, но не принимал объект фото до переноса `UploadService`. + +Причина: PyMax умеет загрузить `Photo` внутри `change_profile`, но это зависит +от upload pipeline Milestone 6. Поддержка `photoToken` уже переносит protocol +payload `PROFILE`, а file upload будет добавлен вместе с multipart/streaming +tests. + +Статус: временное ограничение снято началом Milestone 6. +`AccountService::changeProfile()` теперь принимает `Photo` object и загружает +его через `UploadService` с `profile=true`. + +## 2026-07-03 - UploadService owns HTTP upload boundary + +Решение: `Photo`/`File`/`Video` являются file value objects, а весь TCP/HTTP +upload orchestration живет в `PHPMax\Api\Uploads\UploadService`. HTTP отправка +изолирована за `HttpUploaderInterface`. + +Причина: upload pipeline пересекает TCP opcodes, HTTP multipart/streaming и +event processing. Если смешать это с `MessageService` или domain models, +появится хрупкая зависимость между сетевым кодом, serialization и public +helpers. Отдельный service позволяет подменять HTTP слой в тестах и позже +добавить более строгий streaming adapter без изменения message/account API. + +## 2026-07-03 - File/video uploads wait for NOTIF_ATTACH with a deadline + +Решение: после HTTP upload для file/video PHPMax ждет matching `NOTIF_ATTACH` +только до `ClientOptions::uploadProcessingTimeout`. Frames, которые не относятся +к текущему upload, передаются в обычный event handler через `ConnectionManager`. + +Причина: PyMax ждет processing notification через async future, но PHPMax +ориентирован на bounded CLI/cron runs. Таймаут предотвращает зависание процесса, +а forwarding нерелевантных событий сохраняет поведение dispatcher во время +upload wait. + +## 2026-07-03 - String defaults are data, not callbacks + +Решение: `Model` вызывает default только если это callable object/closure, а не +любую строку, для которой `is_callable()` вернул true. + +Причина: PHP считает строки вроде `FILE` callable из-за одноименной функции +`file()`. Для enum-like constants и `_type` defaults это приводило бы к +скрытым runtime-вызовам вместо сохранения protocol value. + +## 2026-07-03 - Download helpers return temporary URL models + +Решение: `MessageService::getFileById()` и `getVideoById()` переносят PyMax +behavior: через TCP запрашивают temporary URL и возвращают `FileRequest` или +`VideoRequest`. Они не скачивают файл и не открывают HTTP stream сами. + +Причина: в PyMax эти методы являются protocol helpers для `FILE_DOWNLOAD` и +`VIDEO_PLAY`, а не download manager. Разделение сохраняет слоистость: +message service отвечает за payload parity, а реальную загрузку/проигрывание +потребитель делает отдельно по временной ссылке. Для `VideoRequest` сохранена +нормализация dynamic URL key в поле `url`. + +## 2026-07-03 - Heartbeat ping is bounded by runFor + +Решение: PyMax `_ping_loop` перенесен не как отдельный background loop, а как +deadline внутри `Client::runFor()`. PHPMax отправляет `Opcode::PING` с +`interactive=true` по `ClientOptions::pingInterval`; `0.0` отключает heartbeat. + +Причина: PHPMax ориентирован на shared hosting и короткие CLI/cron запуски. +Отдельный бесконечный ping loop нарушал бы bounded runtime, а deadline внутри +`runFor()` сохраняет protocol parity без daemon-поведения. + +## 2026-07-03 - File/video HTTP upload streams directly from chunks + +Решение: `NativeHttpUploader::uploadStream()` больше не собирает весь +file/video upload body во временный `php://temp` stream. При наличии `ext-curl` +он передает cURL `CURLOPT_READFUNCTION`, который читает данные из `StreamBody` +поверх chunk iterator. Реальный HTTP path использует cURL upload mode с +`CURLOPT_CUSTOMREQUEST=POST`, потому что PHP cURL не предоставляет +`CURLOPT_POSTFIELDSIZE`; body streaming закреплен loopback-тестом, который +проверяет метод, `Content-Length` и полученные bytes. +Photo multipart path также закреплен loopback-тестом: проверяются cURL POST, +динамический boundary, `Content-Length`, тело файла и sanitizing field/filename +headers без CRLF injection. + +Причина: целевое окружение включает shared hosting и большие вложения. Даже +`php://temp` может уходить в диск или память и скрывать фактическую стоимость +upload. `StreamBody` держит только небольшой buffer текущего чтения, проверяет +`Content-Length` и fail-fast сигнализирует, если источник отдал меньше или +больше байт, чем было заявлено. + +## 2026-07-03 - URL file sources reject non-2xx HTTP responses + +Решение: `BaseFile` проверяет HTTP status для URL-источников в `read()`, +`size()` и `iterChunks()`. `read()` и `iterChunks()` используют GET, `size()` +использует HEAD; non-2xx status приводит к `UploadException` даже если сервер +вернул body или `Content-Length`. + +Причина: PyMax вызывает `resp.raise_for_status()` при чтении URL и при +chunk-iteration. PHP stream wrappers и `get_headers()` могут вернуть headers +404/500 с `Content-Length`, поэтому без явной проверки `size()` принимал +ошибочную страницу как размер файла и мог запустить некорректный upload. + +## 2026-07-03 - URL file sources are HTTP(S)-only + +Решение: `File::fromUrl()`, `Photo::fromUrl()` и `Video::fromUrl()` принимают +только absolute `http`/`https` URL с host. `file://`, `php://`, `ftp://`, +relative и hostless URL отклоняются на value-object boundary. + +Причина: PyMax URL-источники проходят через `aiohttp.ClientSession`, то есть +предназначены для HTTP(S). В PHP stream wrappers иначе могли бы открыть +локальные файлы или служебные stream schemes через параметр `url`, обходя явный +`path` source и ухудшая security posture shared-hosting runtime. + +## 2026-07-03 - Empty raw sources are not readable upload content + +Решение: `BaseFile::read()` и `size()` для raw source с пустой строкой +выбрасывают `UploadException`. Constructor остается permissive, чтобы object +shape совпадал с PyMax, но фактическое чтение/размер пустого raw считается +ошибкой. `iterChunks()` для пустого raw остается empty iterator, как PyMax +`iter_chunks()`. + +Причина: PyMax проверяет `if self.raw`, а не `raw is not None`, поэтому +`read()`/`size()` для `raw=b""` не возвращают валидные bytes/zero size. В PHP +проверка `raw !== null` принимала пустую строку и могла довести empty photo до +HTTP multipart upload. + +## 2026-07-03 - Reconnect is bounded by runFor budget + +Решение: `ClientOptions::reconnect` по умолчанию включен, как в PyMax +`ExtraConfig`, а `ClientOptions::reconnectDelay` по умолчанию равен `1.0`. +В PHPMax reconnect выполняется только внутри `Client::runFor()` и только пока +остается execution budget текущего короткого запуска. + +Причина: PyMax `start()` рассчитан на async long-running loop и может +переподключаться бесконечно. PHPMax должен работать на shared hosting, поэтому +переносим developer experience (`onDisconnect($exception, true, $delay)` и +повторный `onStart`) без превращения CLI/cron запуска в скрытый daemon. Если +`reconnect=false`, сохраняется прежнее fail-fast поведение: connection закрыт, +disconnect callback получает `false, 0.0`, а исходная ошибка пробрасывается. + +## 2026-07-03 - Bot init data is a TCP service helper + +Решение: `BotsService::getInitData()` переносит PyMax `WEB_APP_INIT_DATA` +как обычный TCP service helper и возвращает `InitData`, а не открывает web app +и не зависит от будущего `WebClient`. + +Причина: PyMax bot init data находится в API service layer, а WebSocket/QR +остается отдельным optional layer. Разделение позволяет использовать bot web +app URL в TCP core уже сейчас и не тянуть browser/websocket runtime в базовый +Composer-пакет. + +## 2026-07-03 - Telemetry is explicit and bounded + +Решение: перенести telemetry как `TelemetryService` с явной отправкой +`Opcode::LOG`, payload builder parity и опциональным login event после +успешной авторизации при `ClientOptions::telemetry=true`. PyMax background +telemetry loop не переносится буквально. + +Причина: в PyMax telemetry работает как async background service, что естественно +для long-running runtime. PHPMax должен быть совместим с shared hosting и +короткими CLI/cron запусками, поэтому diagnostics не должны создавать скрытый +daemon, удерживать соединение или ломать login/API flow при ошибке отправки. + +## 2026-07-03 - MessagePack fallback preserves 64-bit integers + +Решение: pure-PHP `MsgpackPayloadCodec` кодирует и декодирует `uint64`/`int64`, +а значения за пределами PHP integer range отклоняет диагностичной +`ProtocolException`. + +Причина: MAX payloads содержат millisecond timestamps и ids, которые выходят за +32-bit range. Усечение до 32-bit уже ломало QR expiry во fake-transport тесте и +могло бы тихо портить protocol parity в runtime. + +## 2026-07-03 - QR auth belongs to auth layer before WebClient + +Решение: перенести QR auth opcodes (`GET_QR`, `GET_QR_STATUS`, `LOGIN_BY_QR`, +`AUTH_QR_APPROVE`) в `AuthService` и добавить bounded `QrAuthFlow` до +реализации WebSocket `WebClient`. + +Причина: QR authorization contract не зависит от конкретного WebSocket +transport и нужен будущему `WebClient`, но может быть проверен сейчас через +TCP/fake-transport. Polling ограничен server expiry и `qrPollTimeout`, чтобы +не создавать скрытый long-running процесс на shared hosting. + +## 2026-07-03 - ConnectionManager reads through protocol-specific readers + +Решение: обобщить runtime через `FrameProtocolInterface` и +`FrameReaderInterface`. TCP остается default path с `TcpFrameReader`, а +WebSocket использует `WebSocketFrameReader`, который получает целые text +messages от message transport. + +Причина: TCP читает фиксированный binary header и MessagePack payload, а +WebSocket уже предоставляет JSON message frames. Смешивать эти правила в одном +`ConnectionManager::readFrame()` нельзя: это сделало бы transport слой хрупким +и мешало бы сохранить TCP как стабильный baseline. + +## 2026-07-03 - WebClient reuses bounded Client lifecycle + +Решение: `WebClient` является thin public subclass поверх общего `Client` +lifecycle: web user-agent, `WsProtocol`, `WebSocketTransport`, +`WebSocketFrameReader` и `QrAuthFlow` по умолчанию. + +Причина: PyMax `WebClient` отличается transport/auth setup, но high-level API, +router и service facade остаются теми же. Повторное использование bounded +runtime сохраняет shared-hosting ограничения и уменьшает риск расхождения TCP и +WebSocket developer experience. + +## 2026-07-03 - 2FA management stays inside AuthService + +Решение: перенести PyMax 2FA-management (`set_2fa`, `remove_2fa`, +`change_password`, `check_2fa`) как методы `AuthService` и тонкие public +shortcuts на `Client`. Payload contracts живут в `Api\Auth`; email-код +запрашивается через `EmailCodeProviderInterface`. + +Причина: это security-sensitive auth surface. Пароли, email-коды и track ids не +должны попадать в domain models, logs или runtime helpers. Порядок +`expectedCapabilities` сохранен как в PyMax: сначала password, затем hint, +затем email, даже если email validation выполняется до hint validation. + +## 2026-07-03 - Proxy is a transport/upload boundary concern + +Решение: `ClientOptions::proxy` применяется только на границах +`TcpTransport`, `WebSocketTransport` и `NativeHttpUploader`. Services, domain +models и payload contracts о proxy не знают. Поддержаны HTTP CONNECT и SOCKS5; +unsupported schemes завершаются fail-fast. HTTP uploads через proxy требуют +`ext-curl`, чтобы stream fallback не обошел proxy незаметно. + +Причина: PyMax использует один `ExtraConfig.proxy` для TCP, WebSocket и upload +HTTP requests. В PHPMax это должно оставаться инфраструктурной настройкой, +иначе proxy начнет протекать в API surface и усложнит тестирование payload +parity. Credentials считаются чувствительными и не должны логироваться. + +## 2026-07-03 - SQLite session store is optional parity backend + +Решение: сохранить `JsonFileSessionStore` как простой default для shared +hosting и добавить `SQLiteSessionStore` как optional adapter за тем же +`SessionStoreInterface`. SQLite schema использует PyMax-compatible columns: +token, device_id, phone, mt_instance_id и отдельные sync markers. + +Причина: PyMax использует SQLite session store, поэтому PHPMax должен уметь +сохранять те же данные без потери token refresh и sync state. Но требовать +`pdo_sqlite` для всей библиотеки нельзя: на shared hosting JSON-файл остается +самым переносимым вариантом. Optional adapter дает parity путь для окружений, +где SQLite доступен, не меняя public client/service API. + +## 2026-07-03 - Telemetry navigation planner is explicit and bounded + +Решение: перенести PyMax `NavigationPlanner`, screen graph и route profiles, +но использовать их только для явной сборки короткого NAV/PERF batch через +`TelemetryService::plannedNavigationEvents()` или +`sendPlannedNavigation()`. Public client shortcut: +`Client::sendTelemetryNavigationSession()`. + +Причина: PyMax telemetry loop рассчитан на async long-running app и делает +sleep между событиями. Для PHPMax на shared hosting скрытый background loop +недопустим. При этом сам planner важен для payload parity: screen ids, +action ids, CHAT/CHATS source params и render events должны строиться +единообразно и тестируемо. + +## 2026-07-03 - Contract manifest is a pre-publish gate + +Решение: добавить `docs/phpmax/contracts.json`, который генерируется из +локального `src/pymax` reference и проверяется командой +`php tools/contract-manifest.php check`. `just php-check` и +`just pre-publish-check` запускают эту проверку автоматически. + +Причина: PHPMax переносит внутренний protocol/domain surface PyMax, где drift +одного opcode, command, event mapping или enum value может сломать runtime без +очевидной ошибки компиляции. Manifest фиксирует reference anchors для commands, +opcodes, event types, dispatch event map, domain enum values, API enum values, +response payload key values и payload model fields; PHP constants и +EventResolver сверяются с ним до публикации. + +## 2026-07-03 - Release ZIP targets shared hosting runtime + +Решение: добавить `tools/build-release.php` и `just release-zip`. Архив +содержит runtime PHPMax (`src/PHPMax`), `composer.json`, `LICENSE`/`README.md`, +docs/phpmax, optional `vendor` и сгенерированный fallback `autoload.php`. +Python reference `src/pymax`, tests и tooling в архив не включаются. + +Причина: целевой сценарий включает shared hosting без shell Composer install. +Пока runtime Composer packages отсутствуют, архив может работать без `vendor`. +Если такие packages появятся, builder должен fail-fast требовать заранее +собранный `vendor/`, чтобы пользователь получил готовый ZIP, а не неполную +поставку. + +## 2026-07-03 - API error frames keep payload shape + +Решение: `App::invoke()` преобразует `Command::ERROR` в `ApiException`. +Exception хранит opcode, server error code, title, message, +localizedMessage и raw payload. Если payload не проходит `MaxApiError` +hydration, используется fallback `unknown_error`, но исходный payload все +равно сохраняется. + +Причина: PyMax возвращает структурированный `ApiError`, а PHPMax не должен +терять server error details в generic строке. Это важно для auth, uploads, +rate-limit и user-facing diagnostics, где код ошибки и localized message +нужны вызывающему коду. + +## 2026-07-03 - Login response becomes client state + +Решение: после успешного login `Client` хранит bound `LoginResponse` и отдает +PyMax BaseClient-like state через `me()`, `chats()`, `contacts()` и +`messages()`. Чаты из login response добавляются в `ChatService` cache, а +profile/contact users - в `UserService` cache. `relogin()` удаляет текущую +local session, сбрасывает in-memory state и может очистить config token. + +Причина: в PyMax login/sync response является рабочим состоянием клиента, а не +только ответом auth service. Если не связать эти модели с services, domain +helpers на объектах из login state будут падать, а повторные `getChat()`/ +`getUser()` будут делать лишние запросы вместо использования уже полученного +sync state. + +## 2026-07-03 - App owns runtime state caches + +Решение: `App` хранит runtime state `me`, `chats`, `users`, `contacts` и +`messages`, а chat/user services читают и обновляют этот общий state. `Client` +public accessors только отдают данные из `App`, а `relogin()` очищает `App` +state вместе с текущей session. + +Причина: в PyMax caches живут на уровне `App`, поэтому login/sync response, +service fetches и domain helpers видят один источник данных. Service-local +caches в PHPMax могли бы разойтись с login state и будущим sync/event state, +особенно после `leaveGroup()`, `deleteChat()` или `removeContact()`. + +## 2026-07-03 - App close owns runtime cleanup + +Решение: `Client::close()` делегирует в `App::close()`, а `App::close()` +закрывает и `ConnectionManager`, и `SessionStoreInterface`. + +Причина: PyMax закрывает transport и session store вместе в `App.close()`. +Для PHPMax это особенно важно с optional SQLite store: короткие CLI/cron +запуски и `withOpenSession()` не должны оставлять открытые file/SQLite handles. + +## 2026-07-03 - Profile update writes App state + +Решение: `AccountService::changeProfile()` обновляет профиль через +`App::setProfile()`. Это одновременно меняет `App::me()` и кладет contact в +общий user cache. + +Причина: PyMax после `change_profile` записывает новый профиль в `app.me` и +обновляет `app.users`. PHPMax public `Client::me()` читает данные из `App`, +поэтому profile update не должен оставаться только локальным состоянием +`AccountService`. + +## 2026-07-03 - Error scope validation fails fast + +Решение: `Router::onError()` и `Client::onError()` бросают +`InvalidArgumentException`, если scope не равен `global` или `local`. + +Причина: PyMax валидирует `ErrorScope(scope)` и не превращает неизвестное +значение в global handler. Для PHPMax silent fallback опасен: опечатка в +локальном error handler-е могла расширить область обработки ошибок на весь +router tree и скрыть реальные runtime сбои. diff --git a/docs/phpmax/documentation.md b/docs/phpmax/documentation.md new file mode 100644 index 0000000..1ed8822 --- /dev/null +++ b/docs/phpmax/documentation.md @@ -0,0 +1,55 @@ +# Documentation Maintenance + +## Главный принцип + +Документация является частью реализации PHPMax. Ее нужно вести автоматически в +ходе разработки, а не отдельным отложенным этапом. + +## Когда docs нужно обновлять + +Обновлять документацию обязательно, если изменение затрагивает: + +- public API или примеры использования; +- архитектуру, слои, зависимости или Composer scripts; +- protocol, payload/model contracts, auth, session/sync; +- contract manifest `docs/phpmax/contracts.json` или правила его проверки; +- runtime на shared hosting, timeouts, lifecycle; +- security-sensitive поведение; +- тестовый workflow, release workflow или just recipes; +- решения, которые должны быть понятны будущему разработчику. + +## Когда docs можно не менять + +Docs можно оставить без изменения, если изменение действительно не влияет на +понимание проекта: + +- исправлена опечатка в комментарии; +- поменяно форматирование без изменения поведения; +- добавлен тест без изменения public behavior; +- выполнен внутренний рефакторинг, который не меняет архитектурных границ и API. + +Такое решение все равно должно быть осознанным: перед публикацией нужно +запустить `just docs-guard`. Если исходники изменились, а docs нет, допустимо +использовать `DOCS_REVIEWED=1 just docs-guard` только после ручной проверки. + +## Pre-publish rule + +Перед публикацией в git: + +1. Запустить `just pre-publish-check`. +2. Если изменились исходники, проверить, изменились ли docs. +3. Если docs не менялись, оценить реальную необходимость. +4. Если docs не нужны, зафиксировать это в ответе/описании коммита/PR. +5. Если docs нужны, обновить подходящий файл в `docs/phpmax`. + +## Ответственность агента + +Агент, который меняет код, отвечает за актуальность документации. Пользователь +не должен отдельно напоминать обновить docs после каждого изменения. + +Если изменение затрагивает новый архитектурный выбор, обновить +`decision-log.md`. Если меняется процесс синхронизации с PyMax, обновить +`upstream-sync.md`. Если меняется набор команд, обновить этот файл и +`README.md`. Если меняются PyMax/PHP protocol contracts, обновить +`docs/phpmax/contracts.json` через `php tools/contract-manifest.php write` и +запустить `just contract-check`. diff --git a/docs/phpmax/implementation-status.md b/docs/phpmax/implementation-status.md new file mode 100644 index 0000000..0291929 --- /dev/null +++ b/docs/phpmax/implementation-status.md @@ -0,0 +1,514 @@ +# Implementation Status + +Дата: 2026-07-03 + +## Реализовано сейчас + +- Composer skeleton: + - `composer.json` с PHP 7.4 platform config; + - PSR-4 namespace `PHPMax\\`; + - корневой `README.md` переписан как публичный PHPMax quick start и входит + в release ZIP; + - `phpunit.xml`, `phpstan.neon`; + - lightweight runner `tools/run-php-tests.php` для окружений без Composer; + - `tools/php74-compat-check.php` для проверки PHP 8+ syntax/API drift даже + на машинах, где локальный `php -l` запускается новой версией PHP. +- `just` workflow: + - `just php-check` выполняет PHP lint, PHP 7.4 compatibility scan, + contract check и тесты; + - `just contract-check` сверяет `docs/phpmax/contracts.json` с Python + reference, PHP constants/event resolver, PHP payload/domain/event schema + keys и API service/client method surface; + - `just release-check` валидирует release manifest и vendor policy без + создания ZIP; + - `just release-zip` собирает shared-hosting runtime archive; + - `just integration-check` запускает opt-in real-account smoke checks для + TCP login, optional WebSocket, uploads, download URLs, bot init data, + telemetry, chat/session reads и proxy propagation; + - `just integration-plan` показывает безопасный план real-account проверок + и нужные env-переменные без сетевых запросов и без вывода секретов; + - `just pre-publish-check` проверяет agents/docs/PHP/release gates; + - `docs-guard` учитывает PHP source, tooling и GitHub workflow changes. +- GitHub Actions: + - `tests.yml` запускает PHPMax `just pre-publish-check` на PHP 7.4 и 8.3; + - Python reference lint/test baseline сохранен отдельными jobs; + - `publish.yml` собирает PHPMax release ZIP вместо старой PyPI-публикации; + - PR template ориентирован на PHPMax gates, docs review и parity fixtures. +- Core foundation: + - exceptions; + - contract manifest из Python reference: commands, opcodes, event types, + dispatch event map, domain enum values, API enum values и payload model + anchors with serialized payload key metadata, domain/response/attachment + model anchors, typed event model anchors, plus public API service method + mappings/parameter mappings and public client mixin shortcut + mappings/parameter mappings; + - PHP 7.4-compatible constants для `Command`, `Opcode`, базовых domain enum + values, включая `AccessType`; + - PHP 7.4-compatible API constant classes для auth/chat/message/session/user + enums и response payload key anchors; + - chat/message/account/user services читают response payloads через PyMax-like + payload key constant classes вместо scattered string literals; + - schema-driven `Model` с aliases, defaults, nested models и extra fields; + - `Model` принимает protocol camelCase aliases и PyMax/Pydantic snake_case + field names, а сериализует обратно в canonical protocol keys; + - unknown fields сохраняются только для `PHPMax\Domain\*` моделей, как в + PyMax domain `extra="allow"`; API payload и session storage models + игнорируют unknown keys и не сериализуют их обратно; + - `Model` валидирует required fields даже при explicit empty input and fails + explicit malformed array/list/map-list values instead of silently + converting them to empty collections; + - `list<...>` fields and list-like custom factories reject associative arrays + where PyMax expects list payloads; + - primitive list schemas validate item values too: `list` keeps + Pydantic-compatible integer coercion, `list` rejects non-strings, + and `list` still enforces PHP list shape; + - scalar casting is guarded: PHPMax keeps Pydantic-compatible int/bool + coercion but rejects malformed scalar payloads instead of relying on PHP + `(int)`/`(string)`/`(bool)` casts; + - custom model factories follow the same fail-fast rule for message + attachments, login contacts and message delete ids; + - optional model fields сохраняют PyMax nullability: отсутствующий + `Profile::profileOptions` остается `null`, а не превращается в пустой + список; explicit `profileOptions` payload remains a flexible array/map + compatibility exception because current server fixtures may be option maps; + - первые domain models: sync/session/profile/user/chat/member/message/ + elements/reactions/attachments/presence/events/folders/account sessions. + - known attachment models покрывают PyMax fields для photo/video/file/audio/ + contact/sticker/control/inline keyboard/share/call; unknown attachments + сохраняют future fields, но не принимают известный `_type`; attachment + discriminator принимает и `_type`, и `type`. + - `TranscriptionStatus` добавлен как PHP 7.4-compatible constant class для + audio attachments. +- Protocol foundation: + - TCP frame models; + - `TcpPacketFramer`; + - pure-PHP MessagePack codec для используемых типов, включая uint64/int64 + timestamps и signed offsets; + - `FrameProtocolInterface` и `FrameReaderInterface` для TCP/WebSocket runtime; + - `WsProtocol` для JSON WebSocket frames с protocol version 11; + - TCP payload decoder с key normalization; + - LZ4 block decompression; + - optional Zstd adapter через `ext-zstd`. +- Runtime foundation: + - blocking `TcpTransport`; + - blocking `WebSocketTransport` со strict HTTP Upgrade validation, masked + client text frames, unmasked server frame guard, ping/pong, close handling, + fragmentation validation и bounded message reader; + - `ProxyConfig`/`ProxyConnector` для HTTP CONNECT и SOCKS5 proxy на + TCP/WebSocket transport boundary; + - `ConnectionManager` с seq wrap, request/response matching и raw event dispatch; + - `ConnectionManager` event listeners для internal runtime hooks перед + пользовательским event handler; + - `App::invoke()`; + - `App::close()` как общий lifecycle close boundary для connection и session + store; + - `App` internal typed event router для service-level hooks без raw fallback; + - `App` runtime state для `me`, `chats`, `users`, `contacts`, `messages`, + включая profile update через account service; + - `ApiException` для `Command::ERROR` frames с сохранением opcode, + error/title/message/localizedMessage и raw payload; + - `ClientOptions`, `Client`, `Router`, bounded lifecycle anchors; + - `ClientOptions` нормализует runtime/connect timeouts и execution safety + margin, чтобы отрицательные значения не попадали в stream calls и не + расширяли execution budget; + - direct transport/upload boundaries (`TcpTransport`, `WebSocketTransport`, + `ProxyConnector`, `NativeHttpUploader`) тоже нормализуют отрицательные + timeout до безопасного lower bound при использовании без `ClientOptions`; + - прямой `ProxyConnector::connect()` для неположительного timeout использует + bounded fallback `1.0` second, чтобы HTTP CONNECT/SOCKS5 handshake не + становился flaky 1 ms race при invalid direct input; + - `ClientOptions`, `TcpTransport`, `WebSocketTransport` и `ProxyConnector` + fail fast на empty host и port вне `1..65535`; + - `ClientOptions` default user-agent использует PyMax-like random Android + anchors: app versions, device profiles, locale/timezone и build numbers; + - `Client::me()`, `chats()`, `contacts()`, `messages()`, `stop()` и + `relogin()` для PyMax BaseClient-like public state/reset surface; + - bounded heartbeat `Opcode::PING` внутри `Client::runFor()` по + `ClientOptions::pingInterval`; + - disconnect callbacks для нетаймаутных protocol/network ошибок в `runFor()`; + - bounded reconnect policy в `runFor()` с `reconnect`/`reconnectDelay`. +- Auth/session services: + - mobile/web handshake payloads; + - token login; + - SMS auth flow, 2FA password challenge и registration confirm; + - auth response models that mirror PyMax `require_payload_model` reject empty + payloads before hydration and before session-store side effects; + - 2FA account management: create auth track, validate password, email code, + hint, set/remove/change password и profile option check; + - public `Client::setTwoFactor()`, `removeTwoFactor()`, + `changePassword()`, `checkTwoFactor()`; + - QR auth contracts: request/check/confirm QR и approve QR login; + - bounded `QrAuthFlow` с `QrHandlerInterface` и `ConsoleQrHandler`; + - `Client::authorizeQrLogin()` shortcut; + - token refresh с обновлением session store; + - PyMax-like login sync persistence: login response `time` обновляет все + sync markers, `config.hash` обновляет config hash, отсутствующие значения + сохраняют предыдущий state, а `mtInstanceId` пишется в session store; + - saved session continuity: если loaded session содержит `mtInstanceId`, + handshake переиспользует его вместо нового random config id как PyMax; + - login response связывается с service helpers и наполняет общий `App` + chat/user runtime cache; + - profile update обновляет `App::me()` и общий user cache. +- WebClient foundation: + - `WebClient` scaffold поверх общего `Client` lifecycle; + - PyMax-like random web user-agent default (`DeviceType::WEB`) и + `WebHandshakePayload`; + - `QrAuthFlow` по умолчанию, если пользователь не передал custom auth flow; + - явно заданный custom auth flow и уже web-compatible user-agent не + перезатираются constructor-ом `WebClient`; + - `ClientOptions::wsUrl` для WebSocket endpoint. +- Proxy foundation: + - `ClientOptions::proxy`; + - TCP и WebSocket transports подключаются через proxy при заданном URL; + - `NativeHttpUploader` применяет proxy для cURL uploads; + - uploads через proxy требуют `ext-curl`, чтобы proxy не был молча обойден + stream fallback-ом; + - unsupported proxy schemes fail fast. +- Release ZIP foundation: + - `tools/build-release.php` с `--dry-run` и `--output`; + - `just release-zip`; + - fallback `autoload.php` генерируется внутрь архива для окружений без + Composer; + - архив включает `src/PHPMax`, `docs/phpmax`, `composer.json`, + `LICENSE`/`README.md` и optional `vendor`; + - Python reference, tests и tooling в runtime archive не попадают; + - если появятся runtime Composer packages, build потребует существующий + `vendor/`. +- Real integration harness: + - `tools/integration-check.php`, `just integration-plan` и + `just integration-check`; + - `--plan`/`PHPMAX_INTEGRATION_PLAN=1` печатает список real-account проверок + и состояние required env без сетевых запросов; + - без `PHPMAX_INTEGRATION=1` безопасно пропускается и не делает сетевых + запросов; + - при `PHPMAX_INTEGRATION=1` выполняется secret-safe preflight до сети: + session names, numeric env parsing/bounds, readable upload paths, writable + workdir, `Client`/`WebClient` construction и proxy config проверяются до + первого connect; + - при `PHPMAX_TOKEN` выполняет TCP login/profile-state smoke check; + - при `PHPMAX_AUTH_SMS=1` и `PHPMAX_PHONE` выполняет interactive phone/SMS + auth flow через console code prompt, затем проверяет сохраненную local + session и повторный login из этой session без token/SMS auth flow; + - optional env flags подключают fetch chats/sessions, bot init data, + telemetry login/navigation, photo/file/video uploads, file/video + temporary URL checks, WebSocket login и proxy path; + - session data пишется в `PHPMAX_WORKDIR` или temp workdir, токены/proxy + credentials в вывод не попадают даже при preflight errors. +- Message service foundation: + - send/forward/get/edit/history/delete; + - pin; + - file/video temporary URL requests by attachment id; + - response edge parity: `sendMessage()`/`forwardMessage()`/`readMessage()` + require payload like PyMax `require_payload_model`, `editMessage()` + requires the `message` item like `require_payload_item_model`, while + file/video URL helpers return `null` for empty payloads like + `parse_payload_model`; + - malformed `messages` list items in get/history responses fail fast like + PyMax `parse_payload_list` instead of being silently skipped; + - reactions add/get/remove; + - reaction response edge parity: empty optional `reactionInfo` returns + `null`, malformed reaction payloads fail fast, and `messagesReactions` + must stay a message-id keyed map; + - read mark; + - public shortcuts `Client::sendMessage()`, `forwardMessage()`, + `getMessages()`, `getMessage()`, `editMessage()`, `fetchHistory()`, + `deleteMessage()`, `pinMessage()`, `addReaction()`, `getReactions()`, + `removeReaction()`, `readMessage()`, `getFileById()`, + `getVideoById()`. +- Domain binding: + - `PHPMax\Api\Binding` привязывает `Message`, `Chat`, `User` и + `MessageDeleteEvent` к services; + - `Message::reply()`, `answer()`, `forward()`, `pin()`, `edit()`, + `delete()`, `read()`, `react()`, `unreact()`, `getReactions()`; + - `Chat::answer()`, `history()`, `getMessage()`, `getMessages()`, + `leave()`, `delete()`, `invite()`, `removeUsers()`, `pinMessage()`, + `updateSettings()`, `reworkInviteLink()`; + - `User::addContact()`, `removeContact()`, `getChatId()`; + - `MessageDeleteEvent` хранит привязку к `MessageService` как в PyMax; + - mapped events и service responses возвращают bound domain models. +- Event dispatch foundation: + - `EventResolver` и `EventMapper` для message/chat/delete/read/typing/ + presence/reactions/attach events; + - typed `Router::onMessage()`/`onTyping()`/`onChatUpdate()` helpers; + - `Router::onError()` с `global`/`local` scopes, `ErrorContext` и + fail-fast validation неизвестного scope; + - `Router::onDisconnect()` и `Client::emitDisconnect()`; + - raw fallback после typed dispatch; + - mapper edge parity: falsey payloads for known events keep PyMax raw-frame + fallback, but truthy `CHAT_UPDATE` must contain a non-empty nested `chat` + object and malformed payload fails before raw fallback; + - internal typed dispatch перед user router/raw fallback; + - runtime path для events, пришедших между request и response. +- Chat service foundation: + - create group, invite/remove users, settings/profile update; + - join/resolve/rework invite link; + - get/fetch chats с service-local cache; + - PyMax edge parity для chat links и pagination marker: invalid group links + fail fast, `joinChannel()` accepts non-join channel links, and + `fetchChats(0)` sends current timestamp marker like PyMax `marker or now`; + - optional empty `chat` response items mirror PyMax + `parse_payload_item_model`: they return `null`/no-op and do not overwrite + cached chats; + - malformed `chats` list items now fail fast like PyMax + `parse_payload_list` instead of being silently skipped; + - join request confirm/decline/fetch; + - leave/delete chat; + - public `Client` shortcuts для перенесенных chat methods. +- User/account service foundation: + - fetch/get/search users with service-local cache; + - add/remove/import contacts and local dialog chat id calculation; + - contact add/remove responses mirror PyMax `_contact_action` + `require_payload_dict`: invalid list-shaped `CONTACT_UPDATE` payloads + fail before cache side effects; + - malformed `contacts` and `sessions` list items fail fast like PyMax + `parse_payload_list`; + - sessions list; + - account profile update by prepared `photoToken` or uploaded `Photo`; + - profile photo upload URL request; + - create/get/update/delete folders; + - folder responses now mirror PyMax `require_payload_model`: empty folder + create/list/update/delete payloads fail fast instead of returning default + models; + - close all other sessions with token update; + - logout; + - public `Client` shortcuts для перенесенных user/account methods. +- Bot service foundation: + - `BotsService::getInitData()` для `WEB_APP_INIT_DATA`; + - `InitData` domain model; + - empty bot init data responses fail fast before `InitData` hydration; + - public `Client::getBotInitData()` shortcut. +- Telemetry foundation: + - `TelemetryEvent`/`TelemetryPayload` models; + - `TelemetryPayloadBuilder` для login/navigation/open-chat/open-chats + payload parity; + - `TelemetryService::sendEvents()` через `Opcode::LOG`; + - `Screen`/`RouteProfile`/`NavigationRules`/`NavigationPlanner` для PyMax-like + navigation route planning без background loop; + - public `Client::sendTelemetryEvents()` и `sendTelemetryLogin()`; + - public `Client::sendTelemetryNavigationSession()` для явной bounded + отправки planned NAV/PERF batch; + - `ClientOptions::telemetry=false` по умолчанию и bounded login telemetry + после успешной авторизации только при явном включении; + - ошибки telemetry не пробрасываются за service boundary. +- Upload service foundation: + - `PHPMax\Files\File`, `Photo`, `Video` с `path`/`url`/`raw` источниками, + размером и chunk iteration; + - file source helpers покрыты fixtures для raw/path/url name inference, + local read/size/chunk iteration, PyMax-like photo raw/path MIME и photo + URL extension validation; + - `UploadService` для `PHOTO_UPLOAD`, `VIDEO_UPLOAD`, `FILE_UPLOAD`; + - photo multipart upload и `_type` attachment payloads; + - photo HTTP upload response validation mirrors PyMax: invalid JSON, + missing `photos` map and missing token for requested `photo_id` fail as + `UploadException`; + - file/video chunk upload через `HttpUploaderInterface`; + - empty, missing-info, malformed and semantically invalid video/file upload + init payloads fail before HTTP upload starts, including empty upload URL, + empty token and non-positive file/video ids; + - direct cURL streaming через `StreamBody` без предварительного + `php://temp` накопления всего тела запроса; + - native HTTP uploader принимает только absolute `http`/`https` upload URLs + с host и валидным port; он отклоняет `file://`/`ftp://`/relative + endpoints и port `0` до запуска cURL/stream клиента; + - bounded ожидание `NOTIF_ATTACH` для file/video processing, включая + attach-события, пришедшие во время HTTP upload до wait loop; + - upload waiters хранят состояние только для активных ожидаемых + file/video ids, игнорируют чужие `NOTIF_ATTACH` и очищают active state + после успеха или HTTP upload failure; + - `FileRequest`/`VideoRequest` response models для download/playback URL, + включая dynamic video URL key normalization; + - `MessageService` принимает `Photo`/`File`/`Video` objects в attachments; + - `Client::uploadPhoto()`, `uploadVideo()`, `uploadFile()` возвращают typed + attach payload objects, не plain arrays; + - bound `Message::answer()` и `Chat::answer()` проверены с upload-backed + photo/video/file attachments. +- Formatting: + - markdown formatter для headings, quotes, links и inline markers; + - UTF-16 offsets для protocol elements, включая emoji/surrogate pairs; + - formatter parity fixtures покрывают все inline marker types, multiline + code block language skip, invalid/multiline markers, nested close order, + links и offsets после emoji. +- Persistence: + - `SessionStoreInterface`; + - `JsonFileSessionStore` с atomic write и file lock; + - optional `SQLiteSessionStore` с PyMax-compatible session columns, indexes + by `device_id`/`phone`, token update, sync marker persistence и + `deleteAllSessions()` full local cleanup; + - built-in JSON/SQLite stores fail fast на path-like или empty + `sessionName`, чтобы session storage не уходил за пределы `workDir`. + +## Проверено + +Текущий локальный gate: + +```bash +just pre-publish-check +``` + +Результат на 2026-07-03: + +```text +AGENTS.md and GEMINI.md are identical. +Source and docs changes are both present. +PHP 7.4 compatibility check passed. +Contract manifest is in sync. +Composer is not installed; skipping composer validate. +............................. +Assertions: 1303 +OK +Release spec is valid. +``` + +## Важно: еще не готово + +- Auth/login/handshake flow покрыт fake-transport тестами, но реальные + интеграционные проверки с аккаунтом не запускались. +- API services пользователей/account/folders подключены к public client и + покрыты fake-transport тестами. +- Event mapper/resolver покрывает основные TCP events, internal-before-user + dispatch, raw fallback и error/disconnect scopes. +- API error handling покрыт runtime тестами: valid error payload превращается + в `ApiException`, malformed payload получает fallback `unknown_error`, raw + payload сохраняется. +- Message service и public `Client` shortcuts покрыты fake-transport тестами + для send/forward/get/edit/history/delete/pin/reactions/read/download helper + flow. +- Contract manifest покрывает 4 commands, 164 opcodes, 13 event types, + 9 dispatch map entries, 5 domain enum groups/25 values, 16 API enum + groups/47 values, 71 payload model anchors with field/key metadata, + 46 domain/response/attachment model anchors with field/key metadata, + 7 typed event model anchors with field/key metadata, 8 API service + domains/77 public method mappings/77 public parameter mappings plus + 59 public client mixin method mappings/59 public parameter mappings; + `just php-check` теперь падает, если JSON manifest устарел, PHP + constants/event resolver разошлись с PyMax reference, PHP payload/domain/ + event schemas сериализуют другой набор top-level keys, public PyMax service + method не имеет PHP equivalent, PyMax client mixin method не доступен на + `Client`, или public method parameters разошлись по имени/порядку. +- Domain model audit восстановил `Name::firstName`/`lastName` и снял + ошибочную обязательность `Name::name`/`type`, чтобы PHP model соответствовал + PyMax `Name`. +- Domain/response manifest audit закрепил auth response aliases + `LOGIN`/`REGISTER`, snake_case sync storage keys и attachment/download + model keys; лишний canonical `VideoAttachment::url` убран в пользу отдельной + `VideoRequest`. +- Runtime reconnect policy покрыта fake-transport тестом: disconnect callback, + повторный `onStart` и обработка события после reconnect. +- Runtime heartbeat покрыт fake-transport тестом: idle `runFor()` отправляет + `Opcode::PING` с `interactive=true`, а `pingInterval=0.0` отключает ping. +- Lifecycle close покрыт fake-store тестами: `close()`, `withOpenSession()`, + `relogin(false)` и unhandled startup failure закрывают configured session + store через `App::close()`. +- Public client state/relogin покрыт fake-transport тестом: login profile/chats/ + contacts/messages доступны через `Client`, login models bound к services, + chat/user caches seeded, `relogin(false)` удаляет текущий session token и + сбрасывает in-memory login state. +- Auth login sync persistence покрыта fake-store тестами: все четыре + sync markers, config hash и `mtInstanceId` сохраняются как в PyMax + `_update_session`, а отсутствующие `time`/`config.hash` оставляют прежний + sync state. +- Saved session continuity покрыта fake-transport тестом: handshake использует + stored `mtInstanceId`/`deviceId` из session store, а token-auth без saved + session использует fresh config ids. +- `App` runtime state покрыт fake-transport тестами: chat/user services пишут + в общий cache, `leaveGroup()`/`deleteChat()`/`removeContact()` очищают тот же + state, а `Client` accessors читают данные из `App`. +- Profile runtime state покрыт fake-transport тестом: `changeProfile()` + обновляет `AccountService::profile()`, `App::me()` и общий user cache. +- Chat service покрыт fake-transport тестами, но реальные интеграционные + проверки еще не запускались. +- User/account/folders/bots покрыты fake-transport тестами, но реальные + интеграционные проверки еще не запускались. +- Telemetry покрыта fake-transport тестами для payload builder, `Opcode::LOG`, + no-op empty batch, swallow-failure behavior, автоматического login event при + `ClientOptions::telemetry=true`, navigation planner transitions, CHAT/CHATS + source params и planned NAV/PERF batch отправки. PyMax background telemetry + loop не переносится буквально. +- QR auth покрыт fake-transport тестами для payload/opcode parity и bounded + `QrAuthFlow` immediate confirmation. Реальная QR/WebClient авторизация еще + не проверялась. +- 2FA management покрыт fake-transport тестами для `AUTH_CREATE_TRACK`, + `AUTH_VALIDATE_PASSWORD`, `AUTH_VERIFY_EMAIL`, `AUTH_CHECK_EMAIL`, + `AUTH_VALIDATE_HINT`, `AUTH_CHECK_PASSWORD`, `AUTH_SET_2FA`, порядка + `expectedCapabilities` и public `Client::checkTwoFactor()`. +- WebSocket foundation покрыт fake message-transport и byte-level transport + тестами: `WsProtocol` JSON encode/decode, event forwarding before matching + response, `App::invoke()` protocol version 11, `WebClient` default/preserved + web user-agent и QR/custom auth flow, strict handshake validation, masked server frame guard, + control frame limits, RSV rejection, fragmentation reassembly and invalid + continuation/data interleaving failures, а также rejection binary data frames + на JSON text transport boundary. Собранные text messages валидируются как + UTF-8 после fragment reassembly. Реальный WebSocket handshake с Max еще не + проверялся. +- Proxy foundation покрыт unit-тестами для URL parsing, default ports, + credentials, stream/cURL normalization и fail-fast validation в + TCP/WebSocket/uploader constructors. `ProxyConnector` дополнительно покрыт + loopback-тестами HTTP CONNECT, SOCKS5 без auth и SOCKS5 username/password, + включая проверку tunnel bytes после handshake. HTTP CONNECT fixture покрывает + задержанный response при direct negative timeout, чтобы bounded fallback не + регрессировал в flaky 1 ms read window. Реальные внешние proxy подключения + еще не запускались. +- Release ZIP builder покрыт тестом dry-run manifest и реальной сборки через + `ext-zip` или системный `zip`, если backend доступен. Проверяется наличие + fallback `autoload.php`, `src/PHPMax`, docs/phpmax и отсутствие `src/pymax`/ + tests в архиве; распакованный archive smoke-test проверяет, что fallback + autoload реально загружает public clients, transport classes, JSON session + store и file/photo helpers без Composer. Корневой `README.md` дополнительно + проверяется как PHPMax-документация в source tree и extracted archive. +- JSON/SQLite session stores покрыты unit-тестом сохранения/загрузки, поиска + по device/phone, token refresh, sync markers, reconnect-after-close, + удаления одной session, full local cleanup через `deleteAllSessions()` и + fail-fast отклонения небезопасных session file names. + В окружениях без `pdo_sqlite` тест проверяет диагностичную ошибку optional + adapter-а. +- Upload service покрыт fake-transport/fake-HTTP тестами для обычного + processing wait и pre-ready attach во время HTTP upload, но реальные + интеграционные проверки upload endpoints еще не запускались. +- `NativeHttpUploader` для file/video streaming требует `ext-curl`; без него + photo multipart fallback работает через PHP streams, а file/video streaming + завершается диагностичной `UploadException`. С `ext-curl` тело file/video + upload читается напрямую из chunk iterator через `StreamBody`, без + предварительного накопления всего файла во временный stream; реальные cURL + multipart photo и streaming file/video POST paths закреплены loopback-тестами. + Non-HTTP(S), scheme-relative и relative upload endpoints отклоняются до + запуска HTTP client path. +- URL-источники `File`/`Photo`/`Video` теперь проверяют HTTP status для + `read()`/`size()`/`iterChunks()` и fail-fast отклоняют non-2xx responses как + PyMax `raise_for_status()`. URL source принимает только absolute `http` или + `https` URL с host и валидным port, чтобы PHP stream wrappers не читали + локальные/служебные схемы или не стартовали с broken endpoint; diagnostic + errors redact query, fragment and userinfo; success/error/scheme/port paths + покрыты loopback и unit-тестами. +- Empty raw sources now fail on `read()`/`size()` like PyMax falsey raw + handling; empty raw photo upload stops before HTTP multipart. +- Domain-bound helpers используют тот же `MessageService`, поэтому + upload-backed attachments доступны через bound `Message::answer()` и + `Chat::answer()`; photo/video/file paths покрыты fake-transport/fake-HTTP + fixtures, включая bounded `NOTIF_ATTACH` wait для video/file. +- Download helpers для `get_file_by_id`/`get_video_by_id` перенесены и покрыты + fake-transport тестами, но реальные temporary URL ответы Max еще не + проверялись. +- WebSocket `WebClient` scaffold добавлен, но real account QR/WebSocket + integration еще не запускалась. +- `just integration-check` добавлен как opt-in вход для реальных проверок, но + в текущем окружении он был выполнен только в disabled/skip, safe plan, + enabled-without-token fail-fast и SMS-missing-phone preflight режимах, + потому что реальный `PHPMAX_TOKEN`/телефонный код не предоставлен. +- Composer validate/PHPUnit/PHPStan не запускались, потому что Composer не + установлен в текущем окружении. + +## Следующий технический шаг + +Продолжать Milestone 6/7: + +1. Проверить real account upload integration: photo, file, video. +2. Проверить real account download/playback integration для file/video + temporary URLs. +3. Проверить real account bot init data integration. +4. Запустить real account telemetry checks через `just integration-check` для + login и planned navigation batches, если telemetry нужна в production + сценарии. +5. Проверить real account WebClient QR login и bounded `runFor()` поверх + WebSocket transport. +6. Проверить real proxy integration для TCP, WebSocket и uploads. +7. После Milestone 6 расширить real integration и long-run lifecycle проверки. diff --git a/docs/phpmax/release.md b/docs/phpmax/release.md new file mode 100644 index 0000000..8cb34df --- /dev/null +++ b/docs/phpmax/release.md @@ -0,0 +1,76 @@ +# PHPMax Release ZIP + +PHPMax должен уметь поставляться на shared hosting, где нет shell-доступа и +нельзя выполнить `composer install`. + +## Команды + +```bash +just release-check +just release-zip +php tools/build-release.php --check +php tools/build-release.php --output=dist/phpmax-dev.zip +php tools/build-release.php --dry-run +``` + +`just pre-publish-check` не создает архив автоматически, но запускает +`just release-check` и проверяет builder через tests. Перед публикацией release +archive нужно собрать отдельной командой `just release-zip`. + +## GitHub Actions + +`.github/workflows/publish.yml` собирает PHPMax release ZIP на PHP 7.4: + +- запускает `just pre-publish-check`; +- выполняет `just release-zip`; +- сохраняет ZIP как workflow artifact; +- при событии `release: published` прикладывает ZIP к GitHub Release. + +Старая PyPI-публикация отключена: PHPMax публикуется как PHP runtime ZIP и +Composer package metadata, а Python package остается только reference-кодом в +этом репозитории. + +## Состав архива + +Release ZIP содержит runtime-часть PHPMax: + +- `autoload.php` - fallback PSR-4 autoloader для окружений без Composer; +- `composer.json`; +- `LICENSE`, `README.md`, если они есть; корневой `README.md` должен оставаться + PHPMax quick start, потому что он является публичным входом в release ZIP; +- `src/PHPMax`; +- `docs/phpmax`; +- `vendor`, если директория существует. + +Python reference `src/pymax`, tests, tooling и dev-only files в архив не +попадают. + +## Vendor policy + +Сейчас PHPMax не имеет runtime Composer packages: в `require` только `php` и +PHP extensions. Поэтому архив может работать без `vendor`. + +Если в будущем появятся runtime packages, `tools/build-release.php` будет +требовать существующий `vendor/` и завершится ошибкой, пока разработчик не +соберет dependencies через: + +```bash +composer install --no-dev --classmap-authoritative +``` + +После этого `just release-zip` включит `vendor/` в архив. + +## Проверка + +Release builder покрыт lightweight тестом: + +- dry-run должен вернуть JSON manifest состава архива; +- `--check` должен проверить manifest/vendor policy без создания ZIP; +- реальный ZIP собирается через `ext-zip` или системный `zip`; +- archive listing проверяется через `unzip`, если команда доступна; +- архив должен содержать `autoload.php`, `composer.json`, `src/PHPMax` и + `docs/phpmax`; +- архив не должен содержать `src/pymax` и `tests-php`; +- распакованный архив должен запускать fallback `autoload.php` и загружать + runtime classes без Composer: public `Client`/`WebClient`, transport + classes, JSON session store и file/photo helpers. diff --git a/docs/phpmax/roadmap.md b/docs/phpmax/roadmap.md new file mode 100644 index 0000000..6b16a9c --- /dev/null +++ b/docs/phpmax/roadmap.md @@ -0,0 +1,217 @@ +# PHPMax Roadmap + +## Milestone 0 - Preparation + +- Создать `AGENTS.md`, `GEMINI.md` и `docs/phpmax`. +- Добавить upstream remote для `MaxApiTeam/PyMax`. +- Зафиксировать текущую reference-версию PyMax. +- Подготовить Composer skeleton и CI/test commands. + +Status 2026-07-03: выполнено. Composer skeleton, just workflow, upstream +remote, upstream audit и GitHub Actions gates добавлены. CI запускает PHPMax +pre-publish checks на PHP 7.4/8.3 и сохраняет Python reference baseline. + +## Milestone 1 - Contracts and Models + +- Сгенерировать или вручную зафиксировать contract manifest из Python reference: + opcodes, commands, event map, payload aliases, serialized payload keys, + payload/domain/event model fields, public service/client methods and public + service/client method parameters. +- Реализовать PHP 7.4-compatible constants вместо enums. +- Реализовать base model, hydrator, serializer, extra fields. +- Перенести core domain models: sync, session, auth responses, profile, user, + chat, message, attachments, events. + +Status 2026-07-03: частично выполнено. Contract manifest +`docs/phpmax/contracts.json` добавлен и проверяется через `just contract-check`. +Constants, включая domain enums и service/API enums, base model, sync/session, +profile/user/chat/message/elements/reactions/attachments, auth responses, +presence/member и typed event models добавлены. Contract check теперь сверяет +top-level serialized payload keys перенесенных PHP payload schemas с PyMax +reference, domain/response/attachment model keys и ловит отсутствующие PHP +equivalents для public PyMax API service methods и `Client` shortcuts для +PyMax infra mixins, а также drift в public parameter names/order. +`Name::firstName`/`lastName`, auth response aliases, +snake_case sync storage keys и attachment/download keys закреплены через +domain manifest audit. `changePassword($passwordOld, $passwordNew)` и +`Client::fetchHistory(..., $fromTime, ...)` закреплены signature gate. +Typed event model keys теперь тоже проверяются contract gate. +Hydrator validation приближен к Pydantic: required fields проверяются даже при +explicit empty input, malformed explicit collection values fail fast, and +PHP associative arrays are rejected where PyMax expects list payloads. Scalar +casting now keeps Pydantic-compatible coercions but rejects malformed scalar +payloads instead of using PHP permissive casts. Primitive list payload/domain +fields now validate both list shape and item values through `list`, +`list` and `list` schemas, with `Profile::profileOptions` kept +as a server-compatible flexible array/map exception. +Unknown fields are now preserved only on `PHPMax\Domain\*` models to mirror +PyMax domain `extra="allow"`; API payload and session models ignore unknown +keys so they are not serialized back into protocol requests or storage. +Остается расширять model/payload parity fixtures по мере переноса новых API +участков. + +## Milestone 2 - Protocol and Transport + +- Перенести TCP frame models. +- Реализовать `TcpPacketFramer`. +- Реализовать MessagePack codec adapter. +- Реализовать LZ4 block decompression и optional Zstd adapter. +- Реализовать blocking TLS TCP transport с read/write timeouts. +- Покрыть byte-level parity fixtures. + +Status 2026-07-03: частично выполнено. TCP frames, framer, MessagePack codec, +64-bit int fallback parity, LZ4, optional Zstd, blocking TCP transport и +parity tests добавлены. + +## Milestone 3 - Runtime, Session, Auth + +- Реализовать `ConnectionManager`, pending request resolution, seq wrap. +- Реализовать `App::invoke()`. +- Реализовать session stores: JSON first, SQLite optional. +- Перенести handshake, token login, SMS auth, 2FA password, registration config. +- Добавить bounded lifecycle: `open()`, `close()`, `runFor()`, + `withOpenSession()`. + +Status 2026-07-03: частично выполнено. ConnectionManager, App::invoke, +JsonFileSessionStore, optional SQLiteSessionStore, lifecycle anchors, +mobile/web handshake, token login, SMS auth flow, 2FA password challenge, 2FA management +(`setTwoFactor`/`removeTwoFactor`/`changePassword`/`checkTwoFactor`), token +refresh и disconnect callbacks для bounded runtime добавлены. Bounded reconnect +policy в `runFor()` добавлена: по умолчанию включена как в PyMax, но ограничена +execution budget. PyMax-like heartbeat `Opcode::PING` добавлен внутри bounded +`runFor()` и настраивается через `ClientOptions::pingInterval`. Public client +state anchors `me()`/`chats()`/`contacts()`/`messages()` и `relogin()` добавлены; +login response binds domain helpers, seeds chat/user caches и обновляет +persisted sync markers/`mtInstanceId` как PyMax `_update_session`; saved +sessions now reuse stored `mtInstanceId` during handshake instead of replacing +it with a fresh config id. `App` теперь является владельцем runtime state, а +chat/user services обновляют общий cache вместо независимых service-local +caches. Остались реальные интеграционные проверки. `Client::close()` теперь +проходит через `App::close()` и закрывает +transport вместе с session store. `AccountService::changeProfile()` обновляет +`App::me()` и общий user cache. Session stores поддерживают PyMax-like +`deleteAllSessions()` для full local cleanup. Auth response models now reject +empty payloads before hydration/session side effects like PyMax +`require_payload_model`. +Built-in JSON/SQLite stores теперь отклоняют path-like `sessionName`, чтобы +local session storage оставался внутри `workDir`. + +## Milestone 4 - Messages and Events + +- Перенести message service: send, get, edit, delete, history, reactions, read. +- Перенести markdown formatter с UTF-16 offsets. +- Реализовать router/dispatcher/filter/error/raw event behavior. +- Сохранить best-effort event mode для cron/short CLI. + +Status 2026-07-03: частично выполнено. Message service для send/forward/get/ +edit/history/delete/reactions/read, markdown formatter с UTF-16 offsets, +typed event resolver/mapper, typed router handlers, error scopes, disconnect +callbacks, raw fallback, domain-bound `Message` helpers и public `Client` +shortcuts для PyMax MessageMixin surface добавлены. Internal typed listeners +на уровне `App` теперь выполняются до пользовательского router-а и без raw +fallback. Error scopes теперь валидируют неизвестный scope fail-fast, чтобы +ошибка конфигурации не становилась глобальным handler-ом. Formatter parity +fixtures покрывают marker types, multiline code, invalid/multiline markers, +nested marker order, links и UTF-16 offsets после emoji. Message response +edge parity закреплен для strict send/forward/edit/read responses и nullable +file/video request helpers. Malformed `messages` list items теперь fail fast +как PyMax `parse_payload_list`. Reaction responses сохраняют optional empty +`reactionInfo` -> `null` behavior и fail-fast malformed/map checks. +Event mapper edge parity закреплен: falsey known payloads keep raw-frame +fallback, а truthy `CHAT_UPDATE` требует nested non-empty `chat`. + +## Milestone 5 - Chats, Users, Account, Bots + +- Перенести chat/user/self services. +- Перенести bot web app init data service. +- Перенести folder/session/account operations. +- Сверить cache/bind behavior domain objects. + +Status 2026-07-03: начато. Chat service для create/join/resolve/get/fetch/ +invite/remove/settings/profile/join requests/leave/delete добавлен вместе с +public `Client` shortcuts, service-local cache и domain-bound `Chat` helpers. +Chat service edge parity закреплен focused fixtures: `join/` trimming, +non-join channel links, invalid group resolve и PyMax-like `fetch_chats` +`marker or now` fallback для `0`, а также optional empty `chat` item as +missing behavior для create/invite/remove/join-request flows. Malformed +`chats` list items теперь fail fast как PyMax `parse_payload_list`. +User service, account/self service, folders, sessions list, contact import and +public `Client` shortcuts добавлены. Account profile photo object подключен +через `UploadService`. Bot web app init data service и public +`Client::getBotInitData()` добавлены. Folder/bot empty payload edge cases +закреплены PyMax-like fail-fast fixtures. `CONTACT_UPDATE` add/remove теперь +требует dict-like response до cache side effects, как PyMax `_contact_action`. +Malformed `contacts`/`sessions` list items теперь fail fast как PyMax +`parse_payload_list`. +Real account integration еще не запускалась. + +## Milestone 6 - Files and Uploads + +- Перенести `File`, `Photo`, `Video`. +- Реализовать photo multipart upload. +- Реализовать file/video streaming upload. +- Реализовать ожидание processing notification в bounded event loop. + +Status 2026-07-03: начато. `PHPMax\Files\File`/`Photo`/`Video`, upload payload +models, `UploadService`, HTTP uploader abstraction, photo multipart upload, +file/video chunk upload, bounded `NOTIF_ATTACH` wait с internal waiter hooks, +public `Client` shortcuts, `MessageService` attachment object support и +profile photo upload в `AccountService::changeProfile()` добавлены. Photo HTTP +upload response edge cases закреплены PyMax-like `UploadException` fixtures, а +реальный multipart POST path проверяется loopback-тестом. +Download helper parity для `getFileById()`/`getVideoById()` и +`FileRequest`/`VideoRequest` моделей добавлен. Empty/malformed video/file +upload init responses теперь fail fast до HTTP upload. Direct cURL streaming +без предварительного temporary stream добавлен для file/video uploads и +закреплен loopback-тестом реального streaming POST. Photo +raw/path MIME теперь повторяет PyMax `image/` behavior, а URL MIME +остается guess/map behavior. URL source helpers принимают только absolute +`http`/`https` URL с host, а read/size/chunk helpers отклоняют non-2xx HTTP +responses как PyMax `raise_for_status()`. Empty raw sources fail on +read/size before HTTP upload. Domain helper fixtures покрывают +`Message::answer()` +и `Chat::answer()` с uploaded photo/video/file attachments. Upload waiters +теперь tracking-only для активных ids: non-matching attach events не копятся, +а active state очищается после успеха и HTTP upload failure. Добавлен +`just integration-check` как opt-in harness для реальных upload/download +проверок с `PHPMAX_TOKEN`, но реальные endpoints еще не запускались. + +## Milestone 7 - Optional Layers + +- WebSocket `WebClient` и QR auth. +- Proxy adapters. +- Telemetry. +- Release ZIP with vendor для shared hosting без shell composer install. + +Status 2026-07-03: начато. Telemetry payload models, builder и service для +`Opcode::LOG` добавлены. `ClientOptions::telemetry` по умолчанию выключен; при +явном включении после успешного login отправляется bounded login event. PyMax +background telemetry loop не переносится буквально: в PHPMax telemetry остается +явной, короткоживущей и не должна ломать основной сценарий при ошибке отправки. +Navigation/open-chat payload helpers и bounded `NavigationPlanner` добавлены; +navigation session отправляется явно через `TelemetryService`, без фонового +sleep loop. QR auth foundation добавлен в auth layer: +`GET_QR`, `GET_QR_STATUS`, `LOGIN_BY_QR`, `AUTH_QR_APPROVE`, bounded +`QrAuthFlow` и `Client::authorizeQrLogin()`. WebSocket foundation добавлен: +runtime `FrameProtocolInterface`/`FrameReaderInterface`, `WsProtocol`, +`WebSocketFrameReader`, blocking `WebSocketTransport` со strict HTTP Upgrade +validation, frame hardening и `WebClient` scaffold с web user-agent и QR auth +flow по умолчанию. Proxy +foundation добавлен: `ClientOptions::proxy`, HTTP CONNECT/SOCKS5 connector для +TCP/WebSocket, loopback handshake fixtures и proxy propagation в +`NativeHttpUploader`. Release ZIP workflow +добавлен: `just release-zip` собирает runtime archive с fallback autoload, +`src/PHPMax`, docs/phpmax и optional `vendor`. `just integration-check` теперь +умеет запускать optional real WebSocket login, bot init data, telemetry login/ +navigation, proxy и read checks при наличии env-параметров; без них эти +проверки безопасно пропускаются. `just integration-plan` показывает preflight +план real-account проверок без сетевых запросов и без вывода секретов. Реальная +WebClient/proxy/telemetry интеграционная проверка с аккаунтом еще не +запускалась. + +## Definition of Done для каждого milestone + +- Есть PHPUnit tests. +- Есть parity fixtures с Python reference. +- Обновлен `decision-log.md`, если принято новое решение. +- Проверка upstream PyMax не просрочена. diff --git a/docs/phpmax/shared-hosting-runtime.md b/docs/phpmax/shared-hosting-runtime.md new file mode 100644 index 0000000..e3e9b20 --- /dev/null +++ b/docs/phpmax/shared-hosting-runtime.md @@ -0,0 +1,128 @@ +# Shared Hosting Runtime + +## Основная модель запуска + +PHPMax должен работать в окружениях, где нельзя держать постоянный daemon. +Основной сценарий первой версии: + +- cron запускает PHP CLI script; +- script открывает соединение; +- выполняет bounded work; +- сохраняет session/sync; +- закрывает соединение до `max_execution_time`. + +Session persistence может быть JSON-файлом или SQLite DB. Оба варианта должны +лежать вне webroot; SQLite требует `pdo_sqlite` и полезен, когда нужно хранить +несколько sessions с быстрым поиском по device/phone. + +Для shared hosting без shell Composer install использовать release ZIP: +`just release-zip`. Архив содержит fallback `autoload.php`, runtime +`src/PHPMax`, docs/phpmax и optional `vendor`, если он был собран заранее. + +## Public lifecycle anchors + +- `Client::open()` - открыть transport, handshake, auth/login. +- `Client::close()` - через `App::close()` закрыть transport и session store. +- `Client::runFor(int $seconds)` - слушать события и ping не дольше заданного + лимита. +- `Client::withOpenSession(callable $callback)` - короткий сценарий: + открыть, выполнить действие, закрыть. +- `ClientOptions::reconnect` и `reconnectDelay` - best-effort reconnect после + нетаймаутных protocol/network ошибок внутри `runFor()`. +- `ClientOptions::pingInterval` - интервал heartbeat `Opcode::PING` внутри + `runFor()`; default `30.0`, значение `0.0` отключает heartbeat. + +## Execution budget + +- Всегда учитывать `ini_get('max_execution_time')`. +- Если лимит известен, оставлять safety margin минимум 2-5 секунд. +- Любой blocking read/write должен иметь timeout. +- `ClientOptions::requestTimeout`, `connectTimeout`, `uploadHttpTimeout`, + `uploadProcessingTimeout` и `executionSafetyMargin` нормализуются до + безопасных нижних границ, чтобы отрицательные пользовательские значения не + попадали в stream/runtime calls. +- Direct usage `TcpTransport`, `WebSocketTransport`, `ProxyConnector` и + `NativeHttpUploader` также нормализует timeout на boundary, если эти классы + используются без `ClientOptions`. `ProxyConnector` при прямом неположительном + timeout использует bounded fallback `1.0` second, чтобы invalid user input не + превращался в случайный 1 ms handshake race. +- `ClientOptions::host`, `port`, transport endpoints и proxy target endpoints + должны fail-fast отклонять empty host и port вне `1..65535` до socket + connect или handshake. +- Reconnect не должен превращаться в бесконечный daemon: попытки восстановления + выполняются только пока остается execution budget текущего `runFor()`. +- Любой public close path (`close()`, `stop()`, `withOpenSession()`, + `relogin()`) должен закрывать session store, чтобы SQLite/file handles не + оставались открытыми после короткого CLI/cron запуска. +- Если startup падает во время auth или `onStart`, `Client::open()` должен + закрыть transport и session store перед возвратом ошибки. +- `SessionStoreInterface::deleteAllSessions()` должен очищать только локальное + хранилище PHPMax; серверные сессии закрываются отдельным API методом + `Client::closeAllSessions()`. +- Heartbeat ping не должен запускать отдельный background loop: ping отправляется + только внутри текущего bounded `runFor()` и использует timeout, ограниченный + оставшимся execution budget. +- QR auth polling не должен быть бесконечным. `QrAuthFlow` ограничен + `ClientOptions::qrPollTimeout` и временем истечения QR, которое возвращает + сервер. +- Upload/file processing wait не должен бесконечно ждать событие. +- File/video upload processing ждать только через bounded deadline + `uploadProcessingTimeout`; нерелевантные TCP events должны уходить дальше в + event handler, а не теряться внутри upload wait. +- Telemetry не должна запускать скрытый daemon/background loop. В PHPMax она + отправляется явно или один раз после login при `ClientOptions::telemetry=true` + и не должна ломать основной сценарий при ошибке `Opcode::LOG`. +- Telemetry navigation planner не делает `sleep` и не запускает цикл. Он + собирает короткий NAV/PERF batch в памяти и отправляет его только по явному + вызову. +- WebSocket `WebClient` использует тот же bounded lifecycle: `open()`, + `runFor($seconds)`, `close()` и reconnect только в рамках execution budget. + Он не должен работать как бесконечный daemon в HTTP-request lifecycle. +- Proxy handshake должен уважать connect/read timeouts и fail-fast завершаться + понятной ошибкой. Proxy credentials не логировать и не включать в сообщения + исключений. HTTP CONNECT/SOCKS5 handshake должен оставаться покрытым + loopback-тестами без внешнего proxy. +- Для больших file/video uploads preferred runtime имеет `ext-curl`. Если его + нет, PHPMax должен завершаться понятной `UploadException`, а не читать + большой файл целиком в строку. +- При наличии `ext-curl` file/video uploads должны идти через direct read + callback (`StreamBody`), который держит только текущий buffer, а не + предварительно собирает весь upload body в `php://temp`. Реальный cURL path + должен оставаться streaming POST и покрываться loopback-тестом. +- Photo multipart uploads могут читать photo body целиком, как PyMax, но + multipart headers должны экранировать field/filename values и покрываться + loopback-тестом, чтобы не допустить CRLF injection. +- HTTP upload endpoints, которые приходят от сервера для photo/file/video, + должны быть absolute `http`/`https` URL с host. `NativeHttpUploader` + отклоняет `file://`, `ftp://`, scheme-relative, relative URL и port вне + `1..65535` до запуска cURL или PHP stream fallback. +- URL-источники вложений должны проверять HTTP status перед использованием + body или `Content-Length`; non-2xx response считается ошибкой upload source. + Допустимы только absolute `http`/`https` URL с host, чтобы `url` не мог + открыть локальные файлы или служебные PHP stream wrappers. Port должен быть + валидным TCP port `1..65535`. +- Ошибки URL-источников не должны печатать query, fragment или userinfo: + signed URL tokens считаются секретами. +- Empty raw sources считаются ошибкой при `read()`/`size()` и не должны + доходить до HTTP upload. + +## Что не обещаем в v1 + +- Строгий realtime на shared hosting. +- Постоянное WebSocket-соединение в HTTP-request lifecycle. +- Фоновые задачи после завершения PHP-процесса. +- Автоматическую установку Composer dependencies на сервере без shell-доступа: + такие зависимости должны быть включены в release ZIP заранее. + +## Безопасность + +- Session-файлы и SQLite DB хранить вне webroot или явно предупреждать + пользователя. +- `ClientOptions::sessionName` и custom file names для built-in JSON/SQLite + stores должны быть plain file names. Path segments, `.`/`..` и null bytes + отклоняются до создания директории или открытия storage. +- Atomic write + lock при сохранении JSON session; SQLite store использует + транзакционные записи PDO SQLite и file permissions `0600`. +- Не логировать token, phone полностью, SMS-коды, 2FA password. +- Не логировать proxy credentials. +- Ошибки доступа к session-store должны быть диагностичными. diff --git a/docs/phpmax/testing.md b/docs/phpmax/testing.md new file mode 100644 index 0000000..b63affe --- /dev/null +++ b/docs/phpmax/testing.md @@ -0,0 +1,364 @@ +# PHPMax Testing Strategy + +## Test groups + +- Unit tests: small services, hydrator, formatter, JSON/SQLite session stores. +- Contract checks: generated PyMax manifest for opcodes, commands, dispatch + event map, domain/API enums, payload/domain/event anchors, serialized + payload keys and API service/client method surface. +- Release packaging tests: dry-run manifest and archive content. +- Byte parity tests: TCP frame header, MessagePack payloads, compression. +- Domain parity tests: Python fixture payload -> PHP model -> payload. +- Model alias parity tests: hydrator accepts PHP property names, protocol + camelCase keys and PyMax/Pydantic snake_case field names. +- Hydrator validation tests: required-field validation runs even for explicit + empty input, and explicit malformed array/list/map-list values fail instead + of becoming empty collections; PHP associative arrays are rejected where + PyMax expects list payloads; primitive `list`/`list`/ + `list` payload fields validate list shape and item types; malformed + scalar values fail instead of using PHP permissive casts; unknown fields are + preserved for domain models but ignored by API payload/session models. +- Runtime tests: fake transport, pending request resolution, seq wrap. +- Runtime internal dispatch tests: internal typed listeners run before user + router handlers and never receive raw fallback. +- API error tests: `Command::ERROR` payload -> `ApiException` with raw payload. +- WebSocket tests: JSON protocol, message reader, event-before-response flow, + protocol version 11 in `App::invoke`, `WebClient` defaults, preservation of + explicit web auth/user-agent options and byte-level `WebSocketTransport` + hardening. +- User-agent tests: default/random Android and Web payloads stay within PyMax + config anchors and web payload keeps the PyMax alias allowlist. +- Proxy tests: URL parsing, default ports, credentials, cURL/stream proxy + normalization, unsupported scheme fail-fast, endpoint validation, direct + timeout normalization and loopback HTTP CONNECT/SOCKS5 handshakes. +- Shared hosting tests: bounded `runFor()`, timeouts, clean close, + session-store close, reconnect and lower-bound normalization for + timeout/safety-margin options and direct transport/upload boundaries, plus + host/port fail-fast validation. +- Runtime heartbeat tests: idle `runFor()` sends `Opcode::PING` with + `interactive=true`, and `pingInterval=0.0` disables heartbeat. +- Lifecycle close tests: `Client::close()`, `withOpenSession()`, + `relogin(false)` and unhandled startup failures close configured session + store through `App::close()`. +- Public client shortcut tests: MessageMixin-compatible methods delegate to the + service layer and preserve return models. +- Public client state tests: login response profile/chats/contacts/messages, + bound domain helpers from login data, seeded caches and `relogin()` session + reset. +- Domain binding tests: `Message`, `Chat`, `User` and `MessageDeleteEvent` + receive their service bindings through the shared binder. +- App runtime state tests: shared chat/user caches are visible through `App`, + updated by services and cleared by leave/delete/remove operations. +- Profile state tests: `changeProfile()` updates `AccountService::profile()`, + `App::me()` and shared App user cache. +- Upload tests: fake TCP + fake HTTP, multipart/stream headers, bounded + `NOTIF_ATTACH` wait, direct stream body reader, loopback cURL multipart and + streaming POST, file source helpers with loopback URL status checks and typed + `Client::upload*` shortcuts. +- Domain helper upload tests: bound `Message`/`Chat` methods with uploaded + photo/video/file attachments and bounded file/video attach waits. +- Telemetry tests: payload builder parity, navigation planner, `Opcode::LOG` + request payload, empty batch no-op, failure swallowing, optional auto-login + telemetry. +- QR auth tests: request/check/confirm/approve QR opcodes and bounded + immediate-confirmation `QrAuthFlow`. +- 2FA management tests: auth-track creation, password validation, email code, + hint, set/remove/change 2FA payloads and profile option checks. +- Optional integration tests: real account/token through + `just integration-check`, disabled by default and opt-in through + `PHPMAX_INTEGRATION=1`. + +## Required fixtures + +- TCP packet with known header bytes. +- Contract manifest from Python reference: 4 commands, 164 opcodes, 13 event + types, 9 dispatch map entries, 5 domain enum groups/25 values, + 16 API enum groups/47 values and 71 payload model anchors with field/key + metadata, 46 domain/response/attachment model anchors with field/key + metadata, plus 7 typed event model anchors with field/key metadata, + 8 API service domains/77 public method mappings/77 public parameter + mappings and 59 public client mixin method mappings/59 public parameter + mappings. +- Payload key aliases: `_type`, `mt_instanceid`, `from`, chat settings option + keys and `_ContactPayload` -> PHP `ContactPayload` mapping. +- Core domain keys: `Name::firstName`/`lastName`, message reaction/previous-id + keys, chat event counters and login/profile state keys. +- Hydrator edge keys: empty `Chat` input fails required-field validation, + scalar `names`/`profileOptions`/login `messages` values fail collection + validation, and `LoginResponse::contacts` allows `null` items but rejects + scalar contact entries. Custom factories keep the same rule for + `Message::attaches` and `MessageDeleteEvent::messageIds`: scalar attachment + items and non-list/non-scalar delete ids fail fast. Associative arrays are + rejected for list-like `names`, login `messages` map-list values, login + `contacts`, message `attaches`, delete `messageIds`, upload `info`, and + primitive-list request fields such as message ids, user ids, folder include + ids and folder filters. + Scalar coercion fixtures keep Pydantic-compatible int/bool coercion for + `42.0`, `true` and `"false"`, while rejecting `"abc"`, `"42.1"`, arrays, + non-string string fields and unknown boolean strings. Extra-field fixtures + verify `Profile` preserves unknown domain fields, while + `SendMessagePayload`/nested API payload models and `SessionInfo` ignore + unknown keys so they do not leak into outgoing protocol payloads or storage. +- Response/storage/download keys: auth `TokenAttrs` uses `LOGIN`/`REGISTER`, + `SyncState`/`SyncOverrides` keep snake_case storage keys, bot init data uses + `queryId`, and `FileRequest`/`VideoRequest` keep download/playback URL keys. +- Attachment model keys: all known PyMax attachments are contract-checked for + `_type`, id/token fields and aliases such as `videoType`, `photoToken`, + `audioId`, `contactIds` and inline keyboard `keyboard`. +- Typed event keys: delete `messageIds`, read `setAsUnread`, reaction + `totalCount`, file/video upload signal ids. +- API service method mappings: PyMax public service methods must have PHP + equivalents with matching parameter names/order, including explicit PHP + names for 2FA helpers. +- Client shortcut mappings: PyMax infra mixin methods must be exposed on + `PHPMax\Client` with matching parameter names/order, including explicit PHP + names for 2FA helpers. +- Signature anchors: `change_password(password_old, password_new)` maps to + `changePassword($passwordOld, $passwordNew)`, and the public client + `fetchHistory()` wrapper keeps `fromTime` to mirror PyMax `from_time`. +- MessagePack payloads with int keys, bytes keys, enums/constants, uint64 + timestamps and signed int64 values. +- LZ4 compressed payload. +- Zstd compressed payload if adapter exists. +- Message event payloads: new, edit, delete. +- Router/error scope fixtures: `global`/`local` error propagation, filter + exceptions, invalid error scope fail-fast, disconnect callbacks and raw + fallback after handled typed errors. +- Event mapper edge fixtures: falsey payloads for known events keep PyMax + raw-frame fallback, while truthy `CHAT_UPDATE` payloads must contain a + non-empty nested `chat` object and fail before raw fallback when malformed. +- Markdown formatter fixtures: all inline marker types, multiline code blocks, + invalid/multiline markers, nested marker close order, links and UTF-16 + offsets after emoji/surrogate pairs. +- Message client shortcuts: forward/edit/history/delete/pin/reactions/read and + file/video helper flow. +- Message response edge cases: `sendMessage()`/`forwardMessage()`/ + `readMessage()` require non-empty payloads, `editMessage()` requires a + non-empty `message` item, while `getVideoById()`/`getFileById()` keep PyMax + `parse_payload_model` nullable behavior on empty responses. +- Message list parsing edge cases: malformed `messages` items in + `getMessages()` and `fetchHistory()` fail fast like PyMax + `parse_payload_list`. +- Message reaction response edge cases: empty optional `reactionInfo` returns + `null`, malformed `reactionInfo`/`messagesReactions` fail fast, and + `messagesReactions` must be a message-id keyed map. +- Runtime reconnect: non-timeout disconnect, callback metadata, repeated + `onStart`, event processing after reconnect. +- Runtime heartbeat: ping response matching through `ConnectionManager` and + explicit disabled heartbeat path. +- API error frames: valid Max error payload and malformed fallback payload. +- Chat update with nested pinned message. +- Chat model payloads with `AccessType` values and snake_case counters/icons. +- Profile payloads where missing `profileOptions` stays `null` and is omitted + from serialized payloads by default. +- Message model payloads with `{message: {...}}` wrappers, snake_case outer + fields and snake_case nested attachment keys. +- Attachment discriminator fixtures for all known PyMax attachment types: + photo, video, file, audio, contact, sticker, control, inline keyboard, + share and call. Factory fixtures must cover both `_type` and `type` + discriminator keys. +- Photo attachment payloads with PyMax fields (`photo_id`, `photo_token`, + `base_url`, `preview_data`) and legacy `token` alias compatibility. +- `UnknownAttachment` fixtures: future `_type` is preserved with extra fields, + but a known `_type` is rejected when parsed directly as unknown. +- Bot init data response: `queryId`, `url`. +- Auth responses: login token, registration token, password challenge. +- Auth response edge cases: response models that mirror PyMax + `require_payload_model` reject empty payloads, including `AUTH_REQUEST` and + `LOGIN`, and failed login responses must not update the session store. +- Login sync persistence: `LoginResponse::updateSyncState()` preserves saved + markers when response `time`/`config.hash` are absent, updates all four sync + markers plus config hash when present, and auth login saves the refreshed + `mtInstanceId` with the updated sync state before token rotation. +- Client login state: profile, chats, contacts and chat-id keyed messages from + `LOGIN`, saved-session handshake reusing stored `mtInstanceId`/`deviceId`, + token login using fresh config ids when no session exists, plus relogin + deletion of the refreshed session token. +- App cache state: login/fetch-created chats and users, cache hits, and cache + removal for chat leave/delete and contact removal. +- User contact update response edge cases: `CONTACT_UPDATE` must return + dict-like payload before add/remove contact side effects are accepted. +- User list parsing edge cases: malformed `contacts`/`sessions` items fail + fast like PyMax `parse_payload_list` instead of being silently skipped. +- Chat service edge cases: join links are trimmed from the first `join/` + prefix, `joinChannel()` keeps non-join channel links, invalid group resolve + fails fast, `fetchChats(0)` mirrors PyMax `marker or now` behavior, and + optional empty `chat` items are treated as missing instead of cached as + empty domain objects. +- Chat list parsing edge cases: malformed `chats` items fail fast like PyMax + `parse_payload_list` instead of being silently skipped. +- Account folder response edge cases: create/get/update/delete folder require + a non-empty response payload like PyMax `require_payload_model`. +- Bot init data edge cases: empty `WEB_APP_INIT_DATA` response fails fast + before hydrating `InitData`. +- Upload responses: photo, file, video, including invalid photo HTTP JSON, + missing photo maps/tokens, missing or malformed video/file `info`, empty + video/file init responses, empty upload URLs/tokens and non-positive upload + ids rejected before HTTP upload starts. +- Native HTTP uploader: real cURL loopback fixtures must send multipart photo + POST bodies with sanitized field/filename headers, and streaming file/video + POST bodies through `CURLOPT_READFUNCTION` with the expected + `Content-Length`, without relying on fake uploader behavior; upload + endpoints must reject non-HTTP(S), scheme-relative, relative and invalid-port + URLs before cURL/stream execution. +- File source helpers: `raw`, `path` and `url` name inference, local path + size/read/chunk iteration, empty raw `read()`/`size()` failure, URL + `read()`/`size()`/`iterChunks()` success and HTTP error status handling, URL + scheme validation for absolute `http`/`https` only, redacted URL diagnostics + without query/userinfo secrets, PyMax-like photo raw/path MIME and photo URL + extension/MIME validation. +- Upload processing events: matching and non-matching `NOTIF_ATTACH`, pre-ready + attach events dispatched during HTTP upload before blocking wait, and cleanup + of active waiter state after success or HTTP upload failure. +- Domain helper upload attachments: bound `Message::answer()` and + `Chat::answer()` with `Photo`, `Video` and `File` object inputs. +- Download/playback responses: `FileRequest`, `VideoRequest` with dynamic video + URL key. +- Telemetry events: login, navigation, open_chat_to_render, + open_chats_to_render. +- Telemetry navigation route: screen transitions, action ids, CHAT source + params and CHATS main-tab params. +- WebSocket JSON frames: request, response, event and invalid JSON fallback. +- WebSocket transport hardening: HTTP `101` plus `Upgrade`/`Connection`/ + `Sec-WebSocket-Accept` validation, unmasked server frames, control frame + limits, RSV rejection, valid fragmented text reassembly, invalid + continuation/data interleaving failures, binary data frame rejection, + invalid UTF-8 text rejection after full reassembly and oversized frame + rejection before payload read. +- User-agent anchors: PyMax app versions/build numbers, Android device + profiles, locale/timezone list, web version/screen and default browser + header. +- 2FA payloads: `expectedCapabilities` order must match PyMax + (`SET_PASSWORD`, `HINT`, `EMAIL`). +- Proxy URLs: HTTP CONNECT and SOCKS5 forms with and without credentials, + including loopback handshake/tunnel byte fixtures and delayed HTTP CONNECT + response under direct negative timeout normalization. +- JSON/SQLite session stores: token, device_id, phone, mt_instance_id and sync + marker persistence compatible with PyMax store, lookup by device/phone, + token update, delete by token, `deleteAllSessions()` full local cleanup and + unsafe file-name rejection for empty names, `.`/`..`, path separators and + null bytes. +- Session token rotation: `closeAllSessions()` updates the stored token without + losing device, phone, mt_instance_id or sync marker state. +- Release ZIP content: fallback `autoload.php`, `src/PHPMax`, `docs/phpmax`, + no `src/pymax`, no tests, plus extracted-archive smoke test for runtime + class loading without Composer across public clients, transports, JSON + session store and file/photo helpers. Root `README.md` content is checked in + the source tree and extracted archive so PHPMax releases cannot regress to + Python/PyMax installation instructions. + +## Commands target + +`just` является основной точкой входа для локальных проверок: + +```bash +just doctor +just contract-check +just php-check +just release-check +just release-zip +just integration-plan +just integration-check +just pre-publish-check +``` + +Текущий `just php-check` работает даже без Composer: + +- lint всех PHP-файлов в `src/PHPMax`, `tests-php`, `tools`; +- `php tools/php74-compat-check.php` ловит native enum, union types, + attributes, constructor property promotion, `match`, nullsafe operator, + PHP 8-only return/property/parameter types и PHP 8-only helper calls; +- `php tools/contract-manifest.php check`; +- `composer validate --strict`, если Composer установлен; +- lightweight test runner `php tools/run-php-tests.php`. + +`just release-check` запускает `php tools/build-release.php --check` и +проверяет release manifest/vendor policy без создания ZIP. Реальная сборка +архива остается отдельной командой `just release-zip`. + +Optional real-account checks are intentionally outside `php-check` and CI: + +```bash +just integration-plan +PHPMAX_INTEGRATION=1 PHPMAX_TOKEN=... just integration-check +PHPMAX_INTEGRATION=1 PHPMAX_AUTH_SMS=1 PHPMAX_PHONE=+79990000000 just integration-check +``` + +`just integration-plan` performs no network requests, does not require +`PHPMAX_TOKEN`, and prints only check names/env variable names. Token/proxy +values must never be printed by the harness. + +When `PHPMAX_INTEGRATION=1`, the harness performs a preflight before any +network request. It validates session names, numeric env parsing/bounds, +upload path readability, writable workdir and `Client`/`WebClient` +construction, including proxy config, and returns exit code `2` for malformed +configuration without printing token/proxy secret values. + +Base run performs TCP login/profile-state verification. It can use either +`PHPMAX_TOKEN` or interactive SMS auth through `PHPMAX_AUTH_SMS=1` and +`PHPMAX_PHONE`. In SMS mode the harness asks for the code through STDIN, then +verifies that a local session was saved and immediately opens a second client +from that saved session without token/SMS auth flow. Additional checks are +enabled by env flags/ids: + +- `PHPMAX_FETCH_CHATS=1`, `PHPMAX_FETCH_SESSIONS=1`; +- `PHPMAX_TELEMETRY_LOGIN=1`, `PHPMAX_TELEMETRY_NAVIGATION=1`; +- `PHPMAX_BOT_ID`, optional `PHPMAX_BOT_CHAT_ID`, + `PHPMAX_BOT_START_PARAM`; +- `PHPMAX_UPLOAD_PHOTO=1`, `PHPMAX_UPLOAD_FILE=1`, + `PHPMAX_UPLOAD_VIDEO=1` with `PHPMAX_UPLOAD_VIDEO_PATH`; +- `PHPMAX_DOWNLOAD_CHAT_ID`, `PHPMAX_DOWNLOAD_MESSAGE_ID`, + `PHPMAX_DOWNLOAD_FILE_ID`/`PHPMAX_DOWNLOAD_VIDEO_ID`; +- `PHPMAX_WEBSOCKET=1`; +- `PHPMAX_PROXY` to run the same checks through proxy. + +The harness stores session data in `PHPMAX_WORKDIR` or +`sys_get_temp_dir()/phpmax-integration` and never prints tokens or proxy +credentials. + +Результат PHP portion последнего локального `just pre-publish-check` на +2026-07-03: + +```text +PHP 7.4 compatibility check passed. +Contract manifest is in sync. +Composer is not installed; skipping composer validate. +............................. +Assertions: 1303 +OK +Release spec is valid. +``` + +Когда Composer будет установлен в окружении, дополнительно должны быть доступны: + +```bash +composer validate +composer test +composer phpstan +``` + +Если проверяется PHP 7.4 совместимость на машине с новой PHP-версией, Composer +должен использовать platform config `php: 7.4.x`, а lightweight gate дополнительно +запускает `tools/php74-compat-check.php`. + +## CI target + +GitHub Actions `tests.yml` должен зеркалировать локальный gate: + +- PHPMax job запускает `just pre-publish-check` на PHP 7.4 и 8.3; +- Composer только валидирует `composer.json`, runtime dependencies не + устанавливаются для lightweight runner; +- release manifest/vendor policy проверяются через `just release-check`; +- Python reference lint/test baseline остается отдельными jobs, чтобы + `src/pymax` не деградировал как источник parity. + +## Acceptance rule + +Нельзя считать milestone завершенным, если измененный behavior не покрыт хотя бы +одним тестом или fixture. Для protocol/model/payload слоя parity fixture +обязателен. + +Перед публикацией в git обязательно выполнить `just pre-publish-check`. Если +исходники изменились без изменения документации, нужно либо обновить docs, либо +после ручной оценки запустить `DOCS_REVIEWED=1 just docs-guard` и явно указать, +почему документация не требовалась. diff --git a/docs/phpmax/upstream-sync.md b/docs/phpmax/upstream-sync.md new file mode 100644 index 0000000..be47362 --- /dev/null +++ b/docs/phpmax/upstream-sync.md @@ -0,0 +1,478 @@ +# Upstream PyMax Sync + +## Обязательное правило + +Во время активной разработки проверять новые версии PyMax: + +- перед началом каждого крупного milestone; +- перед изменениями protocol/auth/session/security; +- минимум раз в 7 календарных дней. + +Если новая версия найдена, нельзя продолжать milestone вслепую. Нужно сначала +понять, влияет ли upstream diff на PHPMax. + +## Источники проверки + +- GitHub: `https://github.com/MaxApiTeam/PyMax` +- Python package: `maxapi-python` +- Текущий fork: `https://github.com/varyagnord/PHPMax` + +## Процесс + +1. Проверить latest tag/release/version upstream. +2. Сравнить с версией reference-кода в этом репозитории. +3. Просмотреть release notes и diff. +4. Отдельно проверить: + - opcodes and commands; + - TCP/WebSocket protocol; + - auth/login/session/sync; + - payload aliases and model fields; + - dispatcher/event map; + - uploads; + - security-sensitive fixes. +5. Принять решение: + - `port-now` - переносить до продолжения текущего milestone; + - `port-later` - записать в backlog с причиной; + - `ignore` - Python-only или не относится к PHPMax. +6. Обновить log ниже. + +## Decision matrix + +- Protocol/auth/session/security changes: default `port-now`. +- New service method: default `port-later`, если не блокирует текущий milestone. +- Docs-only upstream change: default `ignore`, если не меняет поведение. +- Python-only refactor: default `ignore`. +- Model/payload field change: default `port-now`, если поле участвует в уже + перенесенном PHP функционале. + +## Upstream Check Log + +Записывать новые проверки в начало списка. + +Template: + +```text +Date: YYYY-MM-DD +Checked by: +Current PHPMax reference: +Latest upstream: +Sources checked: +Diff summary: +Decision: +Follow-up: +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local implementation/docs progress audit +Diff summary: no newer upstream version found during PHPMax completion-status audit +Decision: continue from local reference; remaining gaps are real-account integration, production validation and hardening, not upstream drift +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local src/pymax/__init__.py +Diff summary: no newer upstream version found before WebSocket UTF-8 text boundary hardening +Decision: continue from local reference; harden PHP WebSocket transport without changing Max payload contracts +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before proxy transport loopback coverage +Decision: continue from local reference; add deterministic HTTP CONNECT/SOCKS5 loopback tests without changing protocol contracts +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before WebSocket transport hardening +Decision: continue from local reference; harden PHP WebSocket transport boundary without changing PyMax payload contracts +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before API enum contract manifest expansion +Decision: continue from local reference; include service/API enum groups and payload key constants in contracts.json checks +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD refs/tags/v2.3.1 + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before user-agent config parity transfer +Decision: continue from local reference; mirror PyMax app/device/locale anchors in PHP user-agent generation +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before contract manifest domain enum parity expansion +Decision: continue from local reference; include domain enum groups in contracts.json and strict PHP constant checks +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before CI/release workflow alignment +Decision: continue from local reference; align GitHub Actions with PHPMax just gates and release ZIP workflow +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before domain enum/nullability parity transfer +Decision: continue from local reference; add AccessType constants and preserve missing profileOptions as null +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before attachment model field parity transfer +Decision: continue from local reference; complete known attachment model fields and UnknownAttachment discriminator guard +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before internal dispatch/upload waiter parity transfer +Decision: continue from local reference; add App-level internal typed listeners before user router/raw dispatch and use them for upload processing notifications +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json via curl after PHP stream retry failed + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before profile App state parity transfer +Decision: continue profile state transfer from local reference; make changeProfile update App::me and shared user cache +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before lifecycle close/store cleanup transfer +Decision: continue App.close parity from local reference; close connection and session store through the runtime lifecycle boundary +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before App runtime state/cache parity transfer +Decision: continue App-level state transfer from local reference; move chat/user caches to App as the single runtime state owner +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before BaseClient state/relogin transfer +Decision: continue client state and relogin transfer from local reference; keep PHP API explicit with methods instead of Python properties +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before bounded runtime heartbeat transfer +Decision: continue ping behavior transfer from local reference; keep PHP heartbeat inside bounded runFor instead of a background loop +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before optional SQLite session store transfer +Decision: continue session persistence transfer from local reference; keep JSON default and add SQLite as optional parity backend +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before proxy adapter transfer +Decision: continue proxy foundation transfer from local reference; support proxy on TCP/WebSocket/upload boundaries +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before 2FA management transfer +Decision: continue auth/security 2FA transfer from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before WebSocket protocol/transport foundation +Decision: continue WebSocket foundation transfer from local reference; keep runtime bounded and TCP-compatible +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before QR auth contract transfer +Decision: continue auth QR foundation transfer from local reference; keep QR polling bounded for PHP runtime +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before optional telemetry transfer +Decision: continue telemetry transfer from local reference; keep PHP behavior bounded and explicit +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before bounded reconnect policy transfer +Decision: continue runtime reconnect implementation from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before Milestone 6 files/uploads transfer +Decision: continue upload service transfer from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json +Diff summary: no newer upstream version found before user/account/folder service transfer +Decision: continue Milestone 5 user/account implementation from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json +Diff summary: no newer upstream version found before Message/Chat domain-bound helper transfer +Decision: continue domain binding transfer from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json +Diff summary: no newer upstream version found before router error/disconnect scope transfer +Decision: continue Milestone 4 router/dispatcher transfer from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json + - local pyproject.toml and src/pymax/__init__.py +Diff summary: no newer upstream version found before Milestone 4/5 event/chat work +Decision: continue event dispatcher and chat service transfer from local reference +Follow-up: next check before protocol/auth/session/security work or within 7 days +``` + +Initial record: + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: GitHub HEAD/tag v2.3.1, PyPI maxapi-python 2.3.1 +Sources checked: + - git ls-remote https://github.com/MaxApiTeam/PyMax.git HEAD/tags + - https://pypi.org/pypi/maxapi-python/json +Diff summary: no newer upstream version found; local reference matches upstream HEAD and v2.3.1 tag +Decision: continue Milestone 0/1 implementation from local reference +Follow-up: upstream remote added as https://github.com/MaxApiTeam/PyMax.git +``` + +```text +Date: 2026-07-03 +Checked by: Codex +Current PHPMax reference: maxapi-python 2.3.1, commit 8c40b71 +Latest upstream: not rechecked after repository clone +Sources checked: local repository state +Diff summary: baseline documentation preparation only +Decision: before Milestone 0 implementation, perform full GitHub/Python package check +Follow-up: add upstream remote and record the first full sync audit +``` diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..495f49f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - src/PHPMax + - tests-php + phpVersion: 70400 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..27f158b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests-php + + + diff --git a/src/PHPMax/Api/Account/AccountService.php b/src/PHPMax/Api/Account/AccountService.php new file mode 100644 index 0000000..d9cf716 --- /dev/null +++ b/src/PHPMax/Api/Account/AccountService.php @@ -0,0 +1,189 @@ +app = $app; + $this->profile = null; + } + + public function requestProfilePhotoUploadUrl(): string + { + $payload = new UploadPayload(['profile' => true]); + $response = $this->app->invoke(Opcode::PHOTO_UPLOAD, $payload->toArray()); + $url = $response->payload[SelfPayloadKey::URL] ?? null; + if ($url === null) { + throw new PHPMaxException('Profile photo upload URL not found in response'); + } + + return (string) $url; + } + + public function changeProfile( + string $firstName, + ?string $lastName = null, + ?string $description = null, + $photo = null, + ?string $photoToken = null + ): bool { + if (is_string($photo) && $photoToken === null) { + $photoToken = $photo; + $photo = null; + } + if ($photo instanceof Photo) { + $attach = $this->app->api()->uploads->uploadPhoto($photo, true); + $photoToken = (string) $attach->photoToken; + } + $payload = new ChangeProfilePayload([ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'description' => $description, + 'photoToken' => $photoToken, + ]); + $response = $this->app->invoke(Opcode::PROFILE, $payload->toArray()); + $profile = $response->payload[SelfPayloadKey::PROFILE] ?? null; + if (!is_array($profile)) { + throw new PHPMaxException('Profile not found in response'); + } + + $this->profile = $this->app->setProfile(Profile::fromArray($profile)); + + return true; + } + + /** + * @param list $chatInclude + * @param list|null $filters + */ + public function createFolder(string $title, array $chatInclude, ?array $filters = null): FolderUpdate + { + $payload = new CreateFolderPayload([ + 'id' => $this->uuidV4(), + 'title' => $title, + 'include' => $chatInclude, + 'filters' => $filters !== null ? $filters : [], + ]); + $response = $this->app->invoke(Opcode::FOLDERS_UPDATE, $payload->toArray()); + + return FolderUpdate::fromArray($this->requireResponsePayload($response->payload)); + } + + public function getFolders(int $folderSync = 0): FolderList + { + $payload = new GetFolderPayload(['folderSync' => $folderSync]); + $response = $this->app->invoke(Opcode::FOLDERS_GET, $payload->toArray()); + + return FolderList::fromArray($this->requireResponsePayload($response->payload)); + } + + /** + * @param list|null $chatInclude + * @param list|null $filters + * @param list|null $options + */ + public function updateFolder( + string $folderId, + string $title, + ?array $chatInclude = null, + ?array $filters = null, + ?array $options = null + ): FolderUpdate { + $payload = new UpdateFolderPayload([ + 'id' => $folderId, + 'title' => $title, + 'include' => $chatInclude !== null ? $chatInclude : [], + 'filters' => $filters !== null ? $filters : [], + 'options' => $options !== null ? $options : [], + ]); + $response = $this->app->invoke(Opcode::FOLDERS_UPDATE, $payload->toArray()); + + return FolderUpdate::fromArray($this->requireResponsePayload($response->payload)); + } + + public function deleteFolder(string $folderId): FolderUpdate + { + $payload = new DeleteFolderPayload(['folderIds' => [$folderId]]); + $response = $this->app->invoke(Opcode::FOLDERS_DELETE, $payload->toArray()); + + return FolderUpdate::fromArray($this->requireResponsePayload($response->payload)); + } + + public function closeAllSessions(): bool + { + $session = $this->app->session(); + if ($session === null || $session->token === null) { + return false; + } + + $response = $this->app->invoke(Opcode::SESSIONS_CLOSE, []); + $token = $response->payload[SelfPayloadKey::TOKEN] ?? null; + if ($token === null || $token === '') { + return false; + } + + $this->app->store()->updateToken($session->token, (string) $token); + $this->app->setSession(new SessionInfo([ + 'token' => (string) $token, + 'deviceId' => $session->deviceId, + 'phone' => $session->phone, + 'mtInstanceId' => $session->mtInstanceId, + 'sync' => $session->sync, + ])); + + return true; + } + + public function logout(): bool + { + $this->app->invoke(Opcode::LOGOUT, []); + + return true; + } + + public function profile(): ?Profile + { + return $this->profile; + } + + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** + * @param array|null $payload + * @return array + */ + private function requireResponsePayload(?array $payload): array + { + if ($payload === null || $payload === []) { + throw new PHPMaxException('Missing payload in response'); + } + + return $payload; + } +} diff --git a/src/PHPMax/Api/Account/AvatarType.php b/src/PHPMax/Api/Account/AvatarType.php new file mode 100644 index 0000000..fba8b10 --- /dev/null +++ b/src/PHPMax/Api/Account/AvatarType.php @@ -0,0 +1,14 @@ + ['type' => 'string', 'required' => true], + 'lastName' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'photoToken' => ['type' => 'string'], + 'avatarType' => ['type' => 'string', 'default' => AvatarType::USER_AVATAR], + ]; + } +} diff --git a/src/PHPMax/Api/Account/CreateFolderPayload.php b/src/PHPMax/Api/Account/CreateFolderPayload.php new file mode 100644 index 0000000..60ebb1f --- /dev/null +++ b/src/PHPMax/Api/Account/CreateFolderPayload.php @@ -0,0 +1,31 @@ + */ + public $include = []; + /** @var list */ + public $filters = []; + + protected static function schema(): array + { + return [ + 'id' => ['type' => 'string', 'required' => true], + 'title' => ['type' => 'string', 'required' => true], + 'include' => ['type' => 'list', 'required' => true], + 'filters' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Account/DeleteFolderPayload.php b/src/PHPMax/Api/Account/DeleteFolderPayload.php new file mode 100644 index 0000000..19bd38f --- /dev/null +++ b/src/PHPMax/Api/Account/DeleteFolderPayload.php @@ -0,0 +1,20 @@ + */ + public $folderIds = []; + + protected static function schema(): array + { + return [ + 'folderIds' => ['type' => 'list', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Account/GetFolderPayload.php b/src/PHPMax/Api/Account/GetFolderPayload.php new file mode 100644 index 0000000..8449b6d --- /dev/null +++ b/src/PHPMax/Api/Account/GetFolderPayload.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'default' => 0], + ]; + } +} diff --git a/src/PHPMax/Api/Account/SelfPayloadKey.php b/src/PHPMax/Api/Account/SelfPayloadKey.php new file mode 100644 index 0000000..634d020 --- /dev/null +++ b/src/PHPMax/Api/Account/SelfPayloadKey.php @@ -0,0 +1,16 @@ + */ + public $include = []; + /** @var list */ + public $filters = []; + /** @var list */ + public $options = []; + + protected static function schema(): array + { + return [ + 'id' => ['type' => 'string', 'required' => true], + 'title' => ['type' => 'string', 'required' => true], + 'include' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'filters' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'options' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Account/UploadPayload.php b/src/PHPMax/Api/Account/UploadPayload.php new file mode 100644 index 0000000..957da90 --- /dev/null +++ b/src/PHPMax/Api/Account/UploadPayload.php @@ -0,0 +1,23 @@ + ['type' => 'int', 'default' => 1], + 'profile' => ['type' => 'bool', 'default' => false], + ]; + } +} diff --git a/src/PHPMax/Api/ApiFacade.php b/src/PHPMax/Api/ApiFacade.php new file mode 100644 index 0000000..e2898b2 --- /dev/null +++ b/src/PHPMax/Api/ApiFacade.php @@ -0,0 +1,51 @@ +auth = new AuthService($app); + $this->bots = new BotsService($app); + $this->session = new SessionService($app); + $this->telemetry = new TelemetryService($app); + $this->uploads = new UploadService($app); + $this->messages = new MessageService($app); + $this->chats = new ChatService($app); + $this->users = new UserService($app); + $this->account = new AccountService($app); + } +} diff --git a/src/PHPMax/Api/Auth/ApproveQrLoginPayload.php b/src/PHPMax/Api/Auth/ApproveQrLoginPayload.php new file mode 100644 index 0000000..f8b9143 --- /dev/null +++ b/src/PHPMax/Api/Auth/ApproveQrLoginPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/AuthService.php b/src/PHPMax/Api/Auth/AuthService.php new file mode 100644 index 0000000..80cfd64 --- /dev/null +++ b/src/PHPMax/Api/Auth/AuthService.php @@ -0,0 +1,326 @@ +app = $app; + } + + public function requestCode(string $phone): StartAuthResponse + { + $payload = new RequestCodePayload(['phone' => $phone]); + $response = $this->app->invoke(Opcode::AUTH_REQUEST, $payload->toArray()); + + return StartAuthResponse::fromArray($this->requirePayload($response->payload)); + } + + public function sendCode(string $token, string $verifyCode): CheckCodeResponse + { + $payload = new SendCodePayload([ + 'token' => $token, + 'verifyCode' => $verifyCode, + ]); + $response = $this->app->invoke(Opcode::AUTH, $payload->toArray()); + + return CheckCodeResponse::fromArray($this->requirePayload($response->payload)); + } + + public function checkPassword(string $trackId, string $password): CheckPasswordResponse + { + $payload = new CheckPasswordChallengePayload([ + 'trackId' => $trackId, + 'password' => $password, + ]); + $response = $this->app->invoke(Opcode::AUTH_LOGIN_CHECK_PASSWORD, $payload->toArray()); + + return CheckPasswordResponse::fromArray($this->requirePayload($response->payload)); + } + + public function login(MobileUserAgentPayload $userAgent): LoginResponse + { + if ($userAgent->deviceType === DeviceType::WEB) { + return $this->webLogin(); + } + + return $this->mobileLogin(); + } + + public function mobileLogin(): LoginResponse + { + $session = $this->app->session(); + if ($session === null) { + throw new PHPMaxException('No session available for login'); + } + + $sync = $this->app->options()->sync->resolve($session->sync); + $payload = SyncPayload::fromSyncState($this->app->options()->userAgent, $session->token, $sync); + $response = $this->app->invoke(Opcode::LOGIN, $payload->toArray()); + $login = LoginResponse::fromArray($this->requirePayload($response->payload)); + $this->updateSession($login); + + return $login; + } + + public function webLogin(): LoginResponse + { + $session = $this->app->session(); + if ($session === null) { + throw new PHPMaxException('No session available for login'); + } + + $sync = $this->app->options()->sync->resolve($session->sync); + $payload = WebSyncPayload::fromSyncState($session->token, $sync); + $response = $this->app->invoke(Opcode::LOGIN, $payload->toArray()); + $login = LoginResponse::fromArray($this->requirePayload($response->payload)); + $this->updateSession($login); + + return $login; + } + + public function confirmRegistration(string $firstName, ?string $lastName, string $token): ConfirmRegistrationResponse + { + $payload = new ConfirmRegistrationPayload([ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'token' => $token, + ]); + $response = $this->app->invoke(Opcode::AUTH_CONFIRM, $payload->toArray()); + + return ConfirmRegistrationResponse::fromArray($this->requirePayload($response->payload)); + } + + public function requestQr(): RequestQrResponse + { + $response = $this->app->invoke(Opcode::GET_QR, []); + + return RequestQrResponse::fromArray($this->requirePayload($response->payload)); + } + + public function checkQr(string $trackId): CheckQrResponse + { + $payload = new CheckQrPayload(['trackId' => $trackId]); + $response = $this->app->invoke(Opcode::GET_QR_STATUS, $payload->toArray()); + + return CheckQrResponse::fromArray($this->requirePayload($response->payload)); + } + + public function confirmQr(string $trackId): CheckCodeResponse + { + $payload = new ConfirmQrPayload(['trackId' => $trackId]); + $response = $this->app->invoke(Opcode::LOGIN_BY_QR, $payload->toArray()); + + return CheckCodeResponse::fromArray($this->requirePayload($response->payload)); + } + + public function authorizeQrLogin(string $qrLink): bool + { + $payload = new ApproveQrLoginPayload(['qrLink' => $qrLink]); + $this->app->invoke(Opcode::AUTH_QR_APPROVE, $payload->toArray()); + + return true; + } + + public function createAuthTrack(): string + { + $payload = new CreateAuthTrackPayload(); + $response = $this->app->invoke(Opcode::AUTH_CREATE_TRACK, $payload->toArray()); + $trackId = $response->payload['trackId'] ?? null; + if (!is_string($trackId) || $trackId === '') { + throw new PHPMaxException('Failed to create auth track'); + } + + return $trackId; + } + + public function setTwoFactor( + string $password, + ?string $email = null, + ?string $hint = null, + ?EmailCodeProviderInterface $emailCodeProvider = null + ): bool { + $trackId = $this->createAuthTrack(); + $this->setPassword($trackId, $password); + + $hasEmail = false; + $hasHint = false; + if ($email !== null) { + $this->setEmail($trackId, $email, $emailCodeProvider ?: new ConsoleEmailCodeProvider()); + $hasEmail = true; + } + if ($hint !== null) { + $this->setHint($trackId, $hint); + $hasHint = true; + } + + $expectedCapabilities = [TwoFactorAction::SET_PASSWORD]; + if ($hasHint) { + $expectedCapabilities[] = TwoFactorAction::HINT; + } + if ($hasEmail) { + $expectedCapabilities[] = TwoFactorAction::EMAIL; + } + + $payload = new SetTwoFactorPayload([ + 'expectedCapabilities' => $expectedCapabilities, + 'trackId' => $trackId, + 'password' => $password, + 'hint' => $hint, + ]); + $this->app->invoke(Opcode::AUTH_SET_2FA, $payload->toArray()); + + return true; + } + + public function removeTwoFactor(string $password): bool + { + $trackId = $this->createAuthTrack(); + $this->checkCurrentTwoFactorPassword($trackId, $password); + $payload = new RemoveTwoFactorPayload(['trackId' => $trackId]); + $this->app->invoke(Opcode::AUTH_SET_2FA, $payload->toArray()); + + return true; + } + + public function changePassword(string $passwordOld, string $passwordNew): bool + { + $trackId = $this->createAuthTrack(); + $this->checkCurrentTwoFactorPassword($trackId, $passwordOld); + $this->setPassword($trackId, $passwordNew); + $payload = new SetTwoFactorPayload([ + 'expectedCapabilities' => [TwoFactorAction::UPDATE_PASSWORD], + 'trackId' => $trackId, + 'password' => $passwordNew, + ]); + $this->app->invoke(Opcode::AUTH_SET_2FA, $payload->toArray()); + + return true; + } + + public function profileHasTwoFactor(?array $profileOptions): bool + { + if ($profileOptions === null || $profileOptions === []) { + return false; + } + + foreach ($profileOptions as $option) { + if ((int) $option === ProfileOptions::SECOND_FACTOR_PASSWORD_ENABLED) { + return true; + } + } + + return false; + } + + public function checkTwoFactor(): bool + { + $profile = $this->app->me(); + + return $this->profileHasTwoFactor($profile !== null ? $profile->profileOptions : null); + } + + private function setPassword(string $trackId, string $password): bool + { + $payload = new SetPasswordPayload([ + 'trackId' => $trackId, + 'password' => $password, + ]); + $this->app->invoke(Opcode::AUTH_VALIDATE_PASSWORD, $payload->toArray()); + + return true; + } + + private function checkCurrentTwoFactorPassword(string $trackId, string $password): bool + { + $payload = new SetPasswordPayload([ + 'trackId' => $trackId, + 'password' => $password, + ]); + $this->app->invoke(Opcode::AUTH_CHECK_PASSWORD, $payload->toArray()); + + return true; + } + + private function setHint(string $trackId, string $hint): bool + { + $payload = new SetHintPayload([ + 'trackId' => $trackId, + 'hint' => $hint, + ]); + $this->app->invoke(Opcode::AUTH_VALIDATE_HINT, $payload->toArray()); + + return true; + } + + private function setEmail(string $trackId, string $email, EmailCodeProviderInterface $provider): bool + { + $payload = new RequestEmailCodePayload([ + 'trackId' => $trackId, + 'email' => $email, + ]); + $this->app->invoke(Opcode::AUTH_VERIFY_EMAIL, $payload->toArray()); + + $code = $provider->getCode($email); + $confirmPayload = new SendEmailCodePayload([ + 'trackId' => $trackId, + 'verifyCode' => $code, + ]); + $this->app->invoke(Opcode::AUTH_CHECK_EMAIL, $confirmPayload->toArray()); + + return true; + } + + private function updateSession(LoginResponse $response): void + { + $session = $this->app->session(); + if ($session === null) { + return; + } + + $updated = new SessionInfo([ + 'token' => $session->token, + 'deviceId' => $session->deviceId, + 'phone' => $session->phone, + 'mtInstanceId' => $this->app->options()->mtInstanceId, + 'sync' => $response->updateSyncState($session->sync), + ]); + $this->app->setSession($updated); + $this->app->store()->saveSession($updated); + } + + /** + * @param array|null $payload + * @return array + */ + private function requirePayload(?array $payload): array + { + if ($payload === null || $payload === []) { + throw new PHPMaxException('Missing response payload'); + } + + return $payload; + } +} diff --git a/src/PHPMax/Api/Auth/AuthType.php b/src/PHPMax/Api/Auth/AuthType.php new file mode 100644 index 0000000..5f4e2f2 --- /dev/null +++ b/src/PHPMax/Api/Auth/AuthType.php @@ -0,0 +1,18 @@ + ['type' => 'string', 'required' => true], + 'password' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Auth/CheckQrPayload.php b/src/PHPMax/Api/Auth/CheckQrPayload.php new file mode 100644 index 0000000..698d2bc --- /dev/null +++ b/src/PHPMax/Api/Auth/CheckQrPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/ConfirmQrPayload.php b/src/PHPMax/Api/Auth/ConfirmQrPayload.php new file mode 100644 index 0000000..631504d --- /dev/null +++ b/src/PHPMax/Api/Auth/ConfirmQrPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/ConfirmRegistrationPayload.php b/src/PHPMax/Api/Auth/ConfirmRegistrationPayload.php new file mode 100644 index 0000000..9ba1481 --- /dev/null +++ b/src/PHPMax/Api/Auth/ConfirmRegistrationPayload.php @@ -0,0 +1,30 @@ + ['type' => 'string', 'required' => true], + 'lastName' => ['type' => 'string'], + 'token' => ['type' => 'string', 'required' => true], + 'tokenType' => ['type' => 'string', 'default' => AuthType::REGISTER], + ]; + } +} + diff --git a/src/PHPMax/Api/Auth/CreateAuthTrackPayload.php b/src/PHPMax/Api/Auth/CreateAuthTrackPayload.php new file mode 100644 index 0000000..1010583 --- /dev/null +++ b/src/PHPMax/Api/Auth/CreateAuthTrackPayload.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'default' => 0], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/Exp.php b/src/PHPMax/Api/Auth/Exp.php new file mode 100644 index 0000000..eaebe60 --- /dev/null +++ b/src/PHPMax/Api/Auth/Exp.php @@ -0,0 +1,23 @@ + ['type' => 'mixed', 'default' => static function (): BinaryString { + return new BinaryString("\x0a\x32"); + }], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/ProfileOptions.php b/src/PHPMax/Api/Auth/ProfileOptions.php new file mode 100644 index 0000000..74742ef --- /dev/null +++ b/src/PHPMax/Api/Auth/ProfileOptions.php @@ -0,0 +1,17 @@ + */ + public $expectedCapabilities = []; + + protected static function schema(): array + { + return [ + 'trackId' => ['type' => 'string', 'required' => true], + 'remove2fa' => ['type' => 'bool', 'default' => true], + 'expectedCapabilities' => ['type' => 'list', 'default' => static function (): array { + return [TwoFactorAction::REMOVE_2FA]; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/RequestCodePayload.php b/src/PHPMax/Api/Auth/RequestCodePayload.php new file mode 100644 index 0000000..c71cbff --- /dev/null +++ b/src/PHPMax/Api/Auth/RequestCodePayload.php @@ -0,0 +1,27 @@ + ['type' => 'string', 'required' => true], + 'type' => ['type' => 'string', 'default' => AuthType::START_AUTH], + 'language' => ['type' => 'string', 'default' => 'ru'], + ]; + } +} + diff --git a/src/PHPMax/Api/Auth/RequestEmailCodePayload.php b/src/PHPMax/Api/Auth/RequestEmailCodePayload.php new file mode 100644 index 0000000..cc2fd47 --- /dev/null +++ b/src/PHPMax/Api/Auth/RequestEmailCodePayload.php @@ -0,0 +1,23 @@ + ['type' => 'string', 'required' => true], + 'email' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/SendCodePayload.php b/src/PHPMax/Api/Auth/SendCodePayload.php new file mode 100644 index 0000000..9b3b381 --- /dev/null +++ b/src/PHPMax/Api/Auth/SendCodePayload.php @@ -0,0 +1,27 @@ + ['type' => 'string', 'required' => true], + 'verifyCode' => ['type' => 'string', 'required' => true], + 'authTokenType' => ['type' => 'string', 'default' => AuthType::CHECK_CODE], + ]; + } +} + diff --git a/src/PHPMax/Api/Auth/SendEmailCodePayload.php b/src/PHPMax/Api/Auth/SendEmailCodePayload.php new file mode 100644 index 0000000..e9fd080 --- /dev/null +++ b/src/PHPMax/Api/Auth/SendEmailCodePayload.php @@ -0,0 +1,23 @@ + ['type' => 'string', 'required' => true], + 'verifyCode' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/SetHintPayload.php b/src/PHPMax/Api/Auth/SetHintPayload.php new file mode 100644 index 0000000..1f883f8 --- /dev/null +++ b/src/PHPMax/Api/Auth/SetHintPayload.php @@ -0,0 +1,23 @@ + ['type' => 'string', 'required' => true], + 'hint' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/SetPasswordPayload.php b/src/PHPMax/Api/Auth/SetPasswordPayload.php new file mode 100644 index 0000000..3a88494 --- /dev/null +++ b/src/PHPMax/Api/Auth/SetPasswordPayload.php @@ -0,0 +1,23 @@ + ['type' => 'string', 'required' => true], + 'password' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/SetTwoFactorPayload.php b/src/PHPMax/Api/Auth/SetTwoFactorPayload.php new file mode 100644 index 0000000..1ce4b23 --- /dev/null +++ b/src/PHPMax/Api/Auth/SetTwoFactorPayload.php @@ -0,0 +1,31 @@ + */ + public $expectedCapabilities = []; + /** @var string|null */ + public $trackId; + /** @var string|null */ + public $password; + /** @var string|null */ + public $hint; + + protected static function schema(): array + { + return [ + 'expectedCapabilities' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'trackId' => ['type' => 'string', 'required' => true], + 'password' => ['type' => 'string', 'required' => true], + 'hint' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Api/Auth/SyncPayload.php b/src/PHPMax/Api/Auth/SyncPayload.php new file mode 100644 index 0000000..5f15d71 --- /dev/null +++ b/src/PHPMax/Api/Auth/SyncPayload.php @@ -0,0 +1,68 @@ + ['type' => MobileUserAgentPayload::class, 'required' => true], + 'token' => ['type' => 'string', 'required' => true], + 'chatHashFingerprint' => ['type' => 'string'], + 'chatsCount' => ['type' => 'int'], + 'chatsSync' => ['type' => 'int', 'default' => -1], + 'contactsSync' => ['type' => 'int', 'default' => -1], + 'draftsSync' => ['type' => 'int', 'default' => -1], + 'interactive' => ['type' => 'bool', 'default' => true], + 'presenceSync' => ['type' => 'int', 'default' => -1], + 'exp' => ['type' => Exp::class, 'default' => static function (): Exp { + return new Exp(); + }], + 'configHash' => ['type' => 'mixed', 'default' => SyncState::DEFAULT_CONFIG_HASH], + ]; + } + + public static function fromSyncState(MobileUserAgentPayload $userAgent, string $token, SyncState $sync): self + { + return new self([ + 'userAgent' => $userAgent, + 'token' => $token, + 'chatsSync' => $sync->chatsSync, + 'contactsSync' => $sync->contactsSync, + 'draftsSync' => $sync->draftsSync, + 'presenceSync' => $sync->presenceSync, + 'configHash' => $sync->configHash, + ]); + } +} + diff --git a/src/PHPMax/Api/Auth/TwoFactorAction.php b/src/PHPMax/Api/Auth/TwoFactorAction.php new file mode 100644 index 0000000..cbab83d --- /dev/null +++ b/src/PHPMax/Api/Auth/TwoFactorAction.php @@ -0,0 +1,19 @@ + ['type' => 'string', 'required' => true], + 'chatsCount' => ['type' => 'int', 'default' => 40], + 'interactive' => ['type' => 'bool', 'default' => true], + 'chatsSync' => ['type' => 'int', 'default' => -1], + 'contactsSync' => ['type' => 'int', 'default' => -1], + 'presenceSync' => ['type' => 'int', 'default' => -1], + 'draftsSync' => ['type' => 'int', 'default' => -1], + ]; + } + + public static function fromSyncState(string $token, SyncState $sync): self + { + return new self([ + 'token' => $token, + 'chatsSync' => $sync->chatsSync, + 'contactsSync' => $sync->contactsSync, + 'draftsSync' => $sync->draftsSync, + ]); + } +} + diff --git a/src/PHPMax/Api/Binding.php b/src/PHPMax/Api/Binding.php new file mode 100644 index 0000000..d9d9912 --- /dev/null +++ b/src/PHPMax/Api/Binding.php @@ -0,0 +1,93 @@ + $values + * @return list + */ + public static function bindApiModels(App $app, iterable $values): array + { + $result = []; + foreach ($values as $value) { + $result[] = self::bindApiModel($app, $value); + } + + return $result; + } + + /** + * @param mixed $value + * @param array $seen + */ + private static function bindValue(App $app, $value, array &$seen): void + { + if ($value === null || is_scalar($value)) { + return; + } + + if (is_array($value)) { + foreach ($value as $item) { + self::bindValue($app, $item, $seen); + } + return; + } + + if (!is_object($value)) { + return; + } + + $id = spl_object_id($value); + if (isset($seen[$id])) { + return; + } + $seen[$id] = true; + + if ($value instanceof Message) { + $value->bind($app->api()->messages); + } elseif ($value instanceof Chat) { + $value->bind($app->api()->messages, $app->api()->chats); + } elseif ($value instanceof User) { + $value->bind($app->api()->users); + } elseif ($value instanceof MessageDeleteEvent) { + $value->bind($app->api()->messages); + } + + foreach (get_object_vars($value) as $item) { + self::bindValue($app, $item, $seen); + } + + if ($value instanceof Model) { + foreach ($value->extra() as $item) { + self::bindValue($app, $item, $seen); + } + } + } +} diff --git a/src/PHPMax/Api/Bots/BotsService.php b/src/PHPMax/Api/Bots/BotsService.php new file mode 100644 index 0000000..5c644b1 --- /dev/null +++ b/src/PHPMax/Api/Bots/BotsService.php @@ -0,0 +1,46 @@ +app = $app; + } + + public function getInitData(int $botId, ?int $chatId = null, ?string $startParam = null): InitData + { + $payload = new RequestInitDataPayload([ + 'botId' => $botId, + 'chatId' => $chatId, + 'startParam' => $startParam, + ]); + $response = $this->app->invoke(Opcode::WEB_APP_INIT_DATA, $payload->toArray()); + + return InitData::fromArray($this->requireResponsePayload($response->payload)); + } + + /** + * @param array|null $payload + * @return array + */ + private function requireResponsePayload(?array $payload): array + { + if ($payload === null || $payload === []) { + throw new PHPMaxException('Missing payload in response'); + } + + return $payload; + } +} diff --git a/src/PHPMax/Api/Bots/RequestInitDataPayload.php b/src/PHPMax/Api/Bots/RequestInitDataPayload.php new file mode 100644 index 0000000..1050b44 --- /dev/null +++ b/src/PHPMax/Api/Bots/RequestInitDataPayload.php @@ -0,0 +1,27 @@ + ['type' => 'int', 'required' => true], + 'chatId' => ['type' => 'int'], + 'startParam' => ['type' => 'string'], + ]; + } +} + diff --git a/src/PHPMax/Api/Chats/ChangeGroupProfilePayload.php b/src/PHPMax/Api/Chats/ChangeGroupProfilePayload.php new file mode 100644 index 0000000..d547c1a --- /dev/null +++ b/src/PHPMax/Api/Chats/ChangeGroupProfilePayload.php @@ -0,0 +1,26 @@ + ['type' => 'int', 'required' => true], + 'theme' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/ChangeGroupSettingsOptions.php b/src/PHPMax/Api/Chats/ChangeGroupSettingsOptions.php new file mode 100644 index 0000000..428211f --- /dev/null +++ b/src/PHPMax/Api/Chats/ChangeGroupSettingsOptions.php @@ -0,0 +1,32 @@ + ['type' => 'bool', 'payload' => ChatOption::ONLY_OWNER_CAN_CHANGE_ICON_TITLE], + 'allCanPinMessage' => ['type' => 'bool', 'payload' => ChatOption::ALL_CAN_PIN_MESSAGE], + 'onlyAdminCanAddMember' => ['type' => 'bool', 'payload' => ChatOption::ONLY_ADMIN_CAN_ADD_MEMBER], + 'onlyAdminCanCall' => ['type' => 'bool', 'payload' => ChatOption::ONLY_ADMIN_CAN_CALL], + 'membersCanSeePrivateLink' => ['type' => 'bool', 'payload' => ChatOption::MEMBERS_CAN_SEE_PRIVATE_LINK], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/ChangeGroupSettingsPayload.php b/src/PHPMax/Api/Chats/ChangeGroupSettingsPayload.php new file mode 100644 index 0000000..ea8fcf8 --- /dev/null +++ b/src/PHPMax/Api/Chats/ChangeGroupSettingsPayload.php @@ -0,0 +1,23 @@ + ['type' => 'int', 'required' => true], + 'options' => ['type' => ChangeGroupSettingsOptions::class, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/ChatLinkPrefix.php b/src/PHPMax/Api/Chats/ChatLinkPrefix.php new file mode 100644 index 0000000..1aff09f --- /dev/null +++ b/src/PHPMax/Api/Chats/ChatLinkPrefix.php @@ -0,0 +1,14 @@ +app = $app; + $this->prevCid = (int) floor(microtime(true) * 1000); + } + + public function cacheExternalChat(Chat $chat): Chat + { + return $this->cacheChat($chat); + } + + /** + * @param list|null $participantIds + * @return array{0: Chat, 1: Message}|null + */ + public function createGroup(string $name, ?array $participantIds = null, bool $notify = true): ?array + { + $payload = new CreateGroupPayload([ + 'message' => new CreateGroupMessage([ + 'cid' => $this->nextCid(), + 'attaches' => [ + new CreateGroupAttach([ + 'title' => $name, + 'userIds' => $participantIds !== null ? $participantIds : [], + ]), + ], + ]), + 'notify' => $notify, + ]); + + $response = $this->app->invoke(Opcode::MSG_SEND, $payload->toArray()); + $chat = $this->parseChat($response->payload, ChatPayloadKey::CHAT); + if ($chat === null || $response->payload === null) { + return null; + } + + return [ + $this->cacheChat($chat), + Binding::bindApiModel($this->app, Message::fromArray($response->payload)), + ]; + } + + /** + * @param list $userIds + */ + public function inviteUsersToGroup(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + $payload = new InviteUsersPayload([ + 'chatId' => $chatId, + 'userIds' => $userIds, + 'showHistory' => $showHistory, + ]); + $response = $this->app->invoke(Opcode::CHAT_MEMBERS_UPDATE, $payload->toArray()); + + return $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + } + + /** + * @param list $userIds + */ + public function inviteUsersToChannel(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + return $this->inviteUsersToGroup($chatId, $userIds, $showHistory); + } + + /** + * @param list $userIds + */ + public function removeUsersFromGroup(int $chatId, array $userIds, int $cleanMsgPeriod): bool + { + $payload = new RemoveUsersPayload([ + 'chatId' => $chatId, + 'userIds' => $userIds, + 'cleanMsgPeriod' => $cleanMsgPeriod, + ]); + $response = $this->app->invoke(Opcode::CHAT_MEMBERS_UPDATE, $payload->toArray()); + $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + + return true; + } + + public function changeGroupSettings( + int $chatId, + ?bool $allCanPinMessage = null, + ?bool $onlyOwnerCanChangeIconTitle = null, + ?bool $onlyAdminCanAddMember = null, + ?bool $onlyAdminCanCall = null, + ?bool $membersCanSeePrivateLink = null + ): void { + $payload = new ChangeGroupSettingsPayload([ + 'chatId' => $chatId, + 'options' => new ChangeGroupSettingsOptions([ + 'allCanPinMessage' => $allCanPinMessage, + 'onlyOwnerCanChangeIconTitle' => $onlyOwnerCanChangeIconTitle, + 'onlyAdminCanAddMember' => $onlyAdminCanAddMember, + 'onlyAdminCanCall' => $onlyAdminCanCall, + 'membersCanSeePrivateLink' => $membersCanSeePrivateLink, + ]), + ]); + $response = $this->app->invoke(Opcode::CHAT_UPDATE, $payload->toArray()); + $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + } + + public function changeGroupProfile(int $chatId, ?string $name, ?string $description = null): void + { + $payload = new ChangeGroupProfilePayload([ + 'chatId' => $chatId, + 'theme' => $name, + 'description' => $description, + ]); + $response = $this->app->invoke(Opcode::CHAT_UPDATE, $payload->toArray()); + $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + } + + public function joinGroup(string $link): Chat + { + $processed = $this->processChatJoinLink($link); + if ($processed === null) { + throw new InvalidArgumentException('Invalid group link'); + } + + return $this->joinChat($processed); + } + + public function joinChannel(string $link): Chat + { + $processed = $this->processChatJoinLink($link); + + return $this->joinChat($processed !== null ? $processed : $link); + } + + public function resolveGroupByLink(string $link): ?Chat + { + $processed = $this->processChatJoinLink($link); + if ($processed === null) { + throw new InvalidArgumentException('Invalid group link'); + } + + $payload = new LinkInfoPayload(['link' => $processed]); + $response = $this->app->invoke(Opcode::LINK_INFO, $payload->toArray()); + + $chat = $this->parseChat($response->payload, ChatPayloadKey::CHAT); + + return $chat !== null ? Binding::bindApiModel($this->app, $chat) : null; + } + + public function reworkInviteLink(int $chatId): Chat + { + $payload = new ReworkInviteLinkPayload(['chatId' => $chatId]); + $response = $this->app->invoke(Opcode::CHAT_UPDATE, $payload->toArray()); + + return $this->cacheChat($this->requireChat($response->payload, ChatPayloadKey::CHAT)); + } + + /** + * @param list $chatIds + * @return list + */ + public function getChats(array $chatIds): array + { + $cached = []; + $missed = []; + foreach ($chatIds as $chatId) { + $chat = $this->app->cachedChat($chatId); + if ($chat !== null) { + $cached[$chatId] = $chat; + } else { + $missed[] = $chatId; + } + } + + if ($missed !== []) { + $payload = new GetChatInfoPayload(['chatIds' => $missed]); + $response = $this->app->invoke(Opcode::CHAT_INFO, $payload->toArray()); + foreach ($this->parseChats($response->payload, ChatPayloadKey::CHATS) as $chat) { + $chat = $this->cacheChat($chat); + if ($chat->id !== null) { + $cached[$chat->id] = $chat; + } + } + } + + $result = []; + foreach ($chatIds as $chatId) { + if (isset($cached[$chatId])) { + $result[] = $cached[$chatId]; + } + } + + return $result; + } + + public function getChat(int $chatId): Chat + { + $chats = $this->getChats([$chatId]); + if ($chats === []) { + throw new PHPMaxException('Chat not found in response'); + } + + return $chats[0]; + } + + public function leaveGroup(int $chatId): void + { + $payload = new LeaveChatPayload(['chatId' => $chatId]); + $this->app->invoke(Opcode::CHAT_LEAVE, $payload->toArray()); + $this->removeCachedChat($chatId); + } + + public function leaveChannel(int $chatId): void + { + $this->leaveGroup($chatId); + } + + /** + * @return list + */ + public function fetchChats(?int $marker = null): array + { + $payload = new FetchChatsPayload([ + 'marker' => $marker ? $marker : (int) floor(microtime(true) * 1000), + ]); + $response = $this->app->invoke(Opcode::CHATS_LIST, $payload->toArray()); + + $chats = []; + foreach ($this->parseChats($response->payload, ChatPayloadKey::CHATS) as $chat) { + $chats[] = $this->cacheChat($chat); + } + + return $chats; + } + + /** + * @return list + */ + public function getJoinRequests(int $chatId, int $count = 100): array + { + $payload = new FetchJoinRequests(['chatId' => $chatId, 'count' => $count]); + $response = $this->app->invoke(Opcode::CHAT_MEMBERS, $payload->toArray()); + $items = $response->payload[ChatPayloadKey::MEMBERS] ?? []; + if (!is_array($items)) { + return []; + } + + $members = []; + foreach ($items as $item) { + if (is_array($item)) { + $members[] = Binding::bindApiModel($this->app, Member::fromArray($item)); + } + } + + return $members; + } + + /** + * @param list $userIds + */ + public function confirmJoinRequests(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + $payload = new JoinRequestActionPayload([ + 'chatId' => $chatId, + 'userIds' => $userIds, + 'showHistory' => $showHistory, + 'operation' => ChatMemberOperation::ADD, + ]); + $response = $this->app->invoke(Opcode::CHAT_MEMBERS_UPDATE, $payload->toArray()); + + return $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + } + + public function confirmJoinRequest(int $chatId, int $userId, bool $showHistory = true): ?Chat + { + return $this->confirmJoinRequests($chatId, [$userId], $showHistory); + } + + /** + * @param list $userIds + */ + public function declineJoinRequests(int $chatId, array $userIds): ?Chat + { + $payload = new JoinRequestActionPayload([ + 'chatId' => $chatId, + 'userIds' => $userIds, + 'showHistory' => null, + 'operation' => ChatMemberOperation::REMOVE, + ]); + $response = $this->app->invoke(Opcode::CHAT_MEMBERS_UPDATE, $payload->toArray()); + + return $this->cacheNullableChat($this->parseChat($response->payload, ChatPayloadKey::CHAT)); + } + + public function declineJoinRequest(int $chatId, int $userId): ?Chat + { + return $this->declineJoinRequests($chatId, [$userId]); + } + + public function deleteChat(int $chatId, ?int $lastEventTime = null, bool $forAll = true): void + { + $payload = new DeleteChatPayload([ + 'chatId' => $chatId, + 'lastEventTime' => $lastEventTime !== null ? $lastEventTime : (int) floor(microtime(true) * 1000), + 'forAll' => $forAll, + ]); + $this->app->invoke(Opcode::CHAT_DELETE, $payload->toArray()); + $this->removeCachedChat($chatId); + } + + private function joinChat(string $link): Chat + { + $payload = new JoinChatPayload(['link' => $link]); + $response = $this->app->invoke(Opcode::CHAT_JOIN, $payload->toArray()); + + return $this->cacheChat($this->requireChat($response->payload, ChatPayloadKey::CHAT)); + } + + private function processChatJoinLink(string $link): ?string + { + $index = strpos($link, ChatLinkPrefix::JOIN); + if ($index === false) { + return null; + } + + return substr($link, $index); + } + + private function nextCid(): int + { + $now = (int) floor(microtime(true) * 1000); + $next = max($now, $this->prevCid + 1); + $this->prevCid = $next; + + return $next; + } + + private function cacheChat(Chat $chat): Chat + { + return $this->app->cacheChat($chat); + } + + private function cacheNullableChat(?Chat $chat): ?Chat + { + if ($chat === null) { + return null; + } + + return $this->cacheChat($chat); + } + + private function removeCachedChat(int $chatId): void + { + $this->app->removeCachedChat($chatId); + } + + /** + * @param array|null $payload + */ + private function parseChat(?array $payload, string $key): ?Chat + { + if ($payload === null || !isset($payload[$key]) || !is_array($payload[$key]) || $payload[$key] === []) { + return null; + } + + return Chat::fromArray($payload[$key]); + } + + /** + * @param array|null $payload + */ + private function requireChat(?array $payload, string $key): Chat + { + $chat = $this->parseChat($payload, $key); + if ($chat === null) { + throw new PHPMaxException('Chat not found in response'); + } + + return $chat; + } + + /** + * @param array|null $payload + * @return list + */ + private function parseChats(?array $payload, string $key): array + { + $items = $payload[$key] ?? null; + if ($items === null || $items === []) { + return []; + } + if (!is_array($items)) { + throw new PHPMaxException('Invalid chat list in response'); + } + + $chats = []; + foreach ($items as $item) { + if (!is_array($item)) { + throw new PHPMaxException('Invalid chat item in response'); + } + $chats[] = Chat::fromArray($item); + } + + return $chats; + } +} diff --git a/src/PHPMax/Api/Chats/ControlEvent.php b/src/PHPMax/Api/Chats/ControlEvent.php new file mode 100644 index 0000000..5a3f796 --- /dev/null +++ b/src/PHPMax/Api/Chats/ControlEvent.php @@ -0,0 +1,14 @@ + */ + public $userIds = []; + + protected static function schema(): array + { + return [ + 'type' => ['type' => 'string', 'payload' => '_type', 'default' => AttachmentType::CONTROL], + 'event' => ['type' => 'string', 'default' => ControlEvent::NEW], + 'chatType' => ['type' => 'string', 'default' => ChatType::CHAT], + 'title' => ['type' => 'string', 'required' => true], + 'userIds' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/CreateGroupMessage.php b/src/PHPMax/Api/Chats/CreateGroupMessage.php new file mode 100644 index 0000000..eb63193 --- /dev/null +++ b/src/PHPMax/Api/Chats/CreateGroupMessage.php @@ -0,0 +1,23 @@ + */ + public $attaches = []; + + protected static function schema(): array + { + return [ + 'cid' => ['type' => 'int', 'required' => true], + 'attaches' => ['type' => 'list<' . CreateGroupAttach::class . '>', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/CreateGroupPayload.php b/src/PHPMax/Api/Chats/CreateGroupPayload.php new file mode 100644 index 0000000..a7dc60b --- /dev/null +++ b/src/PHPMax/Api/Chats/CreateGroupPayload.php @@ -0,0 +1,23 @@ + ['type' => CreateGroupMessage::class, 'required' => true], + 'notify' => ['type' => 'bool', 'default' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/DeleteChatPayload.php b/src/PHPMax/Api/Chats/DeleteChatPayload.php new file mode 100644 index 0000000..a4a7e22 --- /dev/null +++ b/src/PHPMax/Api/Chats/DeleteChatPayload.php @@ -0,0 +1,26 @@ + ['type' => 'int', 'required' => true], + 'lastEventTime' => ['type' => 'int', 'required' => true], + 'forAll' => ['type' => 'bool', 'default' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/FetchChatsPayload.php b/src/PHPMax/Api/Chats/FetchChatsPayload.php new file mode 100644 index 0000000..b6bad3a --- /dev/null +++ b/src/PHPMax/Api/Chats/FetchChatsPayload.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/FetchJoinRequests.php b/src/PHPMax/Api/Chats/FetchJoinRequests.php new file mode 100644 index 0000000..4cfcb7a --- /dev/null +++ b/src/PHPMax/Api/Chats/FetchJoinRequests.php @@ -0,0 +1,26 @@ + ['type' => 'int', 'required' => true], + 'type' => ['type' => 'string', 'default' => 'JOIN_REQUEST'], + 'count' => ['type' => 'int', 'default' => 100], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/GetChatInfoPayload.php b/src/PHPMax/Api/Chats/GetChatInfoPayload.php new file mode 100644 index 0000000..3093219 --- /dev/null +++ b/src/PHPMax/Api/Chats/GetChatInfoPayload.php @@ -0,0 +1,20 @@ + */ + public $chatIds = []; + + protected static function schema(): array + { + return [ + 'chatIds' => ['type' => 'list', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/InviteUsersPayload.php b/src/PHPMax/Api/Chats/InviteUsersPayload.php new file mode 100644 index 0000000..70f5582 --- /dev/null +++ b/src/PHPMax/Api/Chats/InviteUsersPayload.php @@ -0,0 +1,29 @@ + */ + public $userIds = []; + /** @var bool|null */ + public $showHistory; + /** @var string */ + public $operation = ChatMemberOperation::ADD; + + protected static function schema(): array + { + return [ + 'chatId' => ['type' => 'int', 'required' => true], + 'userIds' => ['type' => 'list', 'required' => true], + 'showHistory' => ['type' => 'bool', 'required' => true], + 'operation' => ['type' => 'string', 'default' => ChatMemberOperation::ADD], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/JoinChatPayload.php b/src/PHPMax/Api/Chats/JoinChatPayload.php new file mode 100644 index 0000000..51e5e1f --- /dev/null +++ b/src/PHPMax/Api/Chats/JoinChatPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/JoinRequestActionPayload.php b/src/PHPMax/Api/Chats/JoinRequestActionPayload.php new file mode 100644 index 0000000..4e60344 --- /dev/null +++ b/src/PHPMax/Api/Chats/JoinRequestActionPayload.php @@ -0,0 +1,32 @@ + */ + public $userIds = []; + /** @var string */ + public $type = 'JOIN_REQUEST'; + /** @var bool|null */ + public $showHistory; + /** @var string|null */ + public $operation; + + protected static function schema(): array + { + return [ + 'chatId' => ['type' => 'int', 'required' => true], + 'userIds' => ['type' => 'list', 'required' => true], + 'type' => ['type' => 'string', 'default' => 'JOIN_REQUEST'], + 'showHistory' => ['type' => 'bool'], + 'operation' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/LeaveChatPayload.php b/src/PHPMax/Api/Chats/LeaveChatPayload.php new file mode 100644 index 0000000..7cc1fc5 --- /dev/null +++ b/src/PHPMax/Api/Chats/LeaveChatPayload.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/LinkInfoPayload.php b/src/PHPMax/Api/Chats/LinkInfoPayload.php new file mode 100644 index 0000000..7fa9e14 --- /dev/null +++ b/src/PHPMax/Api/Chats/LinkInfoPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/RemoveUsersPayload.php b/src/PHPMax/Api/Chats/RemoveUsersPayload.php new file mode 100644 index 0000000..d8aebf5 --- /dev/null +++ b/src/PHPMax/Api/Chats/RemoveUsersPayload.php @@ -0,0 +1,29 @@ + */ + public $userIds = []; + /** @var string */ + public $operation = ChatMemberOperation::REMOVE; + /** @var int|null */ + public $cleanMsgPeriod; + + protected static function schema(): array + { + return [ + 'chatId' => ['type' => 'int', 'required' => true], + 'userIds' => ['type' => 'list', 'required' => true], + 'operation' => ['type' => 'string', 'default' => ChatMemberOperation::REMOVE], + 'cleanMsgPeriod' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Chats/ReworkInviteLinkPayload.php b/src/PHPMax/Api/Chats/ReworkInviteLinkPayload.php new file mode 100644 index 0000000..73e8bd7 --- /dev/null +++ b/src/PHPMax/Api/Chats/ReworkInviteLinkPayload.php @@ -0,0 +1,23 @@ + ['type' => 'bool', 'default' => true], + 'chatId' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/AddReactionPayload.php b/src/PHPMax/Api/Messages/AddReactionPayload.php new file mode 100644 index 0000000..1e641b0 --- /dev/null +++ b/src/PHPMax/Api/Messages/AddReactionPayload.php @@ -0,0 +1,24 @@ + ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'string', 'required' => true], + 'reaction' => ['type' => ReactionInfoPayload::class, 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ChatHistoryPayload.php b/src/PHPMax/Api/Messages/ChatHistoryPayload.php new file mode 100644 index 0000000..ac0e085 --- /dev/null +++ b/src/PHPMax/Api/Messages/ChatHistoryPayload.php @@ -0,0 +1,38 @@ + ['type' => 'int', 'required' => true], + 'forward' => ['type' => 'int', 'required' => true], + 'backward' => ['type' => 'int', 'default' => 40], + 'backwardTime' => ['type' => 'int', 'default' => 0], + 'forwardTime' => ['type' => 'int', 'default' => 0], + 'getChat' => ['type' => 'bool', 'default' => false], + 'from' => ['type' => 'int', 'payload' => 'from', 'required' => true], + 'itemType' => ['type' => 'string', 'default' => ItemType::REGULAR], + 'getMessages' => ['type' => 'bool', 'default' => true], + 'interactive' => ['type' => 'bool', 'default' => false], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/DeleteMessagePayload.php b/src/PHPMax/Api/Messages/DeleteMessagePayload.php new file mode 100644 index 0000000..35d2b92 --- /dev/null +++ b/src/PHPMax/Api/Messages/DeleteMessagePayload.php @@ -0,0 +1,23 @@ + ['type' => 'int', 'required' => true], + 'messageIds' => ['type' => 'list', 'required' => true], + 'forMe' => ['type' => 'bool', 'default' => false], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/EditMessagePayload.php b/src/PHPMax/Api/Messages/EditMessagePayload.php new file mode 100644 index 0000000..d9137e1 --- /dev/null +++ b/src/PHPMax/Api/Messages/EditMessagePayload.php @@ -0,0 +1,36 @@ + */ + public $elements = []; + /** @var array */ + public $attachments = []; + + protected static function schema(): array + { + return [ + 'chatId' => ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'int', 'required' => true], + 'text' => ['type' => 'string', 'required' => true], + 'elements' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'attachments' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/ForwardLink.php b/src/PHPMax/Api/Messages/ForwardLink.php new file mode 100644 index 0000000..b571753 --- /dev/null +++ b/src/PHPMax/Api/Messages/ForwardLink.php @@ -0,0 +1,27 @@ + ['type' => 'string', 'default' => 'FORWARD'], + 'messageId' => ['type' => 'string', 'required' => true], + 'chatId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ForwardMessagePayload.php b/src/PHPMax/Api/Messages/ForwardMessagePayload.php new file mode 100644 index 0000000..67f6ccc --- /dev/null +++ b/src/PHPMax/Api/Messages/ForwardMessagePayload.php @@ -0,0 +1,27 @@ + ['type' => 'int', 'required' => true], + 'message' => ['type' => ForwardMessagePayloadMessage::class, 'required' => true], + 'notify' => ['type' => 'bool', 'default' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ForwardMessagePayloadMessage.php b/src/PHPMax/Api/Messages/ForwardMessagePayloadMessage.php new file mode 100644 index 0000000..666431e --- /dev/null +++ b/src/PHPMax/Api/Messages/ForwardMessagePayloadMessage.php @@ -0,0 +1,28 @@ + */ + public $attaches = []; + + protected static function schema(): array + { + return [ + 'cid' => ['type' => 'int', 'required' => true], + 'link' => ['type' => ForwardLink::class, 'required' => true], + 'attaches' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/GetFilePayload.php b/src/PHPMax/Api/Messages/GetFilePayload.php new file mode 100644 index 0000000..cf6431d --- /dev/null +++ b/src/PHPMax/Api/Messages/GetFilePayload.php @@ -0,0 +1,27 @@ + ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'mixed', 'required' => true], + 'fileId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/GetMessagesPayload.php b/src/PHPMax/Api/Messages/GetMessagesPayload.php new file mode 100644 index 0000000..97edbce --- /dev/null +++ b/src/PHPMax/Api/Messages/GetMessagesPayload.php @@ -0,0 +1,23 @@ + */ + public $messageIds = []; + + protected static function schema(): array + { + return [ + 'chatId' => ['type' => 'int', 'required' => true], + 'messageIds' => ['type' => 'list', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/GetReactionsPayload.php b/src/PHPMax/Api/Messages/GetReactionsPayload.php new file mode 100644 index 0000000..7b2d9ad --- /dev/null +++ b/src/PHPMax/Api/Messages/GetReactionsPayload.php @@ -0,0 +1,21 @@ + ['type' => 'int', 'required' => true], + 'messageIds' => ['type' => 'list', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/GetVideoPayload.php b/src/PHPMax/Api/Messages/GetVideoPayload.php new file mode 100644 index 0000000..7393234 --- /dev/null +++ b/src/PHPMax/Api/Messages/GetVideoPayload.php @@ -0,0 +1,27 @@ + ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'mixed', 'required' => true], + 'videoId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ItemType.php b/src/PHPMax/Api/Messages/ItemType.php new file mode 100644 index 0000000..7f5299f --- /dev/null +++ b/src/PHPMax/Api/Messages/ItemType.php @@ -0,0 +1,16 @@ +app = $app; + $this->prevCid = (int) floor(microtime(true) * 1000); + } + + /** + * @param array|null $attachments + */ + public function sendMessage(int $chatId, string $text, ?int $replyTo = null, ?array $attachments = null, bool $notify = true): ?Message + { + [$cleanText, $elements] = Formatter::formatMarkdown($text); + $payload = new SendMessagePayload([ + 'chatId' => $chatId, + 'message' => new SendMessagePayloadMessage([ + 'text' => $cleanText, + 'cid' => $this->nextCid(), + 'elements' => $elements, + 'attaches' => $this->normalizeAttachments($attachments), + 'link' => $replyTo !== null ? new ReplyLink(['messageId' => $replyTo]) : null, + ]), + 'notify' => $notify, + ]); + + $response = $this->app->invoke(Opcode::MSG_SEND, $payload->toArray()); + + return Binding::bindApiModel($this->app, Message::fromArray($this->requireResponsePayload($response->payload))); + } + + public function forwardMessage(int $chatId, $messageId, ?int $sourceChatId = null, bool $notify = true): ?Message + { + $sourceChatId = $sourceChatId !== null ? $sourceChatId : $chatId; + $payload = new ForwardMessagePayload([ + 'chatId' => $chatId, + 'message' => new ForwardMessagePayloadMessage([ + 'cid' => -$this->nextCid(), + 'link' => new ForwardLink([ + 'messageId' => (string) $messageId, + 'chatId' => $sourceChatId, + ]), + ]), + 'notify' => $notify, + ]); + + $response = $this->app->invoke(Opcode::MSG_SEND, $payload->toArray()); + + return Binding::bindApiModel($this->app, Message::fromArray($this->requireResponsePayload($response->payload))); + } + + /** + * @param list $messageIds + * @return list + */ + public function getMessages(int $chatId, array $messageIds): array + { + $payload = new GetMessagesPayload([ + 'chatId' => $chatId, + 'messageIds' => $messageIds, + ]); + $response = $this->app->invoke(Opcode::MSG_GET, $payload->toArray()); + + $messages = []; + foreach ($this->parseMessageList($response->payload) as $item) { + if (!isset($item['chatId'])) { + $item['chatId'] = $chatId; + } + $messages[] = Binding::bindApiModel($this->app, Message::fromArray($item)); + } + + return $messages; + } + + public function getMessage(int $chatId, int $messageId): ?Message + { + $messages = $this->getMessages($chatId, [$messageId]); + + return $messages[0] ?? null; + } + + /** + * @param array|null $attachments + */ + public function editMessage(int $chatId, int $messageId, string $text, ?array $attachments = null): Message + { + [$cleanText, $elements] = Formatter::formatMarkdown($text); + $payload = new EditMessagePayload([ + 'chatId' => $chatId, + 'messageId' => $messageId, + 'text' => $cleanText, + 'elements' => $elements, + 'attachments' => $this->normalizeAttachments($attachments), + ]); + $response = $this->app->invoke(Opcode::MSG_EDIT, $payload->toArray()); + $message = $this->requireResponsePayloadItem($response->payload, MessagePayloadKey::MESSAGE); + if (!isset($message['chatId'])) { + $message['chatId'] = $chatId; + } + + return Binding::bindApiModel($this->app, Message::fromArray($message)); + } + + /** + * @return list|null + */ + public function fetchHistory( + int $chatId, + int $forward = 0, + int $backward = 40, + int $backwardTime = 0, + int $forwardTime = 0, + ?int $from = null, + string $itemType = ItemType::REGULAR, + bool $getChat = false, + bool $getMessages = true, + bool $interactive = false + ): ?array { + $payload = new ChatHistoryPayload([ + 'chatId' => $chatId, + 'forward' => $forward, + 'backward' => $backward, + 'backwardTime' => $backwardTime, + 'forwardTime' => $forwardTime, + 'from' => $from !== null ? $from : (int) floor(microtime(true) * 1000), + 'itemType' => $itemType, + 'getChat' => $getChat, + 'getMessages' => $getMessages, + 'interactive' => $interactive, + ]); + $response = $this->app->invoke(Opcode::CHAT_HISTORY, $payload->toArray()); + $items = $this->parseMessageList($response->payload); + if ($items === []) { + return null; + } + $messages = []; + foreach ($items as $item) { + $messages[] = Binding::bindApiModel($this->app, Message::fromArray($item)); + } + + return $messages !== [] ? $messages : null; + } + + /** + * @param list $messageIds + */ + public function deleteMessage(int $chatId, array $messageIds, bool $forMe): bool + { + $payload = new DeleteMessagePayload([ + 'chatId' => $chatId, + 'messageIds' => $messageIds, + 'forMe' => $forMe, + ]); + $this->app->invoke(Opcode::MSG_DELETE, $payload->toArray()); + + return true; + } + + public function pinMessage(int $chatId, int $messageId, bool $notifyPin): bool + { + $payload = new PinMessagePayload([ + 'chatId' => $chatId, + 'notifyPin' => $notifyPin, + 'pinMessageId' => $messageId, + ]); + $this->app->invoke(Opcode::CHAT_UPDATE, $payload->toArray()); + + return true; + } + + public function getVideoById(int $chatId, $messageId, int $videoId): ?VideoRequest + { + $payload = new GetVideoPayload([ + 'chatId' => $chatId, + 'messageId' => $messageId, + 'videoId' => $videoId, + ]); + $response = $this->app->invoke(Opcode::VIDEO_PLAY, $payload->toArray()); + if ($response->payload === null || $response->payload === []) { + return null; + } + + return VideoRequest::fromArray($response->payload); + } + + public function getFileById(int $chatId, $messageId, int $fileId): ?FileRequest + { + $payload = new GetFilePayload([ + 'chatId' => $chatId, + 'messageId' => $messageId, + 'fileId' => $fileId, + ]); + $response = $this->app->invoke(Opcode::FILE_DOWNLOAD, $payload->toArray()); + if ($response->payload === null || $response->payload === []) { + return null; + } + + return FileRequest::fromArray($response->payload); + } + + public function addReaction(int $chatId, string $messageId, string $reaction): ?ReactionInfo + { + $payload = new AddReactionPayload([ + 'chatId' => $chatId, + 'messageId' => $messageId, + 'reaction' => new ReactionInfoPayload(['id' => $reaction]), + ]); + $response = $this->app->invoke(Opcode::MSG_REACTION, $payload->toArray()); + + return $this->parseOptionalReactionInfo($response->payload); + } + + /** + * @param list $messageIds + * @return array|null + */ + public function getReactions(int $chatId, array $messageIds): ?array + { + $payload = new GetReactionsPayload([ + 'chatId' => $chatId, + 'messageIds' => $messageIds, + ]); + $response = $this->app->invoke(Opcode::MSG_GET_REACTIONS, $payload->toArray()); + $items = $this->parseReactionInfoMap($response->payload); + if ($items === null) { + return null; + } + $result = []; + foreach ($items as $messageId => $data) { + $result[(string) $messageId] = ReactionInfo::fromArray($data); + } + + return $result; + } + + public function removeReaction(int $chatId, string $messageId): ?ReactionInfo + { + $payload = new RemoveReactionPayload([ + 'chatId' => $chatId, + 'messageId' => $messageId, + ]); + $response = $this->app->invoke(Opcode::MSG_CANCEL_REACTION, $payload->toArray()); + + return $this->parseOptionalReactionInfo($response->payload); + } + + public function readMessage($messageId, int $chatId): ReadState + { + $payload = new ReadMessagesPayload([ + 'type' => ReadAction::READ_MESSAGE, + 'chatId' => $chatId, + 'messageId' => $messageId, + 'mark' => (int) floor(microtime(true) * 1000), + ]); + $response = $this->app->invoke(Opcode::CHAT_MARK, $payload->toArray()); + + return ReadState::fromArray($this->requireResponsePayload($response->payload)); + } + + private function nextCid(): int + { + $now = (int) floor(microtime(true) * 1000); + $next = max($now, $this->prevCid + 1); + $this->prevCid = $next; + + return $next; + } + + /** + * @param array|null $payload + * @return array + */ + private function requireResponsePayload(?array $payload): array + { + if ($payload === null || $payload === []) { + throw new PHPMaxException('Missing payload in response'); + } + + return $payload; + } + + /** + * @param array|null $payload + * @return array + */ + private function requireResponsePayloadItem(?array $payload, string $key): array + { + $payload = $this->requireResponsePayload($payload); + $item = $payload[$key] ?? null; + if (!is_array($item) || $item === []) { + throw new PHPMaxException('Missing payload item in response: ' . $key); + } + + return $item; + } + + /** + * @param array|null $payload + * @return list> + */ + private function parseMessageList(?array $payload): array + { + $items = $payload[MessagePayloadKey::MESSAGES] ?? null; + if ($items === null || $items === []) { + return []; + } + if (!is_array($items) || !$this->isList($items)) { + throw new PHPMaxException('Invalid messages list in response'); + } + + $result = []; + foreach ($items as $item) { + if (!is_array($item)) { + throw new PHPMaxException('Invalid message item in response'); + } + $result[] = $item; + } + + return $result; + } + + private function parseOptionalReactionInfo(?array $payload): ?ReactionInfo + { + $info = $payload[MessagePayloadKey::REACTION_INFO] ?? null; + if ($info === null || $info === []) { + return null; + } + if (!is_array($info)) { + throw new PHPMaxException('Invalid reactionInfo in response'); + } + + return ReactionInfo::fromArray($info); + } + + /** + * @param array|null $payload + * @return array>|null + */ + private function parseReactionInfoMap(?array $payload): ?array + { + $items = $payload[MessagePayloadKey::MESSAGES_REACTIONS] ?? null; + if ($items === null) { + return null; + } + if (!is_array($items)) { + throw new PHPMaxException('Invalid messagesReactions in response'); + } + if ($items !== [] && $this->isList($items)) { + throw new PHPMaxException('Invalid messagesReactions map in response'); + } + + $result = []; + foreach ($items as $messageId => $data) { + if (!is_array($data)) { + throw new PHPMaxException('Invalid reaction info item in response'); + } + $result[(string) $messageId] = $data; + } + + return $result; + } + + /** + * @param array $payload + */ + private function isList(array $payload): bool + { + $expected = 0; + foreach (array_keys($payload) as $key) { + if ($key !== $expected) { + return false; + } + $expected++; + } + + return true; + } + + /** + * @param array|null $attachments + * @return list> + */ + private function normalizeAttachments(?array $attachments): array + { + if ($attachments === null) { + return []; + } + $result = []; + foreach ($attachments as $attachment) { + if ($attachment instanceof Photo) { + $result[] = $this->app->api()->uploads->uploadPhoto($attachment)->toArray(); + } elseif ($attachment instanceof Video) { + $result[] = $this->app->api()->uploads->uploadVideo($attachment)->toArray(); + } elseif ($attachment instanceof File) { + $result[] = $this->app->api()->uploads->uploadFile($attachment)->toArray(); + } elseif ($attachment instanceof Model) { + $result[] = $attachment->toArray(); + } elseif (is_array($attachment)) { + $result[] = $attachment; + } + } + + return $result; + } +} diff --git a/src/PHPMax/Api/Messages/PinMessagePayload.php b/src/PHPMax/Api/Messages/PinMessagePayload.php new file mode 100644 index 0000000..96c3629 --- /dev/null +++ b/src/PHPMax/Api/Messages/PinMessagePayload.php @@ -0,0 +1,26 @@ + ['type' => 'int', 'required' => true], + 'notifyPin' => ['type' => 'bool', 'required' => true], + 'pinMessageId' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Messages/ReactionInfoPayload.php b/src/PHPMax/Api/Messages/ReactionInfoPayload.php new file mode 100644 index 0000000..0175476 --- /dev/null +++ b/src/PHPMax/Api/Messages/ReactionInfoPayload.php @@ -0,0 +1,22 @@ + ['type' => 'string', 'default' => 'EMOJI'], + 'id' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ReadAction.php b/src/PHPMax/Api/Messages/ReadAction.php new file mode 100644 index 0000000..345e1bb --- /dev/null +++ b/src/PHPMax/Api/Messages/ReadAction.php @@ -0,0 +1,16 @@ + ['type' => 'string', 'required' => true], + 'chatId' => ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'mixed', 'required' => true], + 'mark' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/RemoveReactionPayload.php b/src/PHPMax/Api/Messages/RemoveReactionPayload.php new file mode 100644 index 0000000..778dd26 --- /dev/null +++ b/src/PHPMax/Api/Messages/RemoveReactionPayload.php @@ -0,0 +1,22 @@ + ['type' => 'int', 'required' => true], + 'messageId' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/ReplyLink.php b/src/PHPMax/Api/Messages/ReplyLink.php new file mode 100644 index 0000000..ccfe60e --- /dev/null +++ b/src/PHPMax/Api/Messages/ReplyLink.php @@ -0,0 +1,24 @@ + ['type' => 'string', 'default' => 'REPLY'], + 'messageId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/SendMessagePayload.php b/src/PHPMax/Api/Messages/SendMessagePayload.php new file mode 100644 index 0000000..4a172e5 --- /dev/null +++ b/src/PHPMax/Api/Messages/SendMessagePayload.php @@ -0,0 +1,27 @@ + ['type' => 'int', 'required' => true], + 'message' => ['type' => SendMessagePayloadMessage::class, 'required' => true], + 'notify' => ['type' => 'bool', 'default' => false], + ]; + } +} + diff --git a/src/PHPMax/Api/Messages/SendMessagePayloadMessage.php b/src/PHPMax/Api/Messages/SendMessagePayloadMessage.php new file mode 100644 index 0000000..d18e010 --- /dev/null +++ b/src/PHPMax/Api/Messages/SendMessagePayloadMessage.php @@ -0,0 +1,36 @@ + */ + public $elements = []; + /** @var array */ + public $attaches = []; + /** @var ReplyLink|null */ + public $link; + + protected static function schema(): array + { + return [ + 'text' => ['type' => 'string', 'required' => true], + 'cid' => ['type' => 'int', 'required' => true], + 'elements' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'attaches' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'link' => ['type' => ReplyLink::class], + ]; + } +} diff --git a/src/PHPMax/Api/Session/DeviceType.php b/src/PHPMax/Api/Session/DeviceType.php new file mode 100644 index 0000000..2b8a411 --- /dev/null +++ b/src/PHPMax/Api/Session/DeviceType.php @@ -0,0 +1,18 @@ + ['type' => 'string', 'payload' => 'mt_instanceid', 'required' => true], + 'userAgent' => ['type' => MobileUserAgentPayload::class, 'required' => true], + 'clientSessionId' => ['type' => 'int', 'default' => 1], + 'deviceId' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Session/MobileUserAgentPayload.php b/src/PHPMax/Api/Session/MobileUserAgentPayload.php new file mode 100644 index 0000000..1881694 --- /dev/null +++ b/src/PHPMax/Api/Session/MobileUserAgentPayload.php @@ -0,0 +1,205 @@ + ['type' => 'string', 'required' => true], + 'appVersion' => ['type' => 'string', 'required' => true], + 'osVersion' => ['type' => 'string', 'required' => true], + 'timezone' => ['type' => 'string', 'required' => true], + 'screen' => ['type' => 'string', 'required' => true], + 'pushDeviceType' => ['type' => 'string'], + 'arch' => ['type' => 'string'], + 'locale' => ['type' => 'string', 'required' => true], + 'buildNumber' => ['type' => 'int'], + 'deviceName' => ['type' => 'string', 'required' => true], + 'deviceLocale' => ['type' => 'string', 'required' => true], + 'release' => ['type' => 'int'], + 'headerUserAgent' => ['type' => 'string'], + ]; + } + + public static function defaultAndroid(): self + { + return self::randomAndroid(); + } + + public static function randomAndroid(): self + { + $app = self::pick(self::APP_VERSIONS); + $device = self::pick(self::ANDROID_DEVICES); + $locale = self::pick(self::LOCALE_TIMEZONES); + + return new self([ + 'deviceType' => DeviceType::ANDROID, + 'appVersion' => $app[0], + 'osVersion' => $device[1], + 'timezone' => $locale[1], + 'screen' => $device[2], + 'pushDeviceType' => 'GCM', + 'arch' => $device[3], + 'locale' => $locale[0], + 'buildNumber' => $app[1], + 'deviceName' => $device[0], + 'deviceLocale' => $locale[0], + ]); + } + + public static function defaultWeb(): self + { + return self::randomWeb(); + } + + public static function randomWeb(): self + { + $locale = self::pick(self::LOCALE_TIMEZONES); + + return new self([ + 'deviceType' => DeviceType::WEB, + 'appVersion' => self::WEB_APP_VERSION, + 'osVersion' => 'Linux', + 'timezone' => $locale[1], + 'screen' => self::WEB_SCREEN, + 'locale' => $locale[0], + 'deviceName' => 'Chrome', + 'deviceLocale' => $locale[0], + 'headerUserAgent' => self::DEFAULT_WEB_HEADER_USER_AGENT, + ]); + } + + /** + * @return array + */ + public function toWebPayload(): array + { + $payload = $this->toArray(true); + if ($this->deviceType === DeviceType::WEB && !isset($payload['headerUserAgent'])) { + $payload['headerUserAgent'] = self::DEFAULT_WEB_HEADER_USER_AGENT; + } + + $aliases = [ + 'deviceType', + 'locale', + 'deviceLocale', + 'osVersion', + 'deviceName', + 'headerUserAgent', + 'appVersion', + 'screen', + 'timezone', + ]; + $result = []; + foreach ($aliases as $alias) { + if (array_key_exists($alias, $payload)) { + $result[$alias] = $payload[$alias]; + } + } + + return $result; + } + + /** + * @param non-empty-list> $items + * @return array + */ + private static function pick(array $items): array + { + return $items[random_int(0, count($items) - 1)]; + } +} diff --git a/src/PHPMax/Api/Session/SessionService.php b/src/PHPMax/Api/Session/SessionService.php new file mode 100644 index 0000000..1e03435 --- /dev/null +++ b/src/PHPMax/Api/Session/SessionService.php @@ -0,0 +1,52 @@ +app = $app; + } + + public function handshake(string $mtInstanceId, MobileUserAgentPayload $userAgent, string $deviceId): void + { + if ($userAgent->deviceType === DeviceType::WEB) { + $this->webHandshake($userAgent, $deviceId); + return; + } + + $this->mobileHandshake($mtInstanceId, $userAgent, $deviceId); + } + + public function mobileHandshake(string $mtInstanceId, MobileUserAgentPayload $userAgent, string $deviceId): void + { + $payload = new MobileHandshakePayload([ + 'mtInstanceId' => $mtInstanceId, + 'userAgent' => $userAgent, + 'clientSessionId' => $this->app->options()->clientSessionId, + 'deviceId' => $deviceId, + ]); + + $this->app->invoke(Opcode::SESSION_INIT, $payload->toArray()); + } + + public function webHandshake(MobileUserAgentPayload $userAgent, string $deviceId): void + { + $payload = new WebHandshakePayload([ + 'userAgent' => $userAgent, + 'deviceId' => $deviceId, + ]); + + $this->app->invoke(Opcode::SESSION_INIT, $payload->toArray()); + } +} + diff --git a/src/PHPMax/Api/Session/WebHandshakePayload.php b/src/PHPMax/Api/Session/WebHandshakePayload.php new file mode 100644 index 0000000..ad0bd01 --- /dev/null +++ b/src/PHPMax/Api/Session/WebHandshakePayload.php @@ -0,0 +1,34 @@ + ['type' => MobileUserAgentPayload::class, 'required' => true], + 'deviceId' => ['type' => 'string', 'required' => true], + ]; + } + + public function toArray(bool $excludeNull = true): array + { + return [ + 'userAgent' => $this->userAgent instanceof MobileUserAgentPayload + ? $this->userAgent->toWebPayload() + : [], + 'deviceId' => $this->deviceId, + ]; + } +} + diff --git a/src/PHPMax/Api/Telemetry/NavigationPlanner.php b/src/PHPMax/Api/Telemetry/NavigationPlanner.php new file mode 100644 index 0000000..dc88888 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/NavigationPlanner.php @@ -0,0 +1,101 @@ + */ + private $history; + + public function __construct(?NavigationRules $rules = null) + { + $this->rules = $rules ?: NavigationRules::defaults(); + $this->currentScreen = Screen::BACKGROUND; + $this->history = []; + } + + public function newProfile(): RouteProfile + { + return $this->rules->chooseProfile(); + } + + public function nextScreen(RouteProfile $profile): int + { + if ($this->history !== [] && $this->chance($profile->backChance)) { + $this->currentScreen = array_pop($this->history); + + return $this->currentScreen; + } + + $nextScreen = $this->weightedChoice($this->rules->transitionsFor($this->currentScreen)); + if ($nextScreen !== $this->currentScreen) { + $this->history[] = $this->currentScreen; + if (count($this->history) > 4) { + array_shift($this->history); + } + } + + $this->currentScreen = $nextScreen; + + return $nextScreen; + } + + public function resetToBackground(): void + { + $this->currentScreen = Screen::BACKGROUND; + $this->history = []; + } + + public function currentScreen(): int + { + return $this->currentScreen; + } + + /** + * @return list + */ + public function history(): array + { + return $this->history; + } + + /** + * @param list $transitions + */ + private function weightedChoice(array $transitions): int + { + $total = 0; + foreach ($transitions as $transition) { + $total += $transition->weight; + } + + $point = random_int(1, $total); + $current = 0; + foreach ($transitions as $transition) { + $current += $transition->weight; + if ($point <= $current) { + return $transition->screen; + } + } + + return $transitions[count($transitions) - 1]->screen; + } + + private function chance(float $chance): bool + { + if ($chance <= 0.0) { + return false; + } + if ($chance >= 1.0) { + return true; + } + + return random_int(1, 1000000) <= (int) floor($chance * 1000000); + } +} diff --git a/src/PHPMax/Api/Telemetry/NavigationRules.php b/src/PHPMax/Api/Telemetry/NavigationRules.php new file mode 100644 index 0000000..e063521 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/NavigationRules.php @@ -0,0 +1,121 @@ + */ + private $profiles; + /** @var array> */ + private $graph; + + /** + * @param array $profiles + * @param array> $graph + */ + public function __construct(array $profiles, array $graph) + { + if ($profiles === []) { + throw new InvalidArgumentException('Navigation rules require at least one route profile'); + } + + foreach ($profiles as $name => $profile) { + if (!$profile instanceof RouteProfile) { + throw new InvalidArgumentException('Navigation profile `' . (string) $name . '` must be RouteProfile'); + } + } + + foreach ($graph as $screen => $transitions) { + if ($transitions === []) { + throw new InvalidArgumentException('Navigation graph screen `' . (string) $screen . '` has no transitions'); + } + foreach ($transitions as $transition) { + if (!$transition instanceof ScreenTransition) { + throw new InvalidArgumentException('Navigation graph transitions must be ScreenTransition instances'); + } + } + } + + $this->profiles = $profiles; + $this->graph = $graph; + } + + public static function defaults(): self + { + return new self( + [ + 'quick' => new RouteProfile(2, 35.0, 95.0, 0.05, 180.0, 420.0, 0.30), + 'browse' => new RouteProfile(4, 70.0, 210.0, 0.12, 240.0, 720.0, 0.22), + 'read' => new RouteProfile(3, 140.0, 360.0, 0.25, 420.0, 1200.0, 0.18), + ], + [ + Screen::BACKGROUND => [ + new ScreenTransition(Screen::CHATS, 10), + new ScreenTransition(Screen::SETTINGS, 1), + ], + Screen::CHATS => [ + new ScreenTransition(Screen::CHAT, 7), + new ScreenTransition(Screen::CONTACTS, 2), + new ScreenTransition(Screen::SEARCH, 2), + new ScreenTransition(Screen::CALLS, 1), + new ScreenTransition(Screen::SETTINGS, 1), + new ScreenTransition(Screen::CHATS, 2), + ], + Screen::CHAT => [ + new ScreenTransition(Screen::CHATS, 8), + new ScreenTransition(Screen::CHAT, 2), + new ScreenTransition(Screen::SETTINGS, 1), + ], + Screen::CONTACTS => [ + new ScreenTransition(Screen::CHATS, 6), + new ScreenTransition(Screen::CHAT, 2), + new ScreenTransition(Screen::SEARCH, 1), + ], + Screen::SEARCH => [ + new ScreenTransition(Screen::CHATS, 5), + new ScreenTransition(Screen::CHAT, 3), + new ScreenTransition(Screen::CONTACTS, 1), + ], + Screen::CALLS => [ + new ScreenTransition(Screen::CHATS, 5), + new ScreenTransition(Screen::CONTACTS, 2), + new ScreenTransition(Screen::SETTINGS, 2), + ], + Screen::SETTINGS => [ + new ScreenTransition(Screen::CHATS, 7), + new ScreenTransition(Screen::CONTACTS, 2), + new ScreenTransition(Screen::CALLS, 2), + new ScreenTransition(Screen::MINIAPP, 1), + ], + Screen::MINIAPP => [ + new ScreenTransition(Screen::SETTINGS, 3), + new ScreenTransition(Screen::CHATS, 6), + ], + ] + ); + } + + public function chooseProfile(): RouteProfile + { + $names = array_keys($this->profiles); + $index = random_int(0, count($names) - 1); + + return $this->profiles[$names[$index]]; + } + + /** + * @return list + */ + public function transitionsFor(int $screen): array + { + if (!isset($this->graph[$screen])) { + throw new InvalidArgumentException('Navigation graph has no transitions for screen=' . $screen); + } + + return $this->graph[$screen]; + } +} diff --git a/src/PHPMax/Api/Telemetry/RouteProfile.php b/src/PHPMax/Api/Telemetry/RouteProfile.php new file mode 100644 index 0000000..2faad9e --- /dev/null +++ b/src/PHPMax/Api/Telemetry/RouteProfile.php @@ -0,0 +1,47 @@ +steps = $steps; + $this->minPause = $minPause; + $this->maxPause = $maxPause; + $this->longPauseChance = max(0.0, min(1.0, $longPauseChance)); + $this->minLongPause = $minLongPause; + $this->maxLongPause = $maxLongPause; + $this->backChance = max(0.0, min(1.0, $backChance)); + } +} diff --git a/src/PHPMax/Api/Telemetry/Screen.php b/src/PHPMax/Api/Telemetry/Screen.php new file mode 100644 index 0000000..978bd03 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/Screen.php @@ -0,0 +1,21 @@ +screen = $screen; + $this->weight = $weight; + } +} diff --git a/src/PHPMax/Api/Telemetry/TelemetryEvent.php b/src/PHPMax/Api/Telemetry/TelemetryEvent.php new file mode 100644 index 0000000..7ab53c2 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/TelemetryEvent.php @@ -0,0 +1,38 @@ + */ + public $params = []; + /** @var int|null */ + public $sessionId; + + protected static function schema(): array + { + return [ + 'time' => ['type' => 'int', 'required' => true], + 'userId' => ['type' => 'int', 'required' => true], + 'type' => ['type' => 'string', 'required' => true], + 'event' => ['type' => 'string', 'required' => true], + 'params' => ['type' => 'array', 'default' => static function (): array { + return []; + }], + 'sessionId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Telemetry/TelemetryPayload.php b/src/PHPMax/Api/Telemetry/TelemetryPayload.php new file mode 100644 index 0000000..e115734 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/TelemetryPayload.php @@ -0,0 +1,23 @@ + */ + public $events = []; + + protected static function schema(): array + { + return [ + 'events' => ['type' => 'list<' . TelemetryEvent::class . '>', 'default' => static function (): array { + return []; + }], + ]; + } +} + diff --git a/src/PHPMax/Api/Telemetry/TelemetryPayloadBuilder.php b/src/PHPMax/Api/Telemetry/TelemetryPayloadBuilder.php new file mode 100644 index 0000000..a993da5 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/TelemetryPayloadBuilder.php @@ -0,0 +1,118 @@ + self::nowMs(), + 'userId' => $userId, + 'type' => 'PERF', + 'event' => 'login', + 'params' => [ + 'properties' => [ + 'connection_type' => 2, + 'vpn' => 0, + 'class' => 2, + 'background' => 1, + 'warm_start' => 1, + ], + 'errorType' => 100, + ], + 'sessionId' => $sessionId, + ]); + } + + /** + * @param array $extraParams + */ + public function navigation( + int $userId, + int $sessionId, + int $screenFrom, + int $screenTo, + int $prevTime, + int $actionId, + array $extraParams = [] + ): TelemetryEvent { + return new TelemetryEvent([ + 'time' => self::nowMs(), + 'userId' => $userId, + 'type' => 'NAV', + 'event' => 'GO', + 'params' => array_merge([ + 'prev_time' => $prevTime, + 'screen_to' => $screenTo, + 'action_id' => $actionId, + 'screen_from' => $screenFrom, + ], $extraParams), + 'sessionId' => $sessionId, + ]); + } + + public function openChat(int $userId, int $sessionId): TelemetryEvent + { + $messages = random_int(60, 240); + $render = random_int(50, 260); + + return new TelemetryEvent([ + 'time' => self::nowMs(), + 'userId' => $userId, + 'type' => 'PERF', + 'event' => 'open_chat_to_render', + 'params' => [ + 'spans' => [ + ['duration' => $messages + $render, 'name' => 'open_chat_to_render'], + ['duration' => $messages, 'name' => 'messages_list_created'], + ['duration' => $render, 'name' => 'messages_render'], + ], + 'properties' => [ + 'class' => 2, + 'warm' => 1, + 'flow' => 1, + ], + ], + 'sessionId' => $sessionId, + ]); + } + + public function openChats(int $userId, int $sessionId): TelemetryEvent + { + $created = random_int(50, 230); + $rendered = random_int(180, 650); + + return new TelemetryEvent([ + 'time' => self::nowMs(), + 'userId' => $userId, + 'type' => 'PERF', + 'event' => 'open_chats_to_render', + 'params' => [ + 'spans' => [ + ['duration' => $created + $rendered, 'name' => 'open_chats_to_render'], + ['duration' => $created, 'name' => 'chats_tab_created'], + ['duration' => $rendered, 'name' => 'chat_list_render'], + ], + 'properties' => ['class' => 2], + ], + 'sessionId' => $sessionId, + ]); + } + + /** + * @param list> $events + * @return array + */ + public function toPayload(array $events): array + { + return (new TelemetryPayload(['events' => $events]))->toArray(); + } + + public static function nowMs(): int + { + return (int) floor(microtime(true) * 1000); + } +} diff --git a/src/PHPMax/Api/Telemetry/TelemetryService.php b/src/PHPMax/Api/Telemetry/TelemetryService.php new file mode 100644 index 0000000..5bfe381 --- /dev/null +++ b/src/PHPMax/Api/Telemetry/TelemetryService.php @@ -0,0 +1,239 @@ +app = $app; + $this->builder = $builder ?: new TelemetryPayloadBuilder(); + $this->planner = $planner ?: new NavigationPlanner(); + $this->lastNavTime = TelemetryPayloadBuilder::nowMs(); + } + + /** + * @param list> $events + */ + public function sendEvents(array $events): bool + { + if ($events === []) { + return true; + } + + try { + $this->app->invoke(Opcode::LOG, $this->builder->toPayload($events)); + + return true; + } catch (Throwable $e) { + return false; + } + } + + public function login(int $userId, ?int $sessionId = null): bool + { + return $this->sendEvents([ + $this->builder->login($userId, $sessionId !== null ? $sessionId : $this->app->options()->clientSessionId), + ]); + } + + /** + * @param array $extraParams + */ + public function navigation( + int $userId, + int $screenFrom, + int $screenTo, + int $prevTime, + int $actionId, + array $extraParams = [], + ?int $sessionId = null + ): bool { + return $this->sendEvents([ + $this->builder->navigation( + $userId, + $sessionId !== null ? $sessionId : $this->app->options()->clientSessionId, + $screenFrom, + $screenTo, + $prevTime, + $actionId, + $extraParams + ), + ]); + } + + public function openChat(int $userId, ?int $sessionId = null): bool + { + return $this->sendEvents([ + $this->builder->openChat($userId, $sessionId !== null ? $sessionId : $this->app->options()->clientSessionId), + ]); + } + + public function openChats(int $userId, ?int $sessionId = null): bool + { + return $this->sendEvents([ + $this->builder->openChats($userId, $sessionId !== null ? $sessionId : $this->app->options()->clientSessionId), + ]); + } + + /** + * Builds one bounded navigation telemetry batch without sleeping or running + * a background loop. + * + * @param list $chats + * @return list + */ + public function plannedNavigationEvents( + int $userId, + ?int $sessionId = null, + ?RouteProfile $profile = null, + array $chats = [] + ): array { + $resolvedSessionId = $sessionId !== null ? $sessionId : $this->app->options()->clientSessionId; + $routeProfile = $profile ?: $this->planner->newProfile(); + $events = []; + + for ($step = 0; $step < $routeProfile->steps; $step++) { + $screenFrom = $this->planner->currentScreen(); + $screenTo = $this->planner->nextScreen($routeProfile); + $events[] = $this->navigationEvent($userId, $resolvedSessionId, $screenFrom, $screenTo, $chats); + foreach ($this->renderEvents($userId, $resolvedSessionId, $screenTo) as $event) { + $events[] = $event; + } + } + + return $events; + } + + /** + * @param list $chats + */ + public function sendPlannedNavigation( + int $userId, + ?int $sessionId = null, + ?RouteProfile $profile = null, + array $chats = [] + ): bool { + $events = $this->plannedNavigationEvents($userId, $sessionId, $profile, $chats); + $this->planner->resetToBackground(); + + return $this->sendEvents($events); + } + + public function resetNavigation(): void + { + $this->planner->resetToBackground(); + } + + /** + * @param list $chats + */ + private function navigationEvent(int $userId, int $sessionId, int $screenFrom, int $screenTo, array $chats): TelemetryEvent + { + $event = $this->builder->navigation( + $userId, + $sessionId, + $screenFrom, + $screenTo, + $this->lastNavTime, + $this->nextActionId(), + $this->sourceParams($screenTo, $userId, $chats) + ); + $this->lastNavTime = (int) $event->time; + + return $event; + } + + /** + * @return list + */ + private function renderEvents(int $userId, int $sessionId, int $screenTo): array + { + if ($screenTo === Screen::CHAT) { + return [$this->builder->openChat($userId, $sessionId)]; + } + + if ($screenTo === Screen::CHATS && random_int(1, 100) <= 20) { + return [$this->builder->openChats($userId, $sessionId)]; + } + + return []; + } + + /** + * @param list $chats + * @return array + */ + private function sourceParams(int $screenTo, int $userId, array $chats): array + { + if ($screenTo === Screen::CHATS) { + return [ + 'source_type' => 5, + 'source_id' => 1, + 'tab_config' => 2, + ]; + } + + if ($screenTo !== Screen::CHAT) { + return []; + } + + $chat = $this->pickChat($chats); + if ($chat === null || $chat->id === null) { + return [ + 'source_type' => 1, + 'source_id' => $userId, + ]; + } + + return [ + 'source_type' => $chat->type === ChatType::DIALOG ? 1 : 2, + 'source_id' => $chat->id, + ]; + } + + /** + * @param list $chats + */ + private function pickChat(array $chats): ?Chat + { + $available = []; + foreach ($chats as $chat) { + if ($chat instanceof Chat) { + $available[] = $chat; + } + } + + if ($available === []) { + return null; + } + + return $available[random_int(0, count($available) - 1)]; + } + + private function nextActionId(): int + { + $this->actionId = ($this->actionId + 1) % 0xFFFFFFFF; + + return $this->actionId; + } +} diff --git a/src/PHPMax/Api/Uploads/AttachFilePayload.php b/src/PHPMax/Api/Uploads/AttachFilePayload.php new file mode 100644 index 0000000..74876c8 --- /dev/null +++ b/src/PHPMax/Api/Uploads/AttachFilePayload.php @@ -0,0 +1,25 @@ + ['type' => 'string', 'payload' => '_type', 'default' => AttachmentType::FILE], + 'fileId' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/AttachPhotoPayload.php b/src/PHPMax/Api/Uploads/AttachPhotoPayload.php new file mode 100644 index 0000000..19dc7a7 --- /dev/null +++ b/src/PHPMax/Api/Uploads/AttachPhotoPayload.php @@ -0,0 +1,25 @@ + ['type' => 'string', 'payload' => '_type', 'default' => AttachmentType::PHOTO], + 'photoToken' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/FilePayloadResponse.php b/src/PHPMax/Api/Uploads/FilePayloadResponse.php new file mode 100644 index 0000000..bb8131e --- /dev/null +++ b/src/PHPMax/Api/Uploads/FilePayloadResponse.php @@ -0,0 +1,27 @@ + ['type' => 'string', 'required' => true], + 'fileId' => ['type' => 'int', 'required' => true], + 'token' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/FileUploadResponse.php b/src/PHPMax/Api/Uploads/FileUploadResponse.php new file mode 100644 index 0000000..00196d0 --- /dev/null +++ b/src/PHPMax/Api/Uploads/FileUploadResponse.php @@ -0,0 +1,34 @@ + */ + public $info = []; + + protected static function schema(): array + { + return [ + 'info' => ['factory' => static function ($value): array { + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected info list in file upload response'); + } + $items = []; + foreach ($value as $item) { + if (!is_array($item)) { + throw new ValidationException('Expected file upload info item'); + } + $items[] = FilePayloadResponse::fromArray($item); + } + + return $items; + }, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Uploads/HttpUploadResponse.php b/src/PHPMax/Api/Uploads/HttpUploadResponse.php new file mode 100644 index 0000000..47369a0 --- /dev/null +++ b/src/PHPMax/Api/Uploads/HttpUploadResponse.php @@ -0,0 +1,45 @@ +status = $status; + $this->body = $body; + } + + public function status(): int + { + return $this->status; + } + + public function body(): string + { + return $this->body; + } + + /** + * @return array + */ + public function json(): array + { + $decoded = json_decode($this->body, true); + if (!is_array($decoded)) { + throw new UploadException('Failed to decode upload response JSON'); + } + + return $decoded; + } +} + diff --git a/src/PHPMax/Api/Uploads/HttpUploaderInterface.php b/src/PHPMax/Api/Uploads/HttpUploaderInterface.php new file mode 100644 index 0000000..d2d9e40 --- /dev/null +++ b/src/PHPMax/Api/Uploads/HttpUploaderInterface.php @@ -0,0 +1,28 @@ + $headers + * @param iterable $chunks + */ + public function uploadStream( + string $url, + array $headers, + iterable $chunks, + int $contentLength + ): HttpUploadResponse; +} + diff --git a/src/PHPMax/Api/Uploads/NativeHttpUploader.php b/src/PHPMax/Api/Uploads/NativeHttpUploader.php new file mode 100644 index 0000000..a1eb0ae --- /dev/null +++ b/src/PHPMax/Api/Uploads/NativeHttpUploader.php @@ -0,0 +1,240 @@ +timeout = max(0.001, $timeout); + $this->proxy = ProxyConfig::fromUrl($proxy); + } + + public function uploadMultipart( + string $url, + string $fieldName, + string $contents, + string $filename, + string $contentType + ): HttpUploadResponse { + $this->assertHttpUrl($url); + + $boundary = '----PHPMax' . bin2hex(random_bytes(12)); + $body = '--' . $boundary . "\r\n" + . 'Content-Disposition: form-data; name="' . $this->escapeHeaderValue($fieldName) . '"; filename="' . $this->escapeHeaderValue($filename) . '"' . "\r\n" + . 'Content-Type: ' . $contentType . "\r\n\r\n" + . $contents . "\r\n" + . '--' . $boundary . "--\r\n"; + + return $this->postBody($url, [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + 'Content-Length' => (string) strlen($body), + ], $body); + } + + public function uploadStream( + string $url, + array $headers, + iterable $chunks, + int $contentLength + ): HttpUploadResponse { + $this->assertHttpUrl($url); + + if (!function_exists('curl_init')) { + throw new UploadException('ext-curl is required for streaming file/video uploads'); + } + + $streamBody = new StreamBody($chunks, $contentLength); + $ch = curl_init($url); + if ($ch === false) { + throw new UploadException('Failed to initialize upload HTTP client'); + } + + curl_setopt_array($ch, [ + CURLOPT_UPLOAD => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => (int) ceil($this->timeout), + CURLOPT_HTTPHEADER => $this->formatHeaders($this->withContentLength($headers, $contentLength)), + CURLOPT_INFILESIZE => $contentLength, + CURLOPT_READFUNCTION => static function ($curl, $file, int $length) use ($streamBody): string { + return $streamBody->read($length); + }, + ]); + if (defined('CURLOPT_INFILESIZE_LARGE')) { + curl_setopt($ch, CURLOPT_INFILESIZE_LARGE, $contentLength); + } + $this->applyCurlProxy($ch); + + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($body === false) { + throw new UploadException('HTTP upload failed: ' . $error); + } + $streamBody->assertComplete(); + + return new HttpUploadResponse($status, (string) $body); + } + + /** + * @param array $headers + */ + private function postBody(string $url, array $headers, string $body): HttpUploadResponse + { + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + throw new UploadException('Failed to initialize upload HTTP client'); + } + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => (int) ceil($this->timeout), + CURLOPT_HTTPHEADER => $this->formatHeaders($headers), + CURLOPT_POSTFIELDS => $body, + ]); + $this->applyCurlProxy($ch); + + $responseBody = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($responseBody === false) { + throw new UploadException('HTTP upload failed: ' . $error); + } + + return new HttpUploadResponse($status, (string) $responseBody); + } + + if ($this->proxy !== null) { + throw new UploadException('ext-curl is required for HTTP uploads through proxy'); + } + + $httpOptions = [ + 'method' => 'POST', + 'header' => $this->headersToString($headers), + 'content' => $body, + 'timeout' => $this->timeout, + 'ignore_errors' => true, + ]; + + $context = stream_context_create(['http' => $httpOptions]); + $responseBody = @file_get_contents($url, false, $context); + if ($responseBody === false) { + throw new UploadException('HTTP upload failed'); + } + + return new HttpUploadResponse($this->statusFromHeaders($http_response_header ?? []), (string) $responseBody); + } + + /** + * @param array $headers + * @return list + */ + private function formatHeaders(array $headers): array + { + $result = []; + foreach ($headers as $name => $value) { + $result[] = $name . ': ' . $value; + } + + return $result; + } + + /** + * @param array $headers + * @return array + */ + private function withContentLength(array $headers, int $contentLength): array + { + foreach ($headers as $name => $value) { + if (strcasecmp((string) $name, 'Content-Length') === 0) { + return $headers; + } + } + + $headers['Content-Length'] = (string) $contentLength; + + return $headers; + } + + /** + * @param array $headers + */ + private function headersToString(array $headers): string + { + return implode("\r\n", $this->formatHeaders($headers)); + } + + /** + * @param list $headers + */ + private function statusFromHeaders(array $headers): int + { + foreach ($headers as $header) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/', $header, $matches)) { + return (int) $matches[1]; + } + } + + return 0; + } + + private function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"', "\r", "\n"], ['\\\\', '\\"', '', ''], $value); + } + + private function assertHttpUrl(string $url): void + { + $parts = parse_url($url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new UploadException('Upload URL must be an absolute HTTP or HTTPS URL'); + } + if (isset($parts['port'])) { + $port = (int) $parts['port']; + if ($port <= 0 || $port > 65535) { + throw new UploadException('Upload URL port must be between 1 and 65535'); + } + } + + $scheme = strtolower((string) $parts['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new UploadException('Unsupported upload URL scheme: ' . $scheme); + } + } + + /** + * @param resource $ch + */ + private function applyCurlProxy($ch): void + { + if ($this->proxy === null) { + return; + } + + curl_setopt($ch, CURLOPT_PROXY, $this->proxy->curlProxyUrl()); + curl_setopt($ch, CURLOPT_PROXYTYPE, $this->proxy->curlType()); + $userPassword = $this->proxy->curlUserPassword(); + if ($userPassword !== null) { + curl_setopt($ch, CURLOPT_PROXYUSERPWD, $userPassword); + } + } +} diff --git a/src/PHPMax/Api/Uploads/PhotoPayloadResponse.php b/src/PHPMax/Api/Uploads/PhotoPayloadResponse.php new file mode 100644 index 0000000..c0a2b4c --- /dev/null +++ b/src/PHPMax/Api/Uploads/PhotoPayloadResponse.php @@ -0,0 +1,21 @@ + ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/PhotoUploadResponse.php b/src/PHPMax/Api/Uploads/PhotoUploadResponse.php new file mode 100644 index 0000000..a6dd990 --- /dev/null +++ b/src/PHPMax/Api/Uploads/PhotoUploadResponse.php @@ -0,0 +1,33 @@ + */ + public $photos = []; + + protected static function schema(): array + { + return [ + 'photos' => ['factory' => static function ($value): array { + $result = []; + if (!is_array($value)) { + throw new ValidationException('Expected photos map in photo upload response'); + } + foreach ($value as $photoId => $item) { + if (is_array($item)) { + $result[(string) $photoId] = PhotoPayloadResponse::fromArray($item); + } + } + + return $result; + }, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Uploads/StreamBody.php b/src/PHPMax/Api/Uploads/StreamBody.php new file mode 100644 index 0000000..40babda --- /dev/null +++ b/src/PHPMax/Api/Uploads/StreamBody.php @@ -0,0 +1,115 @@ +chunks = self::toIterator($chunks); + $this->chunks->rewind(); + $this->expectedLength = $expectedLength; + } + + public function read(int $length): string + { + if ($length <= 0) { + return ''; + } + + while (strlen($this->buffer) < $length && !$this->exhausted) { + $this->appendNextChunk(); + } + + if ($this->buffer === '') { + return ''; + } + + $data = substr($this->buffer, 0, $length); + $this->buffer = substr($this->buffer, strlen($data)); + $this->bytesRead += strlen($data); + + if ($this->bytesRead > $this->expectedLength) { + throw new UploadException('Upload stream exceeded expected size: expected ' . $this->expectedLength . ', got at least ' . $this->bytesRead); + } + + return $data; + } + + public function assertComplete(): void + { + if ($this->bytesRead !== $this->expectedLength) { + throw new UploadException('Upload stream size mismatch: expected ' . $this->expectedLength . ', got ' . $this->bytesRead); + } + } + + public function bytesRead(): int + { + return $this->bytesRead; + } + + private function appendNextChunk(): void + { + while ($this->chunks->valid()) { + $chunk = $this->chunks->current(); + $this->chunks->next(); + + if (!is_string($chunk)) { + throw new UploadException('Upload stream chunk must be a string'); + } + if ($chunk === '') { + continue; + } + + $this->buffer .= $chunk; + return; + } + + $this->exhausted = true; + } + + private static function toIterator(iterable $chunks): Iterator + { + if (is_array($chunks)) { + return new ArrayIterator($chunks); + } + if ($chunks instanceof Iterator) { + return $chunks; + } + if ($chunks instanceof IteratorAggregate) { + $iterator = $chunks->getIterator(); + if ($iterator instanceof Iterator) { + return $iterator; + } + if ($iterator instanceof Traversable) { + return self::toIterator(iterator_to_array($iterator, false)); + } + } + + throw new UploadException('Upload stream chunks must be iterable'); + } +} + diff --git a/src/PHPMax/Api/Uploads/UploadPayload.php b/src/PHPMax/Api/Uploads/UploadPayload.php new file mode 100644 index 0000000..8d24945 --- /dev/null +++ b/src/PHPMax/Api/Uploads/UploadPayload.php @@ -0,0 +1,24 @@ + ['type' => 'int', 'default' => 1], + 'profile' => ['type' => 'bool', 'default' => false], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/UploadService.php b/src/PHPMax/Api/Uploads/UploadService.php new file mode 100644 index 0000000..5267217 --- /dev/null +++ b/src/PHPMax/Api/Uploads/UploadService.php @@ -0,0 +1,357 @@ + */ + private $readyVideos = []; + /** @var array */ + private $readyFiles = []; + /** @var array */ + private $expectedVideos = []; + /** @var array */ + private $expectedFiles = []; + + public function __construct(App $app, ?HttpUploaderInterface $uploader = null) + { + $this->app = $app; + $this->uploader = $uploader ?: ($app->options()->httpUploader ?: new NativeHttpUploader( + $app->options()->uploadHttpTimeout, + $app->options()->proxy + )); + $this->app->onInternal(EventType::VIDEO_READY, [$this, 'onVideoAttach']); + $this->app->onInternal(EventType::FILE_READY, [$this, 'onFileAttach']); + } + + public function uploadPhoto(Photo $photo, bool $profile = false): AttachPhotoPayload + { + try { + $response = $this->app->invoke(Opcode::PHOTO_UPLOAD, (new UploadPayload(['profile' => $profile]))->toArray()); + } catch (Throwable $e) { + throw new UploadException('Failed to request photo upload URL', 0, $e); + } + + $url = $response->payload['url'] ?? null; + if (!is_string($url) || $url === '') { + throw new UploadException('No photo upload URL received'); + } + + $photoId = $this->parsePhotoId($url); + [$extension, $mime] = $photo->validatePhoto(); + + try { + $httpResponse = $this->uploader->uploadMultipart( + $url, + 'file', + $photo->read(), + 'image.' . rawurlencode($extension), + $mime + ); + } catch (Throwable $e) { + if ($e instanceof UploadException) { + throw $e; + } + throw new UploadException('HTTP error during photo upload', 0, $e); + } + + $this->assertStatusOk($httpResponse, 'Photo upload failed'); + try { + $uploadResponse = PhotoUploadResponse::fromArray($httpResponse->json()); + if (!is_array($uploadResponse->photos)) { + throw new UploadException('Invalid photo upload response model'); + } + } catch (UploadException $e) { + throw $e; + } catch (Throwable $e) { + throw new UploadException('Invalid photo upload response model', 0, $e); + } + $photoResult = $uploadResponse->photos[$photoId] ?? null; + if (!$photoResult instanceof PhotoPayloadResponse || $photoResult->token === null || $photoResult->token === '') { + throw new UploadException('Photo upload response does not contain token for photo_id=' . $photoId); + } + + return new AttachPhotoPayload(['photoToken' => $photoResult->token]); + } + + public function uploadVideo(Video $video): VideoAttachPayload + { + try { + $response = $this->app->invoke(Opcode::VIDEO_UPLOAD, (new UploadPayload())->toArray()); + } catch (Throwable $e) { + throw new UploadException('Failed to request video upload URL', 0, $e); + } + + try { + $uploadResponse = VideoUploadResponse::fromArray($this->requireUploadResponsePayload( + $response->payload, + 'Invalid video upload response model' + )); + } catch (UploadException $e) { + throw $e; + } catch (Throwable $e) { + throw new UploadException('Invalid video upload response model', 0, $e); + } + $uploadInfo = $uploadResponse->info[0] ?? null; + if (!$uploadInfo instanceof VideoPayloadResponse) { + throw new UploadException('Video upload response info is empty'); + } + $this->assertVideoUploadInfo($uploadInfo); + + $size = $this->nonEmptySize($video, 'video'); + $headers = [ + 'Content-Disposition' => 'attachment; filename=' . rawurlencode($video->name()), + 'Content-Range' => '0-' . ($size - 1) . '/' . $size, + 'Content-Length' => (string) $size, + 'Connection' => 'keep-alive', + ]; + + $videoId = (int) $uploadInfo->videoId; + $this->expectedVideos[$videoId] = true; + + try { + try { + $httpResponse = $this->uploader->uploadStream( + (string) $uploadInfo->url, + $headers, + $video->iterChunks($this->app->options()->uploadChunkSize), + $size + ); + } catch (Throwable $e) { + if ($e instanceof UploadException) { + throw $e; + } + throw new UploadException('HTTP error during video upload video_id=' . $uploadInfo->videoId, 0, $e); + } + + $this->assertStatusOk($httpResponse, 'Video upload failed with status'); + $this->waitForAttach('video', $videoId); + + return new VideoAttachPayload([ + 'videoId' => $videoId, + 'token' => (string) $uploadInfo->token, + ]); + } finally { + unset($this->expectedVideos[$videoId], $this->readyVideos[$videoId]); + } + } + + public function uploadFile(File $file): AttachFilePayload + { + try { + $response = $this->app->invoke(Opcode::FILE_UPLOAD, (new UploadPayload())->toArray()); + } catch (Throwable $e) { + throw new UploadException('Failed to request file upload URL', 0, $e); + } + + try { + $uploadResponse = FileUploadResponse::fromArray($this->requireUploadResponsePayload( + $response->payload, + 'Invalid file upload response model' + )); + } catch (UploadException $e) { + throw $e; + } catch (Throwable $e) { + throw new UploadException('Invalid file upload response model', 0, $e); + } + $uploadInfo = $uploadResponse->info[0] ?? null; + if (!$uploadInfo instanceof FilePayloadResponse) { + throw new UploadException('File upload response info is empty'); + } + $this->assertFileUploadInfo($uploadInfo); + + $size = $this->nonEmptySize($file, 'file'); + $headers = [ + 'Content-Disposition' => 'attachment; filename=' . rawurlencode($file->name()), + 'Content-Length' => (string) $size, + 'Content-Range' => '0-' . ($size - 1) . '/' . $size, + ]; + + $fileId = (int) $uploadInfo->fileId; + $this->expectedFiles[$fileId] = true; + + try { + try { + $httpResponse = $this->uploader->uploadStream( + (string) $uploadInfo->url, + $headers, + $file->iterChunks($this->app->options()->uploadChunkSize), + $size + ); + } catch (Throwable $e) { + if ($e instanceof UploadException) { + throw $e; + } + throw new UploadException('HTTP error during file upload file_id=' . $uploadInfo->fileId, 0, $e); + } + + $this->assertStatusOk($httpResponse, 'File upload failed with status'); + $this->waitForAttach('file', $fileId); + + return new AttachFilePayload(['fileId' => $fileId]); + } finally { + unset($this->expectedFiles[$fileId], $this->readyFiles[$fileId]); + } + } + + public function onVideoAttach(VideoUploadSignal $attach): void + { + if ($attach->videoId !== null && isset($this->expectedVideos[(int) $attach->videoId])) { + $this->readyVideos[(int) $attach->videoId] = true; + } + } + + public function onFileAttach(FileUploadSignal $attach): void + { + if ($attach->fileId !== null && isset($this->expectedFiles[(int) $attach->fileId])) { + $this->readyFiles[(int) $attach->fileId] = true; + } + } + + private function parsePhotoId(string $url): string + { + $query = (string) parse_url($url, PHP_URL_QUERY); + $params = []; + parse_str($query, $params); + $photoIds = $params['photoIds'] ?? null; + if (is_array($photoIds)) { + $photoIds = $photoIds[0] ?? null; + } + if ($photoIds === null || $photoIds === '') { + throw new UploadException('Photo upload URL does not contain photoIds'); + } + + return (string) $photoIds; + } + + private function assertStatusOk(HttpUploadResponse $response, string $message): void + { + if ($response->status() !== 200) { + throw new UploadException($message . ' ' . $response->status()); + } + } + + private function nonEmptySize($file, string $type): int + { + $size = $file->size(); + if ($size <= 0) { + throw new UploadException(ucfirst($type) . ' upload source is empty'); + } + + return $size; + } + + private function assertVideoUploadInfo(VideoPayloadResponse $uploadInfo): void + { + if ($uploadInfo->url === null || $uploadInfo->url === '') { + throw new UploadException('Video upload response URL is empty'); + } + if ($uploadInfo->videoId === null || $uploadInfo->videoId <= 0) { + throw new UploadException('Video upload response videoId is invalid'); + } + if ($uploadInfo->token === null || $uploadInfo->token === '') { + throw new UploadException('Video upload response token is empty'); + } + } + + private function assertFileUploadInfo(FilePayloadResponse $uploadInfo): void + { + if ($uploadInfo->url === null || $uploadInfo->url === '') { + throw new UploadException('File upload response URL is empty'); + } + if ($uploadInfo->fileId === null || $uploadInfo->fileId <= 0) { + throw new UploadException('File upload response fileId is invalid'); + } + if ($uploadInfo->token === null || $uploadInfo->token === '') { + throw new UploadException('File upload response token is empty'); + } + } + + /** + * @param array|null $payload + * @return array + */ + private function requireUploadResponsePayload(?array $payload, string $message): array + { + if ($payload === null || $payload === []) { + throw new UploadException($message); + } + + return $payload; + } + + private function waitForAttach(string $kind, int $id): void + { + $timeout = $this->app->options()->uploadProcessingTimeout; + $deadline = microtime(true) + $timeout; + + while (true) { + if ($this->consumeReadyAttach($kind, $id)) { + return; + } + + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + throw new UploadException('Timed out waiting for ' . $kind . ' processing ' . $kind . '_id=' . $id); + } + + try { + $frame = $this->app->connection()->readFrame(min($this->app->options()->requestTimeout, max(0.001, $remaining))); + } catch (ProtocolException $e) { + if ($this->isTimeout($e)) { + continue; + } + throw new UploadException('Connection error while waiting for ' . $kind . ' processing', 0, $e); + } + + $this->app->connection()->dispatchEvent($frame); + if ($this->consumeReadyAttach($kind, $id)) { + return; + } + } + } + + private function consumeReadyAttach(string $kind, int $id): bool + { + if ($kind === 'file') { + if (!isset($this->readyFiles[$id])) { + return false; + } + + unset($this->readyFiles[$id]); + + return true; + } + + if (!isset($this->readyVideos[$id])) { + return false; + } + + unset($this->readyVideos[$id]); + + return true; + } + + private function isTimeout(ProtocolException $exception): bool + { + return stripos($exception->getMessage(), 'timed out') !== false; + } +} diff --git a/src/PHPMax/Api/Uploads/VideoAttachPayload.php b/src/PHPMax/Api/Uploads/VideoAttachPayload.php new file mode 100644 index 0000000..e6854d0 --- /dev/null +++ b/src/PHPMax/Api/Uploads/VideoAttachPayload.php @@ -0,0 +1,28 @@ + ['type' => 'string', 'payload' => '_type', 'default' => AttachmentType::VIDEO], + 'videoId' => ['type' => 'int', 'required' => true], + 'token' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/VideoPayloadResponse.php b/src/PHPMax/Api/Uploads/VideoPayloadResponse.php new file mode 100644 index 0000000..2f3de0c --- /dev/null +++ b/src/PHPMax/Api/Uploads/VideoPayloadResponse.php @@ -0,0 +1,27 @@ + ['type' => 'string', 'required' => true], + 'videoId' => ['type' => 'int', 'required' => true], + 'token' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Api/Uploads/VideoUploadResponse.php b/src/PHPMax/Api/Uploads/VideoUploadResponse.php new file mode 100644 index 0000000..950be4a --- /dev/null +++ b/src/PHPMax/Api/Uploads/VideoUploadResponse.php @@ -0,0 +1,34 @@ + */ + public $info = []; + + protected static function schema(): array + { + return [ + 'info' => ['factory' => static function ($value): array { + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected info list in video upload response'); + } + $items = []; + foreach ($value as $item) { + if (!is_array($item)) { + throw new ValidationException('Expected video upload info item'); + } + $items[] = VideoPayloadResponse::fromArray($item); + } + + return $items; + }, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Users/ContactAction.php b/src/PHPMax/Api/Users/ContactAction.php new file mode 100644 index 0000000..3a9c465 --- /dev/null +++ b/src/PHPMax/Api/Users/ContactAction.php @@ -0,0 +1,15 @@ + ['type' => 'int', 'required' => true], + 'action' => ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Users/ContactPayload.php b/src/PHPMax/Api/Users/ContactPayload.php new file mode 100644 index 0000000..f01570f --- /dev/null +++ b/src/PHPMax/Api/Users/ContactPayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Users/FetchContactsPayload.php b/src/PHPMax/Api/Users/FetchContactsPayload.php new file mode 100644 index 0000000..d2f6b82 --- /dev/null +++ b/src/PHPMax/Api/Users/FetchContactsPayload.php @@ -0,0 +1,20 @@ + */ + public $contactIds = []; + + protected static function schema(): array + { + return [ + 'contactIds' => ['type' => 'list', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Users/ImportContactsPayload.php b/src/PHPMax/Api/Users/ImportContactsPayload.php new file mode 100644 index 0000000..7ad9b2c --- /dev/null +++ b/src/PHPMax/Api/Users/ImportContactsPayload.php @@ -0,0 +1,36 @@ + */ + public $contactList = []; + + protected static function schema(): array + { + return [ + 'contactList' => ['type' => 'mixed', 'default' => static function (): array { + return []; + }], + ]; + } + + /** + * @param iterable $contacts + */ + public static function fromContacts(iterable $contacts): self + { + $items = []; + foreach ($contacts as $contact) { + $items[(string) $contact->phone] = new ContactPayload(['firstName' => $contact->firstName]); + } + + return new self(['contactList' => $items]); + } +} diff --git a/src/PHPMax/Api/Users/SearchByPhonePayload.php b/src/PHPMax/Api/Users/SearchByPhonePayload.php new file mode 100644 index 0000000..e5a3f8e --- /dev/null +++ b/src/PHPMax/Api/Users/SearchByPhonePayload.php @@ -0,0 +1,20 @@ + ['type' => 'string', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Api/Users/UserPayloadKey.php b/src/PHPMax/Api/Users/UserPayloadKey.php new file mode 100644 index 0000000..c166b48 --- /dev/null +++ b/src/PHPMax/Api/Users/UserPayloadKey.php @@ -0,0 +1,16 @@ +app = $app; + } + + public function getCachedUser(int $userId): ?User + { + return $this->app->cachedUser($userId); + } + + public function cacheExternalUser(User $user): User + { + return $this->cacheUser($user); + } + + /** + * @param list $userIds + * @return list + */ + public function getUsers(array $userIds): array + { + $cached = []; + $missing = []; + foreach ($userIds as $userId) { + $user = $this->getCachedUser($userId); + if ($user !== null) { + $cached[$userId] = $user; + } else { + $missing[] = $userId; + } + } + + if ($missing !== []) { + foreach ($this->fetchUsers($missing) as $user) { + if ($user->id !== null) { + $cached[$user->id] = $user; + } + } + } + + $result = []; + foreach ($userIds as $userId) { + if (isset($cached[$userId])) { + $result[] = $cached[$userId]; + } + } + + return $result; + } + + public function getUser(int $userId): ?User + { + $user = $this->getCachedUser($userId); + if ($user !== null) { + return $user; + } + + $users = $this->fetchUsers([$userId]); + + return $users[0] ?? null; + } + + /** + * @param list $userIds + * @return list + */ + public function fetchUsers(array $userIds): array + { + $payload = new FetchContactsPayload(['contactIds' => $userIds]); + $response = $this->app->invoke(Opcode::CONTACT_INFO, $payload->toArray()); + + $users = []; + foreach ($this->parsePayloadList($response->payload, UserPayloadKey::CONTACTS, 'contacts') as $item) { + $users[] = $this->cacheUser(User::fromArray($item)); + } + + return $users; + } + + public function searchByPhone(string $phone): User + { + $payload = new SearchByPhonePayload(['phone' => $phone]); + $response = $this->app->invoke(Opcode::CONTACT_INFO_BY_PHONE, $payload->toArray()); + $contact = $response->payload[UserPayloadKey::CONTACT] ?? null; + if (!is_array($contact)) { + throw new PHPMaxException('Contact not found in response'); + } + + return $this->cacheUser(User::fromArray($contact)); + } + + /** + * @return list + */ + public function getSessions(): array + { + $response = $this->app->invoke(Opcode::SESSIONS_INFO, []); + + $sessions = []; + foreach ($this->parsePayloadList($response->payload, UserPayloadKey::SESSIONS, 'sessions') as $item) { + $sessions[] = Session::fromArray($item); + } + + return $sessions; + } + + public function addContact(int $contactId): User + { + $payload = new ContactActionPayload([ + 'contactId' => $contactId, + 'action' => ContactAction::ADD, + ]); + $response = $this->app->invoke(Opcode::CONTACT_UPDATE, $payload->toArray()); + $responsePayload = $this->requireResponseDict($response->payload); + $contact = $responsePayload[UserPayloadKey::CONTACT] ?? null; + if (!is_array($contact)) { + throw new PHPMaxException('Contact not found in response'); + } + + return $this->cacheUser(User::fromArray($contact)); + } + + public function removeContact(int $contactId): bool + { + $payload = new ContactActionPayload([ + 'contactId' => $contactId, + 'action' => ContactAction::REMOVE, + ]); + $response = $this->app->invoke(Opcode::CONTACT_UPDATE, $payload->toArray()); + $this->requireResponseDict($response->payload); + $this->app->removeCachedUser($contactId); + + return true; + } + + /** + * @param list $contacts + * @return list + */ + public function importContacts(array $contacts): array + { + $payload = ImportContactsPayload::fromContacts($contacts); + $response = $this->app->invoke(Opcode::SYNC, $payload->toArray()); + + $users = []; + foreach ($this->parsePayloadList($response->payload, UserPayloadKey::CONTACTS, 'contacts') as $item) { + $users[] = $this->cacheUser(User::fromArray($item)); + } + + return $users; + } + + public function getChatId(int $firstUserId, int $secondUserId): int + { + return $firstUserId ^ $secondUserId; + } + + private function cacheUser(User $user): User + { + return $this->app->cacheUser($user); + } + + /** + * @param array|null $payload + * @return list> + */ + private function parsePayloadList(?array $payload, string $key, string $label): array + { + $items = $payload[$key] ?? null; + if ($items === null || $items === []) { + return []; + } + if (!is_array($items) || !$this->isList($items)) { + throw new PHPMaxException('Invalid ' . $label . ' list in response'); + } + + $result = []; + foreach ($items as $item) { + if (!is_array($item)) { + throw new PHPMaxException('Invalid ' . $label . ' item in response'); + } + $result[] = $item; + } + + return $result; + } + + /** + * @param array|null $payload + * @return array + */ + private function requireResponseDict(?array $payload): array + { + if ($payload === null || ($payload !== [] && $this->isList($payload))) { + throw new PHPMaxException('Invalid response payload'); + } + + return $payload; + } + + /** + * @param array $payload + */ + private function isList(array $payload): bool + { + $expected = 0; + foreach (array_keys($payload) as $key) { + if ($key !== $expected) { + return false; + } + $expected++; + } + + return true; + } +} diff --git a/src/PHPMax/Auth/AuthFlowInterface.php b/src/PHPMax/Auth/AuthFlowInterface.php new file mode 100644 index 0000000..2a3d2dd --- /dev/null +++ b/src/PHPMax/Auth/AuthFlowInterface.php @@ -0,0 +1,14 @@ +token = $token; + } +} + diff --git a/src/PHPMax/Auth/ConsoleEmailCodeProvider.php b/src/PHPMax/Auth/ConsoleEmailCodeProvider.php new file mode 100644 index 0000000..e6d40ac --- /dev/null +++ b/src/PHPMax/Auth/ConsoleEmailCodeProvider.php @@ -0,0 +1,24 @@ +qrHandler = $qrHandler; + $this->passwordProvider = $passwordProvider ?: new ConsolePasswordProvider(); + } + + public function authenticate(AuthService $authService, ClientOptions $options): AuthResult + { + $qr = $authService->requestQr(); + $this->qrHandler->showQr($qr->qrLink); + + if (!$this->pollQr($authService, $qr, $options)) { + throw new PHPMaxException('QR authentication expired'); + } + + $result = $authService->confirmQr($qr->trackId); + if ($result->loginToken() !== null) { + return new AuthResult($result->loginToken()); + } + + if ($result->passwordChallenge !== null) { + return new AuthResult($this->authenticateWithPassword( + $authService, + $result->passwordChallenge->trackId, + $result->passwordChallenge->hint + )); + } + + return new AuthResult(null); + } + + private function pollQr(AuthService $authService, RequestQrResponse $qr, ClientOptions $options): bool + { + $expiresAt = $qr->expiresAt !== null ? $qr->expiresAt / 1000.0 : microtime(true); + $deadline = min($expiresAt, microtime(true) + $options->qrPollTimeout); + $interval = $qr->pollingInterval !== null && $qr->pollingInterval > 0 + ? $qr->pollingInterval / 1000.0 + : 1.0; + + while (microtime(true) < $deadline) { + $status = $authService->checkQr($qr->trackId); + if ($status->status !== null && $status->status->loginAvailable === true) { + return true; + } + + $remaining = $deadline - microtime(true); + if ($remaining <= 0.0) { + break; + } + usleep((int) floor(min($interval, $remaining) * 1000000)); + } + + return false; + } + + private function authenticateWithPassword(AuthService $authService, string $trackId, ?string $hint): string + { + for ($attempt = 0; $attempt < 5; $attempt++) { + $password = $this->passwordProvider->getPassword($hint); + if ($password === '') { + continue; + } + $response = $authService->checkPassword($trackId, $password); + if ($response->error !== null) { + continue; + } + if ($response->loginToken() !== null) { + return $response->loginToken(); + } + } + + throw new PHPMaxException('2FA password authentication failed'); + } +} diff --git a/src/PHPMax/Auth/QrHandlerInterface.php b/src/PHPMax/Auth/QrHandlerInterface.php new file mode 100644 index 0000000..a2f399a --- /dev/null +++ b/src/PHPMax/Auth/QrHandlerInterface.php @@ -0,0 +1,10 @@ +codeProvider = $codeProvider; + $this->passwordProvider = $passwordProvider ?: new ConsolePasswordProvider(); + } + + public function authenticate(AuthService $authService, ClientOptions $options): AuthResult + { + if ($options->phone === null || $options->phone === '') { + throw new PHPMaxException('Phone is required for SMS authentication'); + } + + $start = $authService->requestCode($options->phone); + $code = $this->codeProvider->getCode($options->phone); + $result = $authService->sendCode($start->token, $code); + + if ($result->loginToken() !== null) { + return new AuthResult($result->loginToken()); + } + + if ($result->passwordChallenge !== null) { + return new AuthResult($this->authenticateWithPassword( + $authService, + $result->passwordChallenge->trackId, + $result->passwordChallenge->hint + )); + } + + if ($result->registerToken() !== null) { + if ($options->registrationFirstName === null || $options->registrationFirstName === '') { + throw new PHPMaxException('Registration first name is required to register a new account'); + } + $registered = $authService->confirmRegistration( + $options->registrationFirstName, + $options->registrationLastName, + $result->registerToken() + ); + + return new AuthResult($registered->token); + } + + return new AuthResult(null); + } + + private function authenticateWithPassword(AuthService $authService, string $trackId, ?string $hint): string + { + for ($attempt = 0; $attempt < 5; $attempt++) { + $password = $this->passwordProvider->getPassword($hint); + if ($password === '') { + continue; + } + $response = $authService->checkPassword($trackId, $password); + if ($response->error !== null) { + continue; + } + if ($response->loginToken() !== null) { + return $response->loginToken(); + } + } + + throw new PHPMaxException('2FA password authentication failed'); + } +} + diff --git a/src/PHPMax/Auth/SmsCodeProviderInterface.php b/src/PHPMax/Auth/SmsCodeProviderInterface.php new file mode 100644 index 0000000..b91a1d2 --- /dev/null +++ b/src/PHPMax/Auth/SmsCodeProviderInterface.php @@ -0,0 +1,11 @@ +options = $options ?: new ClientOptions(); + $this->connection = $connection ?: new ConnectionManager(new TcpTransport( + $this->options->host, + $this->options->port, + $this->options->useSsl, + $this->options->connectTimeout, + $this->options->proxy + )); + $this->router = $router ?: new Router(); + $this->app = new App($this->connection, $this->options); + $this->loginResponse = null; + $client = $this; + $this->connection->setEventHandler(static function (InboundFrame $frame) use ($client): void { + $client->router()->dispatchFrame($frame, $client); + }); + } + + public function open(): void + { + $this->connection->open(); + try { + $this->authenticateIfConfigured(); + } catch (Throwable $e) { + $handled = $this->router->emitError($e, EventType::ON_START, null, $this, $this->router, null); + $this->close(); + if (!$handled) { + throw $e; + } + return; + } + + try { + $this->router->emitStart($this); + } catch (Throwable $e) { + $this->close(); + throw $e; + } + } + + public function close(): void + { + $this->app->close(); + } + + public function stop(): void + { + $this->close(); + } + + /** + * @return mixed + */ + public function withOpenSession(callable $callback) + { + $this->open(); + try { + return $callback($this); + } finally { + $this->close(); + } + } + + public function runFor(int $seconds): void + { + $budget = ExecutionBudget::fromRequestedSeconds($seconds, $this->options->executionSafetyMargin); + $nextPingAt = $this->nextPingDeadline(); + $startedAt = microtime(true); + $this->traceRuntime('run_start', $startedAt, $budget, [ + 'seconds' => $seconds, + 'ping_interval_ms' => (int) round($this->options->pingInterval * 1000), + 'request_timeout_ms' => (int) round($this->options->requestTimeout * 1000), + 'safety_margin_ms' => (int) round($this->options->executionSafetyMargin * 1000), + ]); + while (!$budget->expired() && $this->connection->isOpen()) { + $readTimeout = $this->runReadTimeout($budget, $nextPingAt); + try { + $this->traceRuntime('read_wait', $startedAt, $budget, [ + 'read_timeout_ms' => (int) round($readTimeout * 1000), + 'next_ping_due_ms' => $this->millisecondsUntil($nextPingAt), + ]); + $frame = $this->connection->readFrame($readTimeout); + } catch (ProtocolException $e) { + if ($this->isTimeoutException($e)) { + $this->traceRuntime('read_timeout', $startedAt, $budget, [ + 'error' => $this->shortError($e), + 'next_ping_due_ms' => $this->millisecondsUntil($nextPingAt), + ]); + $nextPingAt = $this->runPingIfDue($nextPingAt, $budget, $startedAt); + continue; + } + $this->traceRuntime('read_error', $startedAt, $budget, [ + 'error' => $this->shortError($e), + 'connection_open' => $this->connection->isOpen() ? 1 : 0, + ]); + if (!$this->handleRunDisconnect($e, $budget)) { + $this->traceRuntime('read_error_unhandled', $startedAt, $budget, [ + 'error' => $this->shortError($e), + ]); + throw $e; + } + $this->traceRuntime('read_error_reconnected', $startedAt, $budget, [ + 'error' => $this->shortError($e), + 'connection_open' => $this->connection->isOpen() ? 1 : 0, + ]); + $nextPingAt = $this->nextPingDeadline(); + continue; + } + $this->traceRuntime('frame_received', $startedAt, $budget, [ + 'opcode' => $frame->opcode, + 'cmd' => $frame->cmd, + 'seq' => $frame->seq, + 'field_keys' => $this->payloadKeySummary($frame->payload), + ]); + $this->connection->dispatchEvent($frame); + $nextPingAt = $this->runPingIfDue($nextPingAt, $budget, $startedAt); + } + $this->traceRuntime('run_end', $startedAt, $budget, [ + 'connection_open' => $this->connection->isOpen() ? 1 : 0, + 'budget_expired' => $budget->expired() ? 1 : 0, + ]); + } + + /** + * @param array $payload + */ + public function invoke(int $opcode, array $payload): InboundFrame + { + return $this->app->invoke($opcode, $payload); + } + + public function login(): ?LoginResponse + { + $this->authenticateIfConfigured(true); + + return $this->loginResponse; + } + + public function relogin(bool $dropConfigToken = true, bool $open = true): void + { + $session = $this->app->session(); + if ($session === null) { + throw new PHPMaxException('Cannot relogin before session is loaded'); + } + + $this->app->store()->deleteSession($session->token); + $this->close(); + + if ($dropConfigToken) { + $this->options->token = null; + } + + $this->app->setSession(null); + $this->app->clearState(); + $this->loginResponse = null; + + if ($open) { + $this->open(); + } + } + + public function me(): ?Profile + { + return $this->app->me(); + } + + /** + * @return list|null + */ + public function chats(): ?array + { + return $this->app->chats(); + } + + /** + * @return list + */ + public function contacts(): array + { + return $this->app->contacts(); + } + + /** + * @return array> + */ + public function messages(): array + { + return $this->app->messages(); + } + + public function includeRouter(Router $router): self + { + $this->router->includeRouter($router); + + return $this; + } + + public function onRaw(callable $handler, callable ...$filters): self + { + $this->router->onRaw($handler, ...$filters); + + return $this; + } + + public function onMessage(callable $handler, callable ...$filters): self + { + $this->router->onMessage($handler, ...$filters); + + return $this; + } + + public function onMessageEdit(callable $handler, callable ...$filters): self + { + $this->router->onMessageEdit($handler, ...$filters); + + return $this; + } + + public function onMessageDelete(callable $handler, callable ...$filters): self + { + $this->router->onMessageDelete($handler, ...$filters); + + return $this; + } + + public function onMessageRead(callable $handler, callable ...$filters): self + { + $this->router->onMessageRead($handler, ...$filters); + + return $this; + } + + public function onTyping(callable $handler, callable ...$filters): self + { + $this->router->onTyping($handler, ...$filters); + + return $this; + } + + public function onPresence(callable $handler, callable ...$filters): self + { + $this->router->onPresence($handler, ...$filters); + + return $this; + } + + public function onReactionUpdate(callable $handler, callable ...$filters): self + { + $this->router->onReactionUpdate($handler, ...$filters); + + return $this; + } + + public function onChatUpdate(callable $handler, callable ...$filters): self + { + $this->router->onChatUpdate($handler, ...$filters); + + return $this; + } + + public function onFileReady(callable $handler, callable ...$filters): self + { + $this->router->onFileReady($handler, ...$filters); + + return $this; + } + + public function onVideoReady(callable $handler, callable ...$filters): self + { + $this->router->onVideoReady($handler, ...$filters); + + return $this; + } + + public function onError(callable $handler, string $scope = ErrorScope::GLOBAL): self + { + $this->router->onError($handler, $scope); + + return $this; + } + + public function onDisconnect(callable $handler): self + { + $this->router->onDisconnect($handler); + + return $this; + } + + public function emitDisconnect(Throwable $exception, bool $reconnect = false, float $delay = 0.0): void + { + $this->router->emitDisconnect($exception, $reconnect, $delay); + } + + public function onStart(callable $handler): self + { + $this->router->onStart($handler); + + return $this; + } + + /** + * @param array|null $attachments + */ + public function sendMessage(int $chatId, string $text, ?int $replyTo = null, ?array $attachments = null, bool $notify = true): ?Message + { + return $this->app->api()->messages->sendMessage($chatId, $text, $replyTo, $attachments, $notify); + } + + /** + * @param list $messageIds + * @return list + */ + public function getMessages(int $chatId, array $messageIds): array + { + return $this->app->api()->messages->getMessages($chatId, $messageIds); + } + + public function getMessage(int $chatId, int $messageId): ?Message + { + return $this->app->api()->messages->getMessage($chatId, $messageId); + } + + public function forwardMessage(int $chatId, $messageId, ?int $sourceChatId = null, bool $notify = true): ?Message + { + return $this->app->api()->messages->forwardMessage($chatId, $messageId, $sourceChatId, $notify); + } + + /** + * @param array|null $attachments + */ + public function editMessage(int $chatId, int $messageId, string $text, ?array $attachments = null): Message + { + return $this->app->api()->messages->editMessage($chatId, $messageId, $text, $attachments); + } + + /** + * @return list|null + */ + public function fetchHistory( + int $chatId, + int $forward = 0, + int $backward = 40, + int $backwardTime = 0, + int $forwardTime = 0, + ?int $fromTime = null, + string $itemType = ItemType::REGULAR, + bool $getChat = false, + bool $getMessages = true, + bool $interactive = false + ): ?array { + return $this->app->api()->messages->fetchHistory( + $chatId, + $forward, + $backward, + $backwardTime, + $forwardTime, + $fromTime, + $itemType, + $getChat, + $getMessages, + $interactive + ); + } + + /** + * @param list $messageIds + */ + public function deleteMessage(int $chatId, array $messageIds, bool $forMe): bool + { + return $this->app->api()->messages->deleteMessage($chatId, $messageIds, $forMe); + } + + public function pinMessage(int $chatId, int $messageId, bool $notifyPin): bool + { + return $this->app->api()->messages->pinMessage($chatId, $messageId, $notifyPin); + } + + public function addReaction(int $chatId, string $messageId, string $reaction): ?ReactionInfo + { + return $this->app->api()->messages->addReaction($chatId, $messageId, $reaction); + } + + /** + * @param list $messageIds + * @return array|null + */ + public function getReactions(int $chatId, array $messageIds): ?array + { + return $this->app->api()->messages->getReactions($chatId, $messageIds); + } + + public function removeReaction(int $chatId, string $messageId): ?ReactionInfo + { + return $this->app->api()->messages->removeReaction($chatId, $messageId); + } + + public function readMessage($messageId, int $chatId): ReadState + { + return $this->app->api()->messages->readMessage($messageId, $chatId); + } + + public function getVideoById(int $chatId, $messageId, int $videoId): ?VideoRequest + { + return $this->app->api()->messages->getVideoById($chatId, $messageId, $videoId); + } + + public function getFileById(int $chatId, $messageId, int $fileId): ?FileRequest + { + return $this->app->api()->messages->getFileById($chatId, $messageId, $fileId); + } + + public function getBotInitData(int $botId, ?int $chatId = null, ?string $startParam = null): InitData + { + return $this->app->api()->bots->getInitData($botId, $chatId, $startParam); + } + + /** + * @param list> $events + */ + public function sendTelemetryEvents(array $events): bool + { + return $this->app->api()->telemetry->sendEvents($events); + } + + public function sendTelemetryLogin(?int $userId = null, ?int $sessionId = null): bool + { + $resolvedUserId = $userId; + if ($resolvedUserId === null && $this->loginResponse !== null) { + $resolvedUserId = $this->userIdFromLoginResponse($this->loginResponse); + } + + if ($resolvedUserId === null) { + return false; + } + + return $this->app->api()->telemetry->login($resolvedUserId, $sessionId); + } + + /** + * @param list $chats + */ + public function sendTelemetryNavigationSession( + ?int $userId = null, + ?int $sessionId = null, + ?RouteProfile $profile = null, + array $chats = [] + ): bool { + $resolvedUserId = $userId; + if ($resolvedUserId === null && $this->loginResponse !== null) { + $resolvedUserId = $this->userIdFromLoginResponse($this->loginResponse); + } + + if ($resolvedUserId === null) { + return false; + } + + return $this->app->api()->telemetry->sendPlannedNavigation($resolvedUserId, $sessionId, $profile, $chats); + } + + public function authorizeQrLogin(string $qrLink): bool + { + return $this->app->api()->auth->authorizeQrLogin($qrLink); + } + + public function setTwoFactor( + string $password, + ?string $email = null, + ?string $hint = null, + ?EmailCodeProviderInterface $emailCodeProvider = null + ): bool { + return $this->app->api()->auth->setTwoFactor($password, $email, $hint, $emailCodeProvider); + } + + public function removeTwoFactor(string $password): bool + { + return $this->app->api()->auth->removeTwoFactor($password); + } + + public function changePassword(string $passwordOld, string $passwordNew): bool + { + return $this->app->api()->auth->changePassword($passwordOld, $passwordNew); + } + + public function checkTwoFactor(): bool + { + return $this->app->api()->auth->checkTwoFactor(); + } + + /** + * @param list|null $participantIds + * @return array{0: Chat, 1: Message}|null + */ + public function createGroup(string $name, ?array $participantIds = null, bool $notify = true): ?array + { + return $this->app->api()->chats->createGroup($name, $participantIds, $notify); + } + + /** + * @param list $userIds + */ + public function inviteUsersToGroup(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + return $this->app->api()->chats->inviteUsersToGroup($chatId, $userIds, $showHistory); + } + + /** + * @param list $userIds + */ + public function inviteUsersToChannel(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + return $this->app->api()->chats->inviteUsersToChannel($chatId, $userIds, $showHistory); + } + + /** + * @param list $userIds + */ + public function removeUsersFromGroup(int $chatId, array $userIds, int $cleanMsgPeriod): bool + { + return $this->app->api()->chats->removeUsersFromGroup($chatId, $userIds, $cleanMsgPeriod); + } + + public function changeGroupSettings( + int $chatId, + ?bool $allCanPinMessage = null, + ?bool $onlyOwnerCanChangeIconTitle = null, + ?bool $onlyAdminCanAddMember = null, + ?bool $onlyAdminCanCall = null, + ?bool $membersCanSeePrivateLink = null + ): void { + $this->app->api()->chats->changeGroupSettings( + $chatId, + $allCanPinMessage, + $onlyOwnerCanChangeIconTitle, + $onlyAdminCanAddMember, + $onlyAdminCanCall, + $membersCanSeePrivateLink + ); + } + + public function changeGroupProfile(int $chatId, ?string $name, ?string $description = null): void + { + $this->app->api()->chats->changeGroupProfile($chatId, $name, $description); + } + + public function joinGroup(string $link): Chat + { + return $this->app->api()->chats->joinGroup($link); + } + + public function joinChannel(string $link): Chat + { + return $this->app->api()->chats->joinChannel($link); + } + + public function resolveGroupByLink(string $link): ?Chat + { + return $this->app->api()->chats->resolveGroupByLink($link); + } + + public function reworkInviteLink(int $chatId): Chat + { + return $this->app->api()->chats->reworkInviteLink($chatId); + } + + /** + * @param list $chatIds + * @return list + */ + public function getChats(array $chatIds): array + { + return $this->app->api()->chats->getChats($chatIds); + } + + public function getChat(int $chatId): Chat + { + return $this->app->api()->chats->getChat($chatId); + } + + public function leaveGroup(int $chatId): void + { + $this->app->api()->chats->leaveGroup($chatId); + } + + public function leaveChannel(int $chatId): void + { + $this->app->api()->chats->leaveChannel($chatId); + } + + /** + * @return list + */ + public function fetchChats(?int $marker = null): array + { + return $this->app->api()->chats->fetchChats($marker); + } + + /** + * @return list + */ + public function getJoinRequests(int $chatId, int $count = 100): array + { + return $this->app->api()->chats->getJoinRequests($chatId, $count); + } + + /** + * @param list $userIds + */ + public function confirmJoinRequests(int $chatId, array $userIds, bool $showHistory = true): ?Chat + { + return $this->app->api()->chats->confirmJoinRequests($chatId, $userIds, $showHistory); + } + + public function confirmJoinRequest(int $chatId, int $userId, bool $showHistory = true): ?Chat + { + return $this->app->api()->chats->confirmJoinRequest($chatId, $userId, $showHistory); + } + + /** + * @param list $userIds + */ + public function declineJoinRequests(int $chatId, array $userIds): ?Chat + { + return $this->app->api()->chats->declineJoinRequests($chatId, $userIds); + } + + public function declineJoinRequest(int $chatId, int $userId): ?Chat + { + return $this->app->api()->chats->declineJoinRequest($chatId, $userId); + } + + public function deleteChat(int $chatId, ?int $lastEventTime = null, bool $forAll = true): void + { + $this->app->api()->chats->deleteChat($chatId, $lastEventTime, $forAll); + } + + public function getCachedUser(int $userId): ?User + { + return $this->app->api()->users->getCachedUser($userId); + } + + /** + * @param list $userIds + * @return list + */ + public function getUsers(array $userIds): array + { + return $this->app->api()->users->getUsers($userIds); + } + + public function getUser(int $userId): ?User + { + return $this->app->api()->users->getUser($userId); + } + + /** + * @param list $userIds + * @return list + */ + public function fetchUsers(array $userIds): array + { + return $this->app->api()->users->fetchUsers($userIds); + } + + public function searchByPhone(string $phone): User + { + return $this->app->api()->users->searchByPhone($phone); + } + + /** + * @return list + */ + public function getSessions(): array + { + return $this->app->api()->users->getSessions(); + } + + public function addContact(int $contactId): User + { + return $this->app->api()->users->addContact($contactId); + } + + public function removeContact(int $contactId): bool + { + return $this->app->api()->users->removeContact($contactId); + } + + /** + * @param list $contacts + * @return list + */ + public function importContacts(array $contacts): array + { + return $this->app->api()->users->importContacts($contacts); + } + + public function getChatId(int $firstUserId, int $secondUserId): int + { + return $this->app->api()->users->getChatId($firstUserId, $secondUserId); + } + + public function requestProfilePhotoUploadUrl(): string + { + return $this->app->api()->account->requestProfilePhotoUploadUrl(); + } + + public function changeProfile( + string $firstName, + ?string $lastName = null, + ?string $description = null, + $photo = null, + ?string $photoToken = null + ): bool { + return $this->app->api()->account->changeProfile($firstName, $lastName, $description, $photo, $photoToken); + } + + public function uploadPhoto(Photo $photo, bool $profile = false): AttachPhotoPayload + { + return $this->app->api()->uploads->uploadPhoto($photo, $profile); + } + + public function uploadVideo(Video $video): VideoAttachPayload + { + return $this->app->api()->uploads->uploadVideo($video); + } + + public function uploadFile(File $file): AttachFilePayload + { + return $this->app->api()->uploads->uploadFile($file); + } + + /** + * @param list $chatInclude + * @param list|null $filters + */ + public function createFolder(string $title, array $chatInclude, ?array $filters = null): FolderUpdate + { + return $this->app->api()->account->createFolder($title, $chatInclude, $filters); + } + + public function getFolders(int $folderSync = 0): FolderList + { + return $this->app->api()->account->getFolders($folderSync); + } + + /** + * @param list|null $chatInclude + * @param list|null $filters + * @param list|null $options + */ + public function updateFolder( + string $folderId, + string $title, + ?array $chatInclude = null, + ?array $filters = null, + ?array $options = null + ): FolderUpdate { + return $this->app->api()->account->updateFolder($folderId, $title, $chatInclude, $filters, $options); + } + + public function deleteFolder(string $folderId): FolderUpdate + { + return $this->app->api()->account->deleteFolder($folderId); + } + + public function closeAllSessions(): bool + { + return $this->app->api()->account->closeAllSessions(); + } + + public function logout(): bool + { + return $this->app->api()->account->logout(); + } + + public function router(): Router + { + return $this->router; + } + + public function connection(): ConnectionManager + { + return $this->connection; + } + + public function app(): App + { + return $this->app; + } + + public function loginResponse(): ?LoginResponse + { + return $this->loginResponse; + } + + private function authenticateIfConfigured(bool $force = false): void + { + if ($this->loginResponse !== null && !$force) { + return; + } + + $session = $this->app->store()->loadSession(); + $hasAuthInput = $session !== null || $this->options->token !== null || $this->options->authFlow !== null; + if (!$hasAuthInput) { + return; + } + + $deviceId = $session !== null ? $session->deviceId : $this->options->deviceId; + if ($session !== null && $session->mtInstanceId !== null && $session->mtInstanceId !== '') { + $this->options->mtInstanceId = $session->mtInstanceId; + } + $this->app->api()->session->handshake($this->options->mtInstanceId, $this->options->userAgent, $deviceId); + + if ($session === null) { + if ($this->options->token !== null) { + $session = new SessionInfo([ + 'token' => $this->options->token, + 'deviceId' => $this->options->deviceId, + 'phone' => $this->options->phone !== null ? $this->options->phone : '', + 'mtInstanceId' => $this->options->mtInstanceId, + ]); + } elseif ($this->options->authFlow !== null) { + $result = $this->options->authFlow->authenticate($this->app->api()->auth, $this->options); + if ($result->token === null || $result->token === '') { + throw new PHPMaxException('Authentication failed: no token received'); + } + $session = new SessionInfo([ + 'token' => $result->token, + 'deviceId' => $this->options->deviceId, + 'phone' => $this->options->phone !== null ? $this->options->phone : '', + 'mtInstanceId' => $this->options->mtInstanceId, + ]); + } + + if ($session !== null) { + $this->app->store()->saveSession($session); + } + } + + if ($session === null) { + return; + } + + $this->app->setSession($session); + $login = $this->app->api()->auth->login($this->options->userAgent); + + $current = $this->app->session(); + if ($current !== null && $login->token !== null && $login->token !== $current->token) { + $this->app->store()->updateToken($current->token, $login->token); + $current = new SessionInfo([ + 'token' => $login->token, + 'deviceId' => $current->deviceId, + 'phone' => $current->phone, + 'mtInstanceId' => $current->mtInstanceId, + 'sync' => $current->sync, + ]); + $this->app->setSession($current); + } + + $this->loginResponse = $this->cacheLoginState($login); + $this->sendLoginTelemetry($this->loginResponse); + } + + private function cacheLoginState(LoginResponse $login): LoginResponse + { + return $this->app->applyLoginState($login); + } + + private function handleRunDisconnect(ProtocolException $exception, ExecutionBudget $budget): bool + { + $this->close(); + + if (!$this->options->reconnect) { + $this->emitDisconnect($exception, false, 0.0); + + return false; + } + + $delay = $this->options->reconnectDelay; + $this->emitDisconnect($exception, true, $delay); + + if ($delay > 0.0) { + $sleep = min($delay, $budget->remaining()); + if ($sleep > 0.0) { + usleep((int) floor($sleep * 1000000)); + } + } + + if ($budget->expired()) { + return true; + } + + $this->open(); + + return true; + } + + private function nextPingDeadline(): ?float + { + if ($this->options->pingInterval <= 0.0) { + return null; + } + + return microtime(true) + $this->options->pingInterval; + } + + private function runReadTimeout(ExecutionBudget $budget, ?float $nextPingAt): float + { + $timeout = min(max(0.001, $this->options->requestTimeout), max(0.001, $budget->remaining())); + if ($nextPingAt !== null) { + $timeout = min($timeout, max(0.001, $nextPingAt - microtime(true))); + } + + return $timeout; + } + + private function runPingIfDue(?float $nextPingAt, ExecutionBudget $budget, ?float $startedAt = null): ?float + { + if ($nextPingAt === null || microtime(true) < $nextPingAt || $budget->expired()) { + return $nextPingAt; + } + + $startedAt = $startedAt !== null ? $startedAt : microtime(true); + $this->traceRuntime('ping_due', $startedAt, $budget, [ + 'ping_interval_ms' => (int) round($this->options->pingInterval * 1000), + ]); + try { + $this->app->invoke( + Opcode::PING, + ['interactive' => true], + Command::REQUEST, + min(max(0.001, $this->options->requestTimeout), max(0.001, $budget->remaining())) + ); + $this->traceRuntime('ping_ok', $startedAt, $budget, []); + } catch (ProtocolException $e) { + $this->traceRuntime('ping_error', $startedAt, $budget, [ + 'error' => $this->shortError($e), + 'timeout' => $this->isTimeoutException($e) ? 1 : 0, + ]); + if (!$this->isTimeoutException($e) && !$this->handleRunDisconnect($e, $budget)) { + $this->traceRuntime('ping_error_unhandled', $startedAt, $budget, [ + 'error' => $this->shortError($e), + ]); + throw $e; + } + $this->traceRuntime('ping_error_reconnected', $startedAt, $budget, [ + 'connection_open' => $this->connection->isOpen() ? 1 : 0, + ]); + } + + return $this->nextPingDeadline(); + } + + /** + * @param array $context + */ + private function traceRuntime(string $event, float $startedAt, ExecutionBudget $budget, array $context): void + { + if (!is_callable($this->options->debugLogger)) { + return; + } + + $safe = [ + 'event' => $event, + 'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'remaining_ms' => (int) round(max(0.0, $budget->remaining()) * 1000), + ]; + foreach ($context as $key => $value) { + if (is_scalar($value) || $value === null) { + $safe[$key] = $value; + } + } + + call_user_func($this->options->debugLogger, $event, $safe); + } + + private function millisecondsUntil(?float $deadline): int + { + if ($deadline === null) { + return -1; + } + return (int) round(max(0.0, $deadline - microtime(true)) * 1000); + } + + private function shortError(ProtocolException $exception): string + { + return substr(str_replace(["\r", "\n"], ' ', $exception->getMessage()), 0, 180); + } + + /** + * @param array|null $payload + */ + private function payloadKeySummary(?array $payload): string + { + if ($payload === null || $payload === []) { + return ''; + } + $keys = array_slice(array_map('strval', array_keys($payload)), 0, 12); + sort($keys); + return implode(',', $keys); + } + + private function isTimeoutException(ProtocolException $exception): bool + { + return stripos($exception->getMessage(), 'timed out') !== false; + } + + private function sendLoginTelemetry(LoginResponse $login): void + { + if (!$this->options->telemetry) { + return; + } + + $userId = $this->userIdFromLoginResponse($login); + if ($userId === null) { + return; + } + + try { + $this->app->api()->telemetry->login($userId, $this->options->clientSessionId); + } catch (Throwable $e) { + return; + } + } + + private function userIdFromLoginResponse(LoginResponse $login): ?int + { + if ($login->profile === null || $login->profile->contact === null || $login->profile->contact->id === null) { + return null; + } + + return $login->profile->contact->id; + } +} diff --git a/src/PHPMax/Config/ClientOptions.php b/src/PHPMax/Config/ClientOptions.php new file mode 100644 index 0000000..f20bc75 --- /dev/null +++ b/src/PHPMax/Config/ClientOptions.php @@ -0,0 +1,153 @@ + $options + */ + public function __construct(array $options = []) + { + $this->phone = isset($options['phone']) ? (string) $options['phone'] : null; + $this->workDir = isset($options['workDir']) ? (string) $options['workDir'] : '.'; + $this->sessionName = isset($options['sessionName']) ? (string) $options['sessionName'] : 'session.json'; + $this->token = isset($options['token']) ? (string) $options['token'] : null; + $this->registrationFirstName = isset($options['registrationFirstName']) ? (string) $options['registrationFirstName'] : null; + $this->registrationLastName = isset($options['registrationLastName']) ? (string) $options['registrationLastName'] : null; + $this->host = isset($options['host']) ? (string) $options['host'] : 'api.oneme.ru'; + $this->assertNonEmptyString($this->host, 'host'); + $this->port = isset($options['port']) ? (int) $options['port'] : 443; + $this->assertPort($this->port, 'port'); + $this->wsUrl = isset($options['wsUrl']) ? (string) $options['wsUrl'] : 'wss://ws-api.oneme.ru/websocket'; + $this->proxy = isset($options['proxy']) ? (string) $options['proxy'] : null; + $this->useSsl = array_key_exists('useSsl', $options) ? (bool) $options['useSsl'] : true; + $this->requestTimeout = isset($options['requestTimeout']) ? max(0.001, (float) $options['requestTimeout']) : 30.0; + $this->pingInterval = array_key_exists('pingInterval', $options) ? max(0.0, (float) $options['pingInterval']) : 30.0; + $this->connectTimeout = isset($options['connectTimeout']) ? max(0.001, (float) $options['connectTimeout']) : 30.0; + $this->telemetry = array_key_exists('telemetry', $options) ? (bool) $options['telemetry'] : false; + $this->reconnect = array_key_exists('reconnect', $options) ? (bool) $options['reconnect'] : true; + $this->reconnectDelay = isset($options['reconnectDelay']) ? max(0.0, (float) $options['reconnectDelay']) : 1.0; + $this->qrPollTimeout = isset($options['qrPollTimeout']) ? max(0.001, (float) $options['qrPollTimeout']) : 180.0; + $this->executionSafetyMargin = isset($options['executionSafetyMargin']) ? max(0.0, (float) $options['executionSafetyMargin']) : 3.0; + $this->deviceId = isset($options['deviceId']) ? (string) $options['deviceId'] : $this->uuidV4(); + $this->mtInstanceId = isset($options['mtInstanceId']) ? (string) $options['mtInstanceId'] : $this->uuidV4(); + $this->clientSessionId = isset($options['clientSessionId']) ? (int) $options['clientSessionId'] : random_int(1, 70); + $this->userAgent = isset($options['userAgent']) && $options['userAgent'] instanceof MobileUserAgentPayload + ? $options['userAgent'] + : MobileUserAgentPayload::defaultAndroid(); + $this->sync = isset($options['sync']) && $options['sync'] instanceof SyncOverrides + ? $options['sync'] + : new SyncOverrides(); + $this->store = isset($options['store']) && $options['store'] instanceof SessionStoreInterface + ? $options['store'] + : null; + $this->authFlow = isset($options['authFlow']) && $options['authFlow'] instanceof AuthFlowInterface + ? $options['authFlow'] + : null; + $this->httpUploader = isset($options['httpUploader']) && $options['httpUploader'] instanceof HttpUploaderInterface + ? $options['httpUploader'] + : null; + $this->uploadChunkSize = isset($options['uploadChunkSize']) ? max(1, (int) $options['uploadChunkSize']) : 1024 * 1024; + $this->uploadProcessingTimeout = isset($options['uploadProcessingTimeout']) ? max(0.001, (float) $options['uploadProcessingTimeout']) : 60.0; + $this->uploadHttpTimeout = isset($options['uploadHttpTimeout']) ? max(0.001, (float) $options['uploadHttpTimeout']) : 900.0; + $this->debugLogger = isset($options['debugLogger']) && is_callable($options['debugLogger']) + ? $options['debugLogger'] + : null; + } + + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + private function assertNonEmptyString(string $value, string $name): void + { + if (trim($value) === '') { + throw new PHPMaxException('Client option `' . $name . '` must not be empty'); + } + } + + private function assertPort(int $port, string $name): void + { + if ($port <= 0 || $port > 65535) { + throw new PHPMaxException('Client option `' . $name . '` must be between 1 and 65535'); + } + } +} diff --git a/src/PHPMax/Dispatch/ErrorContext.php b/src/PHPMax/Dispatch/ErrorContext.php new file mode 100644 index 0000000..69a7416 --- /dev/null +++ b/src/PHPMax/Dispatch/ErrorContext.php @@ -0,0 +1,32 @@ +client = $client; + $this->eventType = $eventType; + $this->event = $event; + $this->router = $router; + $this->handler = $handler; + } +} diff --git a/src/PHPMax/Dispatch/ErrorScope.php b/src/PHPMax/Dispatch/ErrorScope.php new file mode 100644 index 0000000..3870a2c --- /dev/null +++ b/src/PHPMax/Dispatch/ErrorScope.php @@ -0,0 +1,15 @@ +cmd !== Command::REQUEST || $frame->payload === null || $frame->payload === []) { + return $frame; + } + + if ($eventType === EventType::MESSAGE_NEW || $eventType === EventType::MESSAGE_EDIT) { + return Message::fromArray($frame->payload); + } + if ($eventType === EventType::CHAT_UPDATE) { + if ( + !array_key_exists('chat', $frame->payload) + || !is_array($frame->payload['chat']) + || $frame->payload['chat'] === [] + ) { + throw new ValidationException('Invalid chat update event payload: missing `chat` object'); + } + return Chat::fromArray($frame->payload['chat']); + } + if ($eventType === EventType::MESSAGE_DELETE) { + return MessageDeleteEvent::fromArray($frame->payload); + } + if ($eventType === EventType::MESSAGE_READ) { + return MessageReadEvent::fromArray($frame->payload); + } + if ($eventType === EventType::TYPING) { + return TypingEvent::fromArray($frame->payload); + } + if ($eventType === EventType::PRESENCE) { + return PresenceEvent::fromArray($frame->payload); + } + if ($eventType === EventType::REACTION_UPDATE) { + return ReactionUpdateEvent::fromArray($frame->payload); + } + if ($eventType === EventType::VIDEO_READY) { + return VideoUploadSignal::fromArray($frame->payload); + } + if ($eventType === EventType::FILE_READY) { + return FileUploadSignal::fromArray($frame->payload); + } + + return $frame; + } +} diff --git a/src/PHPMax/Dispatch/EventResolver.php b/src/PHPMax/Dispatch/EventResolver.php new file mode 100644 index 0000000..0d48d2c --- /dev/null +++ b/src/PHPMax/Dispatch/EventResolver.php @@ -0,0 +1,89 @@ +cmd !== Command::REQUEST) { + return null; + } + + switch ($frame->opcode) { + case Opcode::NOTIF_MESSAGE: + case Opcode::MSG_EDIT: + return $this->resolveMessage($frame); + case Opcode::NOTIF_CHAT: + return EventType::CHAT_UPDATE; + case Opcode::NOTIF_MSG_DELETE: + return EventType::MESSAGE_DELETE; + case Opcode::NOTIF_ATTACH: + return $this->resolveAttach($frame); + case Opcode::NOTIF_TYPING: + return EventType::TYPING; + case Opcode::NOTIF_MARK: + return EventType::MESSAGE_READ; + case Opcode::NOTIF_PRESENCE: + return EventType::PRESENCE; + case Opcode::NOTIF_MSG_REACTIONS_CHANGED: + return EventType::REACTION_UPDATE; + } + + return null; + } + + private function resolveMessage(InboundFrame $frame): ?string + { + if ($frame->payload === null) { + return null; + } + + try { + $message = Message::fromArray($frame->payload); + } catch (ValidationException $e) { + return null; + } + + if ($message->status === MessageStatus::EDITED) { + return EventType::MESSAGE_EDIT; + } + if ($message->status === MessageStatus::REMOVED) { + return EventType::MESSAGE_DELETE; + } + + return EventType::MESSAGE_NEW; + } + + private function resolveAttach(InboundFrame $frame): ?string + { + if ($frame->payload === null) { + return null; + } + + try { + FileUploadSignal::fromArray($frame->payload); + return EventType::FILE_READY; + } catch (ValidationException $e) { + } + + try { + VideoUploadSignal::fromArray($frame->payload); + return EventType::VIDEO_READY; + } catch (ValidationException $e) { + } + + return null; + } +} diff --git a/src/PHPMax/Dispatch/EventType.php b/src/PHPMax/Dispatch/EventType.php new file mode 100644 index 0000000..14c5b31 --- /dev/null +++ b/src/PHPMax/Dispatch/EventType.php @@ -0,0 +1,27 @@ +}>> */ + private $handlers = []; + /** @var list */ + private $startHandlers = []; + /** @var list */ + private $children = []; + /** @var list */ + private $errorHandlers = []; + /** @var list */ + private $disconnectHandlers = []; + /** @var EventResolver */ + private $resolver; + /** @var EventMapper */ + private $mapper; + + public function __construct(?EventResolver $resolver = null, ?EventMapper $mapper = null) + { + $this->resolver = $resolver ?: new EventResolver(); + $this->mapper = $mapper ?: new EventMapper(); + } + + public function on(string $eventType, callable $handler, callable ...$filters): self + { + if (!isset($this->handlers[$eventType])) { + $this->handlers[$eventType] = []; + } + $this->handlers[$eventType][] = [$handler, $filters]; + + return $this; + } + + public function onRaw(callable $handler, callable ...$filters): self + { + return $this->on(EventType::RAW, $handler, ...$filters); + } + + public function onMessage(callable $handler, callable ...$filters): self + { + return $this->on(EventType::MESSAGE_NEW, $handler, ...$filters); + } + + public function onMessageEdit(callable $handler, callable ...$filters): self + { + return $this->on(EventType::MESSAGE_EDIT, $handler, ...$filters); + } + + public function onMessageDelete(callable $handler, callable ...$filters): self + { + return $this->on(EventType::MESSAGE_DELETE, $handler, ...$filters); + } + + public function onMessageRead(callable $handler, callable ...$filters): self + { + return $this->on(EventType::MESSAGE_READ, $handler, ...$filters); + } + + public function onTyping(callable $handler, callable ...$filters): self + { + return $this->on(EventType::TYPING, $handler, ...$filters); + } + + public function onPresence(callable $handler, callable ...$filters): self + { + return $this->on(EventType::PRESENCE, $handler, ...$filters); + } + + public function onReactionUpdate(callable $handler, callable ...$filters): self + { + return $this->on(EventType::REACTION_UPDATE, $handler, ...$filters); + } + + public function onChatUpdate(callable $handler, callable ...$filters): self + { + return $this->on(EventType::CHAT_UPDATE, $handler, ...$filters); + } + + public function onFileReady(callable $handler, callable ...$filters): self + { + return $this->on(EventType::FILE_READY, $handler, ...$filters); + } + + public function onVideoReady(callable $handler, callable ...$filters): self + { + return $this->on(EventType::VIDEO_READY, $handler, ...$filters); + } + + public function onError(callable $handler, string $scope = ErrorScope::GLOBAL): self + { + if ($scope !== ErrorScope::GLOBAL && $scope !== ErrorScope::LOCAL) { + throw new \InvalidArgumentException('Invalid error scope: ' . $scope); + } + $this->errorHandlers[] = [$handler, $scope]; + + return $this; + } + + public function onDisconnect(callable $handler): self + { + $this->disconnectHandlers[] = $handler; + + return $this; + } + + public function onStart(callable $handler): self + { + $this->startHandlers[] = $handler; + + return $this; + } + + public function includeRouter(Router $router): self + { + $this->children[] = $router; + + return $this; + } + + /** + * @param mixed $client + */ + public function emitStart($client): void + { + $this->emitStartInTree($client, $this); + } + + /** + * @param mixed $client + */ + private function emitStartInTree($client, Router $root): void + { + foreach ($this->startHandlers as $handler) { + try { + $handler($client); + } catch (Throwable $e) { + $handled = $root->emitError($e, EventType::ON_START, null, $client, $this, $handler); + if (!$handled) { + throw $e; + } + } + } + foreach ($this->children as $child) { + $child->emitStartInTree($client, $root); + } + } + + /** + * @param mixed $event + * @param mixed $client + */ + public function dispatch(string $eventType, $event, $client): void + { + $this->dispatchInTree($eventType, $event, $client, $this); + } + + /** + * @param mixed $event + * @param mixed $client + */ + private function dispatchInTree(string $eventType, $event, $client, Router $root): void + { + foreach ($this->handlers[$eventType] ?? [] as $entry) { + [$handler, $filters] = $entry; + try { + if (!$this->matches($filters, $event)) { + continue; + } + $handler($event, $client); + } catch (Throwable $e) { + $handled = $root->emitError($e, $eventType, $event, $client, $this, $handler); + if (!$handled) { + throw $e; + } + } + } + + foreach ($this->children as $child) { + $child->dispatchInTree($eventType, $event, $client, $root); + } + } + + /** + * @param mixed $client + */ + public function dispatchRaw(InboundFrame $frame, $client): void + { + $this->dispatch(EventType::RAW, $frame, $client); + } + + /** + * @param mixed $client + */ + public function dispatchFrame(InboundFrame $frame, $client, bool $dispatchRaw = true): void + { + $eventType = $this->resolver->resolve($frame); + if ($eventType !== null) { + $event = $this->mapper->map($eventType, $frame); + $this->dispatch($eventType, $this->bindEvent($event, $client), $client); + } + + if ($dispatchRaw) { + $this->dispatchRaw($frame, $client); + } + } + + /** + * @param mixed $event + * @param mixed $client + */ + public function emitError(Throwable $exception, string $eventType, $event, $client, Router $failedRouter, ?callable $handler): bool + { + $handled = false; + $context = new ErrorContext($client, $eventType, $event, $failedRouter, $handler); + foreach ($this->errorEntries() as $entry) { + /** @var Router $owner */ + $owner = $entry[0]; + /** @var callable $errorHandler */ + $errorHandler = $entry[1]; + /** @var string $scope */ + $scope = $entry[2]; + + if ($scope === ErrorScope::LOCAL && $owner !== $failedRouter) { + continue; + } + + $handled = true; + try { + $errorHandler($exception, $context); + } catch (Throwable $e) { + return false; + } + } + + return $handled; + } + + public function emitDisconnect(Throwable $exception, bool $reconnect = false, float $delay = 0.0): void + { + foreach ($this->disconnectHandlers as $handler) { + try { + $handler($exception, $reconnect, $delay); + } catch (Throwable $e) { + // Disconnect handlers must not interrupt reconnect/close flow. + } + } + + foreach ($this->children as $child) { + $child->emitDisconnect($exception, $reconnect, $delay); + } + } + + /** + * @param list $filters + * @param mixed $event + */ + private function matches(array $filters, $event): bool + { + foreach ($filters as $filter) { + if (!$filter($event)) { + return false; + } + } + + return true; + } + + /** + * @param mixed $event + * @param mixed $client + * @return mixed + */ + private function bindEvent($event, $client) + { + if (!is_object($client) || !method_exists($client, 'app')) { + return $event; + } + + $app = $client->app(); + if (!$app instanceof App) { + return $event; + } + + return Binding::bindApiModel($app, $event); + } + + /** + * @return list + */ + private function errorEntries(): array + { + $entries = []; + foreach ($this->errorHandlers as $entry) { + $entries[] = [$this, $entry[0], $entry[1]]; + } + foreach ($this->children as $child) { + foreach ($child->errorEntries() as $entry) { + $entries[] = $entry; + } + } + + return $entries; + } +} diff --git a/src/PHPMax/Domain/AccessType.php b/src/PHPMax/Domain/AccessType.php new file mode 100644 index 0000000..b014ad5 --- /dev/null +++ b/src/PHPMax/Domain/AccessType.php @@ -0,0 +1,16 @@ + PhotoAttachment::class, + AttachmentType::VIDEO => VideoAttachment::class, + AttachmentType::FILE => FileAttachment::class, + AttachmentType::CONTACT => ContactAttachment::class, + AttachmentType::STICKER => StickerAttachment::class, + AttachmentType::AUDIO => AudioAttachment::class, + AttachmentType::CONTROL => ControlAttachment::class, + AttachmentType::INLINE_KEYBOARD => InlineKeyboardAttachment::class, + AttachmentType::SHARE => ShareAttachment::class, + AttachmentType::CALL => CallAttachment::class, + ]; + + private function __construct() + { + } + + /** + * @param array $payload + * @return BaseAttachment + */ + public static function fromArray(array $payload): BaseAttachment + { + $type = $payload['_type'] ?? ($payload['type'] ?? AttachmentType::UNKNOWN); + $type = is_string($type) ? $type : AttachmentType::UNKNOWN; + $class = self::MAP[$type] ?? UnknownAttachment::class; + + return $class::fromArray($payload); + } +} diff --git a/src/PHPMax/Domain/Attachments/AudioAttachment.php b/src/PHPMax/Domain/Attachments/AudioAttachment.php new file mode 100644 index 0000000..1a3a72a --- /dev/null +++ b/src/PHPMax/Domain/Attachments/AudioAttachment.php @@ -0,0 +1,33 @@ + ['type' => 'int'], + 'audioId' => ['type' => 'int'], + 'wave' => ['type' => 'string'], + 'transcriptionStatus' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'token' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/BaseAttachment.php b/src/PHPMax/Domain/Attachments/BaseAttachment.php new file mode 100644 index 0000000..f7721b7 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/BaseAttachment.php @@ -0,0 +1,21 @@ + ['type' => 'string', 'payload' => '_type', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Attachments/CallAttachment.php b/src/PHPMax/Domain/Attachments/CallAttachment.php new file mode 100644 index 0000000..f33fd47 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/CallAttachment.php @@ -0,0 +1,26 @@ + */ + public $contactIds = []; + + protected static function schema(): array + { + return parent::schema() + [ + 'duration' => ['type' => 'int'], + 'conversationId' => ['type' => 'mixed'], + 'contactIds' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/ContactAttachment.php b/src/PHPMax/Domain/Attachments/ContactAttachment.php new file mode 100644 index 0000000..d803c5b --- /dev/null +++ b/src/PHPMax/Domain/Attachments/ContactAttachment.php @@ -0,0 +1,30 @@ + ['type' => 'int'], + 'firstName' => ['type' => 'string'], + 'lastName' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'photoUrl' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/ControlAttachment.php b/src/PHPMax/Domain/Attachments/ControlAttachment.php new file mode 100644 index 0000000..b04beeb --- /dev/null +++ b/src/PHPMax/Domain/Attachments/ControlAttachment.php @@ -0,0 +1,18 @@ + ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/FileAttachment.php b/src/PHPMax/Domain/Attachments/FileAttachment.php new file mode 100644 index 0000000..48f4553 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/FileAttachment.php @@ -0,0 +1,27 @@ + ['type' => 'int'], + 'name' => ['type' => 'string'], + 'size' => ['type' => 'int'], + 'token' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/InlineKeyboardAttachment.php b/src/PHPMax/Domain/Attachments/InlineKeyboardAttachment.php new file mode 100644 index 0000000..98ae94b --- /dev/null +++ b/src/PHPMax/Domain/Attachments/InlineKeyboardAttachment.php @@ -0,0 +1,18 @@ +|null */ + public $keyboard; + + protected static function schema(): array + { + return parent::schema() + [ + 'keyboard' => ['type' => 'array'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/PhotoAttachment.php b/src/PHPMax/Domain/Attachments/PhotoAttachment.php new file mode 100644 index 0000000..4dd40d2 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/PhotoAttachment.php @@ -0,0 +1,48 @@ + ['type' => 'string'], + 'height' => ['type' => 'int'], + 'width' => ['type' => 'int'], + 'photoId' => ['type' => 'string'], + 'photoToken' => ['type' => 'string', 'aliases' => ['token']], + 'previewData' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/ShareAttachment.php b/src/PHPMax/Domain/Attachments/ShareAttachment.php new file mode 100644 index 0000000..fb3e06f --- /dev/null +++ b/src/PHPMax/Domain/Attachments/ShareAttachment.php @@ -0,0 +1,27 @@ +|null */ + public $image; + + protected static function schema(): array + { + return parent::schema() + [ + 'url' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'image' => ['type' => 'array'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/StickerAttachment.php b/src/PHPMax/Domain/Attachments/StickerAttachment.php new file mode 100644 index 0000000..97c5286 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/StickerAttachment.php @@ -0,0 +1,48 @@ +|null */ + public $tags; + /** @var int|null */ + public $width; + /** @var int|null */ + public $setId; + /** @var int|null */ + public $time; + /** @var string|null */ + public $stickerType; + /** @var bool|null */ + public $audio; + /** @var int|null */ + public $height; + + protected static function schema(): array + { + return parent::schema() + [ + 'authorType' => ['type' => 'string'], + 'lottieUrl' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'stickerId' => ['type' => 'int'], + 'tags' => ['type' => 'list'], + 'width' => ['type' => 'int'], + 'setId' => ['type' => 'int'], + 'time' => ['type' => 'int'], + 'stickerType' => ['type' => 'string'], + 'audio' => ['type' => 'bool'], + 'height' => ['type' => 'int'], + ]; + } +} diff --git a/src/PHPMax/Domain/Attachments/UnknownAttachment.php b/src/PHPMax/Domain/Attachments/UnknownAttachment.php new file mode 100644 index 0000000..ad36487 --- /dev/null +++ b/src/PHPMax/Domain/Attachments/UnknownAttachment.php @@ -0,0 +1,34 @@ + ['type' => 'int'], + 'width' => ['type' => 'int'], + 'videoId' => ['type' => 'int'], + 'duration' => ['type' => 'int'], + 'previewData' => ['type' => 'string'], + 'token' => ['type' => 'string'], + 'thumbnail' => ['type' => 'string'], + 'videoType' => ['type' => 'int'], + ]; + } +} diff --git a/src/PHPMax/Domain/Auth/CheckCodeResponse.php b/src/PHPMax/Domain/Auth/CheckCodeResponse.php new file mode 100644 index 0000000..165bc8f --- /dev/null +++ b/src/PHPMax/Domain/Auth/CheckCodeResponse.php @@ -0,0 +1,40 @@ + ['type' => TokenAttrs::class, 'default' => static function (): TokenAttrs { + return new TokenAttrs(); + }], + 'passwordChallenge' => ['type' => PasswordChallenge::class], + ]; + } + + public function loginToken(): ?string + { + return $this->tokenAttrs !== null && $this->tokenAttrs->login !== null + ? $this->tokenAttrs->login->token + : null; + } + + public function registerToken(): ?string + { + return $this->tokenAttrs !== null && $this->tokenAttrs->registerToken !== null + ? $this->tokenAttrs->registerToken->token + : null; + } +} + diff --git a/src/PHPMax/Domain/Auth/CheckPasswordResponse.php b/src/PHPMax/Domain/Auth/CheckPasswordResponse.php new file mode 100644 index 0000000..d3a84a9 --- /dev/null +++ b/src/PHPMax/Domain/Auth/CheckPasswordResponse.php @@ -0,0 +1,35 @@ + ['type' => TokenAttrs::class, 'default' => static function (): TokenAttrs { + return new TokenAttrs(); + }], + // MAX на успешной проверке 2FA может вернуть error=false вместе с + // tokenAttrs. Храним поле без строгого string-cast, чтобы не + // ломать успешный login из-за формы внешнего API. + 'error' => ['type' => 'mixed'], + ]; + } + + public function loginToken(): ?string + { + return $this->tokenAttrs !== null && $this->tokenAttrs->login !== null + ? $this->tokenAttrs->login->token + : null; + } +} diff --git a/src/PHPMax/Domain/Auth/CheckQrResponse.php b/src/PHPMax/Domain/Auth/CheckQrResponse.php new file mode 100644 index 0000000..40ae0e6 --- /dev/null +++ b/src/PHPMax/Domain/Auth/CheckQrResponse.php @@ -0,0 +1,20 @@ + ['type' => QrStatus::class, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Auth/ConfirmRegistrationResponse.php b/src/PHPMax/Domain/Auth/ConfirmRegistrationResponse.php new file mode 100644 index 0000000..7a48e70 --- /dev/null +++ b/src/PHPMax/Domain/Auth/ConfirmRegistrationResponse.php @@ -0,0 +1,31 @@ + ['type' => 'int', 'required' => true], + 'profile' => ['type' => Profile::class, 'required' => true], + 'tokenType' => ['type' => 'string', 'required' => true], + 'token' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Auth/PasswordChallenge.php b/src/PHPMax/Domain/Auth/PasswordChallenge.php new file mode 100644 index 0000000..93310ea --- /dev/null +++ b/src/PHPMax/Domain/Auth/PasswordChallenge.php @@ -0,0 +1,24 @@ + ['type' => 'string', 'required' => true], + 'hint' => ['type' => 'string'], + ]; + } +} + diff --git a/src/PHPMax/Domain/Auth/QrStatus.php b/src/PHPMax/Domain/Auth/QrStatus.php new file mode 100644 index 0000000..8052f50 --- /dev/null +++ b/src/PHPMax/Domain/Auth/QrStatus.php @@ -0,0 +1,23 @@ + ['type' => 'int', 'required' => true], + 'loginAvailable' => ['type' => 'bool'], + ]; + } +} diff --git a/src/PHPMax/Domain/Auth/RequestQrResponse.php b/src/PHPMax/Domain/Auth/RequestQrResponse.php new file mode 100644 index 0000000..9c542da --- /dev/null +++ b/src/PHPMax/Domain/Auth/RequestQrResponse.php @@ -0,0 +1,32 @@ + ['type' => 'int', 'required' => true], + 'pollingInterval' => ['type' => 'int', 'required' => true], + 'qrLink' => ['type' => 'string', 'required' => true], + 'trackId' => ['type' => 'string', 'required' => true], + 'ttl' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Auth/StartAuthResponse.php b/src/PHPMax/Domain/Auth/StartAuthResponse.php new file mode 100644 index 0000000..addf5f1 --- /dev/null +++ b/src/PHPMax/Domain/Auth/StartAuthResponse.php @@ -0,0 +1,33 @@ + ['type' => 'string', 'required' => true], + 'codeLength' => ['type' => 'int', 'required' => true], + 'requestMaxDuration' => ['type' => 'int', 'required' => true], + 'requestCountLeft' => ['type' => 'int', 'required' => true], + 'altActionDuration' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Auth/Token.php b/src/PHPMax/Domain/Auth/Token.php new file mode 100644 index 0000000..2f07f58 --- /dev/null +++ b/src/PHPMax/Domain/Auth/Token.php @@ -0,0 +1,21 @@ + ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Auth/TokenAttrs.php b/src/PHPMax/Domain/Auth/TokenAttrs.php new file mode 100644 index 0000000..bfe9f0e --- /dev/null +++ b/src/PHPMax/Domain/Auth/TokenAttrs.php @@ -0,0 +1,24 @@ + ['type' => Token::class, 'payload' => 'LOGIN'], + 'registerToken' => ['type' => Token::class, 'payload' => 'REGISTER'], + ]; + } +} + diff --git a/src/PHPMax/Domain/Chat.php b/src/PHPMax/Domain/Chat.php new file mode 100644 index 0000000..6626740 --- /dev/null +++ b/src/PHPMax/Domain/Chat.php @@ -0,0 +1,315 @@ + */ + public $participants = []; + /** @var string|null */ + public $title; + /** @var string|null */ + public $baseRawIconUrl; + /** @var string|null */ + public $baseIconUrl; + /** @var Message|null */ + public $lastMessage; + /** @var Message|null */ + public $pinnedMessage; + /** @var int */ + public $lastEventTime = 0; + /** @var int */ + public $lastDelayedUpdateTime = 0; + /** @var int */ + public $lastFireDelayedErrorTime = 0; + /** @var int */ + public $created = 0; + /** @var int|null */ + public $newMessages; + /** @var string|null */ + public $link; + /** @var string|null */ + public $access; + /** @var int|null */ + public $restrictions; + /** @var int */ + public $participantsCount = 0; + /** @var string|null */ + public $description; + /** @var mixed */ + public $options; + /** @var int */ + public $joinTime = 0; + /** @var int|null */ + public $invitedBy; + /** @var int */ + public $modified = 0; + /** @var int */ + public $messagesCount = 0; + /** @var bool|null */ + public $hasBots; + /** @var int|null */ + public $prevMessageId; + /** @var array */ + public $adminParticipants = []; + /** @var list */ + public $admins = []; + /** @var int|null */ + public $cid; + /** @var MessageService|null */ + private $messageActions; + /** @var ChatService|null */ + private $chatActions; + + public function bind(MessageService $messageActions, ChatService $chatActions): self + { + $this->messageActions = $messageActions; + $this->chatActions = $chatActions; + if ($this->lastMessage !== null) { + $this->lastMessage->bind($messageActions); + } + if ($this->pinnedMessage !== null) { + $this->pinnedMessage->bind($messageActions); + } + + return $this; + } + + /** + * @param array|null $attachments + */ + public function answer(string $text, ?int $replyTo = null, ?array $attachments = null, bool $notify = true): ?Message + { + [$messageActions] = $this->bound(); + + return $messageActions->sendMessage($this->requireChatId(), $text, $replyTo, $attachments, $notify); + } + + /** + * @return list|null + */ + public function history( + int $forward = 0, + int $backward = 40, + int $backwardTime = 0, + int $forwardTime = 0, + ?int $from = null, + string $itemType = ItemType::REGULAR, + bool $getChat = false, + bool $getMessages = true, + bool $interactive = false + ): ?array { + [$messageActions] = $this->bound(); + + return $messageActions->fetchHistory( + $this->requireChatId(), + $forward, + $backward, + $backwardTime, + $forwardTime, + $from, + $itemType, + $getChat, + $getMessages, + $interactive + ); + } + + public function getMessage(int $messageId): ?Message + { + [$messageActions] = $this->bound(); + + return $messageActions->getMessage($this->requireChatId(), $messageId); + } + + /** + * @param list $messageIds + * @return list + */ + public function getMessages(array $messageIds): array + { + [$messageActions] = $this->bound(); + + return $messageActions->getMessages($this->requireChatId(), $messageIds); + } + + public function leave(): void + { + [, $chatActions] = $this->bound(); + $chatId = $this->requireChatId(); + if ($this->type === ChatType::DIALOG) { + throw new RuntimeException('Cannot leave dialog'); + } + if ($this->type === ChatType::CHAT) { + $chatActions->leaveGroup($chatId); + return; + } + if ($this->type === ChatType::CHANNEL) { + $chatActions->leaveChannel($chatId); + return; + } + + throw new InvalidArgumentException('Unknown chat type=' . (string) $this->type); + } + + public function delete(bool $forAll = true): void + { + [, $chatActions] = $this->bound(); + + $chatActions->deleteChat($this->requireChatId(), $this->lastEventTime, $forAll); + } + + /** + * @param list $userIds + */ + public function invite(array $userIds, bool $showHistory = true): ?Chat + { + [, $chatActions] = $this->bound(); + $chatId = $this->requireChatId(); + if ($this->type === ChatType::CHAT) { + return $chatActions->inviteUsersToGroup($chatId, $userIds, $showHistory); + } + if ($this->type === ChatType::CHANNEL) { + return $chatActions->inviteUsersToChannel($chatId, $userIds, $showHistory); + } + + throw new InvalidArgumentException('Unknown chat type=' . (string) $this->type); + } + + /** + * @param list $userIds + */ + public function removeUsers(array $userIds, int $cleanMsgPeriod = 0): bool + { + [, $chatActions] = $this->bound(); + + return $chatActions->removeUsersFromGroup($this->requireChatId(), $userIds, $cleanMsgPeriod); + } + + public function pinMessage(int $messageId, bool $notifyPin = true): bool + { + [$messageActions] = $this->bound(); + + return $messageActions->pinMessage($this->requireChatId(), $messageId, $notifyPin); + } + + public function updateSettings( + ?bool $allCanPinMessage = null, + ?bool $onlyOwnerCanChangeIconTitle = null, + ?bool $onlyAdminCanAddMember = null, + ?bool $onlyAdminCanCall = null, + ?bool $membersCanSeePrivateLink = null + ): void { + [, $chatActions] = $this->bound(); + $chatActions->changeGroupSettings( + $this->requireChatId(), + $allCanPinMessage, + $onlyOwnerCanChangeIconTitle, + $onlyAdminCanAddMember, + $onlyAdminCanCall, + $membersCanSeePrivateLink + ); + } + + public function reworkInviteLink(): Chat + { + [, $chatActions] = $this->bound(); + + return $chatActions->reworkInviteLink($this->requireChatId()); + } + + public function isDialog(): bool + { + return $this->type === ChatType::DIALOG; + } + + public function isGroup(): bool + { + return $this->type === ChatType::CHAT; + } + + public function isChannel(): bool + { + return $this->type === ChatType::CHANNEL; + } + + protected static function schema(): array + { + return [ + 'id' => ['type' => 'int', 'required' => true], + 'type' => ['type' => 'string', 'required' => true], + 'status' => ['type' => 'string', 'required' => true], + 'owner' => ['type' => 'int', 'required' => true], + 'participants' => ['type' => 'array', 'default' => static function (): array { + return []; + }], + 'title' => ['type' => 'string'], + 'baseRawIconUrl' => ['type' => 'string'], + 'baseIconUrl' => ['type' => 'string'], + 'lastMessage' => ['type' => Message::class], + 'pinnedMessage' => ['type' => Message::class], + 'lastEventTime' => ['type' => 'int', 'default' => 0], + 'lastDelayedUpdateTime' => ['type' => 'int', 'default' => 0], + 'lastFireDelayedErrorTime' => ['type' => 'int', 'default' => 0], + 'created' => ['type' => 'int', 'default' => 0], + 'newMessages' => ['type' => 'int', 'default' => 0], + 'link' => ['type' => 'string'], + 'access' => ['type' => 'string'], + 'restrictions' => ['type' => 'int'], + 'participantsCount' => ['type' => 'int', 'default' => 0], + 'description' => ['type' => 'string'], + 'options' => ['type' => 'mixed'], + 'joinTime' => ['type' => 'int', 'default' => 0], + 'invitedBy' => ['type' => 'int'], + 'modified' => ['type' => 'int', 'default' => 0], + 'messagesCount' => ['type' => 'int', 'default' => 0], + 'hasBots' => ['type' => 'bool'], + 'prevMessageId' => ['type' => 'int'], + 'adminParticipants' => ['type' => 'array', 'default' => static function (): array { + return []; + }], + 'admins' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'cid' => ['type' => 'int'], + ]; + } + + /** + * @return array{0: MessageService, 1: ChatService} + */ + private function bound(): array + { + if ($this->messageActions === null || $this->chatActions === null) { + throw new RuntimeException('Chat is not bound to a client.'); + } + + return [$this->messageActions, $this->chatActions]; + } + + private function requireChatId(): int + { + if ($this->id === null) { + throw new RuntimeException('Chat does not contain id.'); + } + + return $this->id; + } +} diff --git a/src/PHPMax/Domain/ChatType.php b/src/PHPMax/Domain/ChatType.php new file mode 100644 index 0000000..f2f49c7 --- /dev/null +++ b/src/PHPMax/Domain/ChatType.php @@ -0,0 +1,17 @@ + ['type' => 'string', 'required' => true], + 'firstName' => ['type' => 'string', 'required' => true], + 'lastName' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Element.php b/src/PHPMax/Domain/Element.php new file mode 100644 index 0000000..282196b --- /dev/null +++ b/src/PHPMax/Domain/Element.php @@ -0,0 +1,30 @@ + ['type' => 'string', 'required' => true], + 'from' => ['type' => 'int', 'payload' => 'from'], + 'length' => ['type' => 'int'], + 'attributes' => ['type' => ElementAttributes::class], + ]; + } +} + diff --git a/src/PHPMax/Domain/ElementAttributes.php b/src/PHPMax/Domain/ElementAttributes.php new file mode 100644 index 0000000..c8107df --- /dev/null +++ b/src/PHPMax/Domain/ElementAttributes.php @@ -0,0 +1,21 @@ + ['type' => 'string'], + ]; + } +} + diff --git a/src/PHPMax/Domain/Events/FileUploadSignal.php b/src/PHPMax/Domain/Events/FileUploadSignal.php new file mode 100644 index 0000000..1909e47 --- /dev/null +++ b/src/PHPMax/Domain/Events/FileUploadSignal.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Events/MessageDeleteEvent.php b/src/PHPMax/Domain/Events/MessageDeleteEvent.php new file mode 100644 index 0000000..2eb9f1e --- /dev/null +++ b/src/PHPMax/Domain/Events/MessageDeleteEvent.php @@ -0,0 +1,89 @@ + */ + public $messageIds = []; + /** @var int|null */ + public $chatId; + /** @var Chat|null */ + public $chat; + /** @var Message|null */ + public $message; + /** @var bool */ + public $ttl = false; + /** @var MessageService|null */ + private $actions; + + public function bind(MessageService $actions): self + { + $this->actions = $actions; + + return $this; + } + + protected static function schema(): array + { + return [ + 'messageIds' => ['type' => 'array', 'required' => true, 'factory' => static function ($value): array { + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected messageIds list in MessageDeleteEvent'); + } + $messageIds = []; + foreach ($value as $item) { + if (!is_int($item) && !is_string($item)) { + throw new ValidationException('Expected scalar message id in MessageDeleteEvent'); + } + $messageIds[] = (int) $item; + } + + return $messageIds; + }], + 'chatId' => ['type' => 'int', 'required' => true], + 'chat' => ['type' => Chat::class], + 'message' => ['type' => Message::class], + 'ttl' => ['type' => 'bool', 'default' => false], + ]; + } + + protected static function normalizeInput(array $data): array + { + if (isset($data['chat']) && is_array($data['chat'])) { + $messageIds = $data['messageIds'] ?? $data['message_ids'] ?? null; + $chatId = $data['chat']['id'] ?? null; + if ($chatId !== null && $messageIds !== null) { + return [ + 'chat' => $data['chat'], + 'ttl' => $data['ttl'] ?? false, + 'messageIds' => $messageIds, + 'chatId' => $chatId, + ]; + } + } + + if (isset($data['message']) && is_array($data['message'])) { + $messageId = $data['message']['id'] ?? null; + $chatId = $data['chatId'] ?? $data['chat_id'] ?? null; + if ($chatId !== null && $messageId !== null) { + return [ + 'chatId' => $chatId, + 'message' => $data['message'], + 'ttl' => $data['ttl'] ?? false, + 'messageIds' => [$messageId], + ]; + } + } + + return $data; + } +} diff --git a/src/PHPMax/Domain/Events/MessageReadEvent.php b/src/PHPMax/Domain/Events/MessageReadEvent.php new file mode 100644 index 0000000..0735880 --- /dev/null +++ b/src/PHPMax/Domain/Events/MessageReadEvent.php @@ -0,0 +1,29 @@ + ['type' => 'bool', 'required' => true], + 'chatId' => ['type' => 'int', 'required' => true], + 'userId' => ['type' => 'int', 'required' => true], + 'mark' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Events/PresenceEvent.php b/src/PHPMax/Domain/Events/PresenceEvent.php new file mode 100644 index 0000000..f96527a --- /dev/null +++ b/src/PHPMax/Domain/Events/PresenceEvent.php @@ -0,0 +1,24 @@ + ['type' => Presence::class, 'required' => true], + 'userId' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Events/ReactionUpdateEvent.php b/src/PHPMax/Domain/Events/ReactionUpdateEvent.php new file mode 100644 index 0000000..600dd51 --- /dev/null +++ b/src/PHPMax/Domain/Events/ReactionUpdateEvent.php @@ -0,0 +1,46 @@ + */ + public $counters = []; + /** @var int|null */ + public $totalCount; + + protected static function schema(): array + { + return [ + 'messageId' => ['type' => 'string', 'required' => true], + 'chatId' => ['type' => 'int', 'required' => true], + 'counters' => ['type' => 'list<' . ReactionCounter::class . '>', 'default' => static function (): array { + return []; + }], + 'totalCount' => ['type' => 'int', 'required' => true], + ]; + } + + protected static function normalizeInput(array $data): array + { + // MAX может прислать messageId реакции числом. Для EasyChat это + // служебное событие не должно ронять весь короткий polling-цикл. + if (array_key_exists('messageId', $data) && (is_int($data['messageId']) || is_float($data['messageId']))) { + $data['messageId'] = (string)$data['messageId']; + } + if (array_key_exists('message_id', $data) && (is_int($data['message_id']) || is_float($data['message_id']))) { + $data['message_id'] = (string)$data['message_id']; + } + + return $data; + } +} diff --git a/src/PHPMax/Domain/Events/TypingEvent.php b/src/PHPMax/Domain/Events/TypingEvent.php new file mode 100644 index 0000000..f9c2259 --- /dev/null +++ b/src/PHPMax/Domain/Events/TypingEvent.php @@ -0,0 +1,23 @@ + ['type' => 'int', 'required' => true], + 'userId' => ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Events/VideoUploadSignal.php b/src/PHPMax/Domain/Events/VideoUploadSignal.php new file mode 100644 index 0000000..77de967 --- /dev/null +++ b/src/PHPMax/Domain/Events/VideoUploadSignal.php @@ -0,0 +1,20 @@ + ['type' => 'int', 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/FileRequest.php b/src/PHPMax/Domain/FileRequest.php new file mode 100644 index 0000000..9e85659 --- /dev/null +++ b/src/PHPMax/Domain/FileRequest.php @@ -0,0 +1,24 @@ + ['type' => 'bool', 'required' => true], + 'url' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Folder.php b/src/PHPMax/Domain/Folder.php new file mode 100644 index 0000000..0e2ed75 --- /dev/null +++ b/src/PHPMax/Domain/Folder.php @@ -0,0 +1,44 @@ + */ + public $include = []; + /** @var list */ + public $options = []; + /** @var int */ + public $updateTime = 0; + /** @var string */ + public $id = ''; + /** @var list */ + public $filters = []; + /** @var string */ + public $title = ''; + + protected static function schema(): array + { + return [ + 'sourceId' => ['type' => 'int', 'default' => 0], + 'include' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'options' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'updateTime' => ['type' => 'int', 'default' => 0], + 'id' => ['type' => 'string', 'default' => ''], + 'filters' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'title' => ['type' => 'string', 'default' => ''], + ]; + } +} diff --git a/src/PHPMax/Domain/FolderList.php b/src/PHPMax/Domain/FolderList.php new file mode 100644 index 0000000..1871c49 --- /dev/null +++ b/src/PHPMax/Domain/FolderList.php @@ -0,0 +1,35 @@ + */ + public $foldersOrder = []; + /** @var list */ + public $folders = []; + /** @var list */ + public $allFilterExcludeFolders = []; + /** @var int */ + public $folderSync = 0; + + protected static function schema(): array + { + return [ + 'foldersOrder' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'folders' => ['type' => 'list<' . Folder::class . '>', 'default' => static function (): array { + return []; + }], + 'allFilterExcludeFolders' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'folderSync' => ['type' => 'int', 'default' => 0], + ]; + } +} diff --git a/src/PHPMax/Domain/FolderUpdate.php b/src/PHPMax/Domain/FolderUpdate.php new file mode 100644 index 0000000..a87b19f --- /dev/null +++ b/src/PHPMax/Domain/FolderUpdate.php @@ -0,0 +1,28 @@ + */ + public $foldersOrder = []; + /** @var Folder|null */ + public $folder; + /** @var int */ + public $folderSync = 0; + + protected static function schema(): array + { + return [ + 'foldersOrder' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'folder' => ['type' => Folder::class], + 'folderSync' => ['type' => 'int', 'default' => 0], + ]; + } +} diff --git a/src/PHPMax/Domain/InitData.php b/src/PHPMax/Domain/InitData.php new file mode 100644 index 0000000..853fef8 --- /dev/null +++ b/src/PHPMax/Domain/InitData.php @@ -0,0 +1,24 @@ + ['type' => 'string', 'required' => true], + 'url' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/LoginConfig.php b/src/PHPMax/Domain/LoginConfig.php new file mode 100644 index 0000000..c8d8c93 --- /dev/null +++ b/src/PHPMax/Domain/LoginConfig.php @@ -0,0 +1,21 @@ + ['type' => 'mixed'], + ]; + } +} + diff --git a/src/PHPMax/Domain/LoginResponse.php b/src/PHPMax/Domain/LoginResponse.php new file mode 100644 index 0000000..72517cc --- /dev/null +++ b/src/PHPMax/Domain/LoginResponse.php @@ -0,0 +1,75 @@ + */ + public $chats = []; + /** @var Profile|null */ + public $profile; + /** @var array> */ + public $messages = []; + /** @var list */ + public $contacts = []; + /** @var string|null */ + public $token; + /** @var int|null */ + public $time; + /** @var LoginConfig|null */ + public $config; + + protected static function schema(): array + { + return [ + 'chats' => ['type' => 'list<' . Chat::class . '>', 'default' => static function (): array { + return []; + }], + 'profile' => ['type' => Profile::class, 'required' => true], + 'messages' => ['type' => 'map-list<' . Message::class . '>', 'default' => static function (): array { + return []; + }], + 'contacts' => ['default' => static function (): array { + return []; + }, 'factory' => static function ($value): array { + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected contacts list in LoginResponse'); + } + $contacts = []; + foreach ($value as $item) { + if ($item === null) { + $contacts[] = null; + continue; + } + if (!is_array($item)) { + throw new ValidationException('Expected contact item array or null in LoginResponse'); + } + $contacts[] = User::fromArray($item); + } + return $contacts; + }], + 'token' => ['type' => 'string'], + 'time' => ['type' => 'int'], + 'config' => ['type' => LoginConfig::class], + ]; + } + + public function updateSyncState(SyncState $current): SyncState + { + $syncTime = $this->time; + $configHash = $this->config !== null ? $this->config->hash : null; + + return new SyncState([ + 'chatsSync' => $syncTime !== null ? $syncTime : $current->chatsSync, + 'contactsSync' => $syncTime !== null ? $syncTime : $current->contactsSync, + 'draftsSync' => $syncTime !== null ? $syncTime : $current->draftsSync, + 'presenceSync' => $syncTime !== null ? $syncTime : $current->presenceSync, + 'configHash' => $configHash !== null ? $configHash : $current->configHash, + ]); + } +} diff --git a/src/PHPMax/Domain/MaxApiError.php b/src/PHPMax/Domain/MaxApiError.php new file mode 100644 index 0000000..b38f338 --- /dev/null +++ b/src/PHPMax/Domain/MaxApiError.php @@ -0,0 +1,30 @@ + ['type' => 'string', 'required' => true], + 'title' => ['type' => 'string'], + 'message' => ['type' => 'string'], + 'localizedMessage' => ['type' => 'string'], + ]; + } +} + diff --git a/src/PHPMax/Domain/Member.php b/src/PHPMax/Domain/Member.php new file mode 100644 index 0000000..72c0d7a --- /dev/null +++ b/src/PHPMax/Domain/Member.php @@ -0,0 +1,23 @@ + ['type' => User::class, 'required' => true], + 'presence' => ['type' => Presence::class, 'required' => true], + ]; + } +} diff --git a/src/PHPMax/Domain/Message.php b/src/PHPMax/Domain/Message.php new file mode 100644 index 0000000..b05917f --- /dev/null +++ b/src/PHPMax/Domain/Message.php @@ -0,0 +1,231 @@ + */ + public $attaches = []; + /** @var array|null */ + public $stats; + /** @var string|null */ + public $status; + /** @var ReactionInfo|null */ + public $reactionInfo; + /** @var mixed */ + public $options; + /** @var int|string|null */ + public $prevMessageId; + /** @var bool|null */ + public $ttl; + /** @var int|null */ + public $unread; + /** @var int|null */ + public $mark; + /** @var list */ + public $elements = []; + /** @var MessageService|null */ + private $actions; + + public function bind(MessageService $actions): self + { + $this->actions = $actions; + + return $this; + } + + /** + * @param array|null $attachments + */ + public function reply(string $text, ?array $attachments = null, bool $notify = true): ?Message + { + [$actions, $chatId] = $this->bound(); + + return $actions->sendMessage($chatId, $text, $this->requireMessageId(), $attachments, $notify); + } + + /** + * @param array|null $attachments + */ + public function answer(string $text, ?int $replyTo = null, ?array $attachments = null, bool $notify = true): ?Message + { + [$actions, $chatId] = $this->bound(); + + return $actions->sendMessage($chatId, $text, $replyTo, $attachments, $notify); + } + + public function forward(int $chatId, bool $notify = true): ?Message + { + [$actions, $sourceChatId] = $this->bound(); + + return $actions->forwardMessage($chatId, $this->requireMessageId(), $sourceChatId, $notify); + } + + public function pin(bool $notifyPin = true): bool + { + [$actions, $chatId] = $this->bound(); + + return $actions->pinMessage($chatId, $this->requireMessageId(), $notifyPin); + } + + /** + * @param array|null $attachments + */ + public function edit(string $text, ?array $attachments = null): Message + { + [$actions, $chatId] = $this->bound(); + + return $actions->editMessage($chatId, $this->requireMessageId(), $text, $attachments); + } + + public function delete(bool $forMe = false): bool + { + [$actions, $chatId] = $this->bound(); + + return $actions->deleteMessage($chatId, [$this->requireMessageId()], $forMe); + } + + public function read(): ReadState + { + [$actions, $chatId] = $this->bound(); + + return $actions->readMessage($this->requireMessageId(), $chatId); + } + + public function react(string $reaction): ?ReactionInfo + { + [$actions, $chatId] = $this->bound(); + + return $actions->addReaction($chatId, (string) $this->requireMessageId(), $reaction); + } + + public function unreact(): ?ReactionInfo + { + [$actions, $chatId] = $this->bound(); + + return $actions->removeReaction($chatId, (string) $this->requireMessageId()); + } + + /** + * @return array|null + */ + public function getReactions(): ?array + { + [$actions, $chatId] = $this->bound(); + $messageId = (string) $this->requireMessageId(); + + return $actions->getReactions($chatId, [$messageId]); + } + + protected static function schema(): array + { + return [ + 'id' => ['type' => 'int', 'required' => true], + 'chatId' => ['type' => 'int'], + 'sender' => ['type' => 'int'], + 'text' => ['type' => 'string', 'default' => ''], + 'time' => ['type' => 'int', 'required' => true], + 'type' => ['type' => 'string', 'required' => true], + 'cid' => ['type' => 'int'], + 'attaches' => ['default' => static function (): array { + return []; + }, 'factory' => static function ($value): array { + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected attaches list in Message'); + } + $items = []; + foreach ($value as $item) { + if (!is_array($item)) { + throw new ValidationException('Expected attachment item array in Message'); + } + $items[] = AttachmentFactory::fromArray($item); + } + return $items; + }], + 'stats' => ['type' => 'array'], + 'status' => ['type' => 'string'], + 'reactionInfo' => ['type' => ReactionInfo::class], + 'options' => ['type' => 'mixed'], + 'prevMessageId' => ['type' => 'mixed'], + 'ttl' => ['type' => 'bool'], + 'unread' => ['type' => 'int'], + 'mark' => ['type' => 'int'], + 'elements' => ['type' => 'list<' . Element::class . '>', 'default' => static function (): array { + return []; + }], + ]; + } + + protected static function normalizeInput(array $data): array + { + if (!isset($data['message']) || !is_array($data['message'])) { + return $data; + } + + $message = $data['message']; + $outerFields = [ + 'chatId' => ['chatId', 'chat_id'], + 'prevMessageId' => ['prevMessageId', 'prev_message_id'], + 'ttl' => ['ttl'], + 'unread' => ['unread'], + 'mark' => ['mark'], + ]; + + foreach ($outerFields as $target => $aliases) { + foreach ($aliases as $alias) { + if (array_key_exists($alias, $data)) { + $message[$target] = $data[$alias]; + break; + } + } + } + + return $message; + } + + /** + * @return array{0: MessageService, 1: int} + */ + private function bound(): array + { + if ($this->actions === null) { + throw new RuntimeException('Message is not bound to a client.'); + } + if ($this->chatId === null) { + throw new RuntimeException('Message does not contain chatId.'); + } + + return [$this->actions, $this->chatId]; + } + + private function requireMessageId(): int + { + if ($this->id === null) { + throw new RuntimeException('Message does not contain id.'); + } + + return $this->id; + } +} diff --git a/src/PHPMax/Domain/MessageStatus.php b/src/PHPMax/Domain/MessageStatus.php new file mode 100644 index 0000000..bc81421 --- /dev/null +++ b/src/PHPMax/Domain/MessageStatus.php @@ -0,0 +1,15 @@ + ['type' => 'string'], + 'firstName' => ['type' => 'string'], + 'lastName' => ['type' => 'string'], + 'type' => ['type' => 'string'], + ]; + } +} diff --git a/src/PHPMax/Domain/Presence.php b/src/PHPMax/Domain/Presence.php new file mode 100644 index 0000000..956fc36 --- /dev/null +++ b/src/PHPMax/Domain/Presence.php @@ -0,0 +1,23 @@ + ['type' => 'int'], + 'status' => ['type' => 'int'], + ]; + } +} diff --git a/src/PHPMax/Domain/Profile.php b/src/PHPMax/Domain/Profile.php new file mode 100644 index 0000000..9628d44 --- /dev/null +++ b/src/PHPMax/Domain/Profile.php @@ -0,0 +1,23 @@ +|null */ + public $profileOptions; + + protected static function schema(): array + { + return [ + 'contact' => ['type' => User::class, 'required' => true], + 'profileOptions' => ['type' => 'array'], + ]; + } +} diff --git a/src/PHPMax/Domain/ReactionCounter.php b/src/PHPMax/Domain/ReactionCounter.php new file mode 100644 index 0000000..f1a68bf --- /dev/null +++ b/src/PHPMax/Domain/ReactionCounter.php @@ -0,0 +1,24 @@ + ['type' => 'int', 'required' => true], + 'reaction' => ['type' => 'string', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/ReactionInfo.php b/src/PHPMax/Domain/ReactionInfo.php new file mode 100644 index 0000000..e00ca36 --- /dev/null +++ b/src/PHPMax/Domain/ReactionInfo.php @@ -0,0 +1,29 @@ + */ + public $counters = []; + /** @var string|null */ + public $yourReaction; + + protected static function schema(): array + { + return [ + 'totalCount' => ['type' => 'int', 'default' => 0], + 'counters' => ['type' => 'list<' . ReactionCounter::class . '>', 'default' => static function (): array { + return []; + }], + 'yourReaction' => ['type' => 'string'], + ]; + } +} + diff --git a/src/PHPMax/Domain/ReadState.php b/src/PHPMax/Domain/ReadState.php new file mode 100644 index 0000000..3780c2d --- /dev/null +++ b/src/PHPMax/Domain/ReadState.php @@ -0,0 +1,24 @@ + ['type' => 'int', 'required' => true], + 'mark' => ['type' => 'int', 'required' => true], + ]; + } +} + diff --git a/src/PHPMax/Domain/Session.php b/src/PHPMax/Domain/Session.php new file mode 100644 index 0000000..2c3f8c3 --- /dev/null +++ b/src/PHPMax/Domain/Session.php @@ -0,0 +1,59 @@ + ['type' => 'mixed'], + 'deviceId' => ['type' => 'string'], + 'current' => ['type' => 'bool'], + 'userAgent' => ['type' => 'string'], + 'appVersion' => ['type' => 'string'], + 'deviceName' => ['type' => 'string'], + 'deviceType' => ['type' => 'string'], + 'platform' => ['type' => 'string'], + 'ip' => ['type' => 'string'], + 'location' => ['type' => 'string'], + 'created' => ['type' => 'int'], + 'updated' => ['type' => 'int'], + 'lastActivity' => ['type' => 'int'], + 'options' => ['type' => 'mixed'], + ]; + } +} diff --git a/src/PHPMax/Domain/SyncOverrides.php b/src/PHPMax/Domain/SyncOverrides.php new file mode 100644 index 0000000..0b0cb40 --- /dev/null +++ b/src/PHPMax/Domain/SyncOverrides.php @@ -0,0 +1,43 @@ + ['type' => 'int', 'payload' => 'chats_sync'], + 'contactsSync' => ['type' => 'int', 'payload' => 'contacts_sync'], + 'draftsSync' => ['type' => 'int', 'payload' => 'drafts_sync'], + 'presenceSync' => ['type' => 'int', 'payload' => 'presence_sync'], + 'configHash' => ['type' => 'mixed', 'payload' => 'config_hash'], + ]; + } + + public function resolve(SyncState $saved): SyncState + { + return new SyncState([ + 'chatsSync' => $this->chatsSync !== null ? $this->chatsSync : $saved->chatsSync, + 'contactsSync' => $this->contactsSync !== null ? $this->contactsSync : $saved->contactsSync, + 'draftsSync' => $this->draftsSync !== null ? $this->draftsSync : $saved->draftsSync, + 'presenceSync' => $this->presenceSync !== null ? $this->presenceSync : $saved->presenceSync, + 'configHash' => $this->configHash !== null ? $this->configHash : $saved->configHash, + ]); + } +} diff --git a/src/PHPMax/Domain/SyncState.php b/src/PHPMax/Domain/SyncState.php new file mode 100644 index 0000000..857e1a4 --- /dev/null +++ b/src/PHPMax/Domain/SyncState.php @@ -0,0 +1,34 @@ + ['type' => 'int', 'payload' => 'chats_sync', 'default' => -1], + 'contactsSync' => ['type' => 'int', 'payload' => 'contacts_sync', 'default' => -1], + 'draftsSync' => ['type' => 'int', 'payload' => 'drafts_sync', 'default' => -1], + 'presenceSync' => ['type' => 'int', 'payload' => 'presence_sync', 'default' => -1], + 'configHash' => ['type' => 'mixed', 'payload' => 'config_hash', 'default' => self::DEFAULT_CONFIG_HASH], + ]; + } +} diff --git a/src/PHPMax/Domain/TranscriptionStatus.php b/src/PHPMax/Domain/TranscriptionStatus.php new file mode 100644 index 0000000..37de988 --- /dev/null +++ b/src/PHPMax/Domain/TranscriptionStatus.php @@ -0,0 +1,19 @@ + */ + public $names = []; + /** @var array */ + public $options = []; + /** @var int|null */ + public $photoId; + /** @var int|null */ + public $updateTime; + /** @var mixed */ + public $phone; + /** @var string|null */ + public $status; + /** @var string|null */ + public $description; + /** @var mixed */ + public $gender; + /** @var string|null */ + public $link; + /** @var mixed */ + public $webApp; + /** @var array|null */ + public $menuButton; + /** @var UserService|null */ + private $actions; + + public function bind(UserService $actions): self + { + $this->actions = $actions; + + return $this; + } + + public function addContact(): User + { + return $this->bound()->addContact($this->requireUserId()); + } + + public function removeContact(): bool + { + return $this->bound()->removeContact($this->requireUserId()); + } + + public function getChatId(int $userId): int + { + return $this->bound()->getChatId($userId, $this->requireUserId()); + } + + protected static function schema(): array + { + return [ + 'id' => ['type' => 'int', 'required' => true], + 'accountStatus' => ['type' => 'int'], + 'registrationTime' => ['type' => 'int'], + 'country' => ['type' => 'string'], + 'baseRawUrl' => ['type' => 'string'], + 'baseUrl' => ['type' => 'string'], + 'names' => ['type' => 'list<' . Name::class . '>', 'default' => static function (): array { + return []; + }], + 'options' => ['type' => 'list', 'default' => static function (): array { + return []; + }], + 'photoId' => ['type' => 'int'], + 'updateTime' => ['type' => 'int'], + 'phone' => ['type' => 'mixed'], + 'status' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'gender' => ['type' => 'mixed'], + 'link' => ['type' => 'string'], + 'webApp' => ['type' => 'mixed'], + 'menuButton' => ['type' => 'array'], + ]; + } + + private function bound(): UserService + { + if ($this->actions === null) { + throw new RuntimeException('User is not bound to a client.'); + } + + return $this->actions; + } + + private function requireUserId(): int + { + if ($this->id === null) { + throw new RuntimeException('User does not contain id.'); + } + + return $this->id; + } +} diff --git a/src/PHPMax/Domain/VideoRequest.php b/src/PHPMax/Domain/VideoRequest.php new file mode 100644 index 0000000..17deaf2 --- /dev/null +++ b/src/PHPMax/Domain/VideoRequest.php @@ -0,0 +1,43 @@ + ['type' => 'mixed', 'payload' => 'EXTERNAL'], + 'cache' => ['type' => 'bool', 'required' => true], + 'url' => ['type' => 'string', 'required' => true], + ]; + } + + protected static function normalizeInput(array $data): array + { + if (isset($data['url'])) { + return $data; + } + + foreach ($data as $key => $value) { + if ($key !== 'EXTERNAL' && $key !== 'cache') { + $data['url'] = $value; + break; + } + } + + return $data; + } +} + diff --git a/src/PHPMax/Exception/ApiException.php b/src/PHPMax/Exception/ApiException.php new file mode 100644 index 0000000..38de042 --- /dev/null +++ b/src/PHPMax/Exception/ApiException.php @@ -0,0 +1,93 @@ + */ + private $payload; + + /** + * @param array $payload + */ + public function __construct( + int $opcode, + ?string $error = null, + ?string $title = null, + ?string $apiMessage = null, + ?string $localizedMessage = null, + array $payload = [] + ) { + $this->opcode = $opcode; + $this->error = $error; + $this->title = $title; + $this->apiMessage = $apiMessage; + $this->localizedMessage = $localizedMessage; + $this->payload = $payload; + + parent::__construct($this->buildMessage()); + } + + public function opcode(): int + { + return $this->opcode; + } + + public function error(): ?string + { + return $this->error; + } + + public function title(): ?string + { + return $this->title; + } + + public function apiMessage(): ?string + { + return $this->apiMessage; + } + + public function localizedMessage(): ?string + { + return $this->localizedMessage; + } + + /** + * @return array + */ + public function payload(): array + { + return $this->payload; + } + + private function buildMessage(): string + { + $parts = []; + foreach ([$this->localizedMessage, $this->apiMessage] as $part) { + if ($part !== null && $part !== '' && !in_array($part, $parts, true)) { + $parts[] = $part; + } + } + if ($this->title !== null && $this->title !== '') { + $parts[] = '(' . $this->title . ')'; + } + if ($this->error !== null && $this->error !== '') { + $parts[] = '[' . $this->error . ']'; + } + + return $parts !== [] ? implode(' ', $parts) : 'API request failed'; + } +} diff --git a/src/PHPMax/Exception/PHPMaxException.php b/src/PHPMax/Exception/PHPMaxException.php new file mode 100644 index 0000000..3d6d5dc --- /dev/null +++ b/src/PHPMax/Exception/PHPMaxException.php @@ -0,0 +1,10 @@ + 1) { + throw new UploadException('Only one of raw data, URL or path must be provided'); + } + if ($raw !== null && ($name === null || $name === '')) { + throw new UploadException('Name must be provided for raw data'); + } + if ($url !== null) { + $this->assertSupportedUrl($url); + } + + $this->raw = $raw; + $this->path = $path; + $this->url = $url; + $this->name = $name; + } + + public static function fromPath(string $path, ?string $name = null) + { + return new static(null, $path, null, $name); + } + + public static function fromUrl(string $url, ?string $name = null) + { + return new static(null, null, $url, $name); + } + + public static function fromRaw(string $raw, string $name) + { + return new static($raw, null, null, $name); + } + + public function read(): string + { + if ($this->raw !== null) { + if ($this->raw === '') { + throw new UploadException('Raw upload source is empty'); + } + + return $this->raw; + } + + if ($this->path !== null) { + if (!is_file($this->path) || !is_readable($this->path)) { + throw new UploadException('File is not readable: ' . $this->path); + } + $contents = file_get_contents($this->path); + if ($contents === false) { + throw new UploadException('Failed to read file: ' . $this->path); + } + + return $contents; + } + + if ($this->url !== null) { + $context = $this->urlContext('GET'); + $contents = @file_get_contents($this->url, false, $context); + if ($contents === false) { + throw new UploadException('Failed to read URL: ' . $this->urlForError()); + } + $this->assertHttpStatusOk($http_response_header ?? [], 'Failed to read URL'); + + return $contents; + } + + throw new UploadException('Path, URL or raw data must be provided'); + } + + public function size(): int + { + if ($this->raw !== null) { + if ($this->raw === '') { + throw new UploadException('Raw upload source is empty'); + } + + return strlen($this->raw); + } + + if ($this->path !== null) { + if (!is_file($this->path)) { + throw new UploadException('File does not exist: ' . $this->path); + } + $size = filesize($this->path); + if ($size === false) { + throw new UploadException('Failed to get file size: ' . $this->path); + } + + return (int) $size; + } + + if ($this->url !== null) { + $headers = @get_headers($this->url, true, $this->urlContext('HEAD')); + if (!is_array($headers)) { + throw new UploadException('Failed to read URL headers: ' . $this->urlForError()); + } + $this->assertHttpStatusOk($headers, 'Failed to read URL headers'); + + $length = $this->headerValue($headers, 'Content-Length'); + if (is_array($length)) { + $length = end($length); + } + if ($length === null || !is_numeric($length)) { + throw new UploadException('URL response does not contain Content-Length: ' . $this->urlForError()); + } + + return (int) $length; + } + + throw new UploadException('Path, URL or raw data must be provided'); + } + + /** + * @return iterable + */ + public function iterChunks(int $size): iterable + { + if ($size <= 0) { + throw new UploadException('Chunk size must be greater than zero'); + } + + if ($this->raw !== null) { + $length = strlen($this->raw); + for ($offset = 0; $offset < $length; $offset += $size) { + yield substr($this->raw, $offset, $size); + } + return; + } + + if ($this->path !== null) { + if (!is_file($this->path) || !is_readable($this->path)) { + throw new UploadException('File is not readable: ' . $this->path); + } + $handle = fopen($this->path, 'rb'); + if (!is_resource($handle)) { + throw new UploadException('Failed to open file: ' . $this->path); + } + try { + while (!feof($handle)) { + $chunk = fread($handle, $size); + if ($chunk === false) { + throw new UploadException('Failed to read file chunk: ' . $this->path); + } + if ($chunk !== '') { + yield $chunk; + } + } + } finally { + fclose($handle); + } + return; + } + + if ($this->url !== null) { + $context = $this->urlContext('GET'); + $handle = @fopen($this->url, 'rb', false, $context); + if (!is_resource($handle)) { + throw new UploadException('Failed to open URL: ' . $this->urlForError()); + } + try { + $meta = stream_get_meta_data($handle); + $this->assertHttpStatusOk($meta['wrapper_data'] ?? [], 'Failed to open URL'); + + while (!feof($handle)) { + $chunk = fread($handle, $size); + if ($chunk === false) { + throw new UploadException('Failed to read URL chunk: ' . $this->urlForError()); + } + if ($chunk !== '') { + yield $chunk; + } + } + } finally { + fclose($handle); + } + } + } + + public function name(): string + { + return (string) $this->name; + } + + public function path(): ?string + { + return $this->path; + } + + public function url(): ?string + { + return $this->url; + } + + protected function inferName(): string + { + if ($this->name !== null && $this->name !== '') { + return $this->name; + } + if ($this->path !== null) { + return basename($this->path); + } + if ($this->url !== null) { + $path = (string) parse_url($this->url, PHP_URL_PATH); + return basename($path); + } + + return ''; + } + + private function assertSupportedUrl(string $url): void + { + $parts = parse_url($url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new UploadException('URL source must be an absolute HTTP or HTTPS URL'); + } + if (isset($parts['port'])) { + $port = (int) $parts['port']; + if ($port <= 0 || $port > 65535) { + throw new UploadException('URL source port must be between 1 and 65535'); + } + } + + $scheme = strtolower((string) $parts['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new UploadException('Unsupported URL source scheme: ' . $scheme); + } + } + + /** + * @return resource + */ + private function urlContext(string $method) + { + return stream_context_create([ + 'http' => [ + 'method' => $method, + 'timeout' => 30, + 'ignore_errors' => true, + ], + ]); + } + + /** + * @param array $headers + */ + private function assertHttpStatusOk(array $headers, string $message): void + { + $status = $this->httpStatusCode($headers); + if ($status === null) { + throw new UploadException($message . ': missing HTTP status for ' . $this->urlForError()); + } + if ($status < 200 || $status >= 300) { + throw new UploadException($message . ': HTTP ' . $status . ' for ' . $this->urlForError()); + } + } + + private function urlForError(): string + { + if ($this->url === null) { + return ''; + } + + $parts = parse_url($this->url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + return ''; + } + + $url = strtolower((string) $parts['scheme']) . '://' . (string) $parts['host']; + if (isset($parts['port'])) { + $url .= ':' . (int) $parts['port']; + } + + return $url . '/'; + } + + /** + * @param array $headers + */ + private function httpStatusCode(array $headers): ?int + { + $status = null; + foreach ($headers as $key => $value) { + if (is_int($key) && is_string($value) && preg_match('/^HTTP\/\S+\s+(\d{3})\b/', $value, $matches)) { + $status = (int) $matches[1]; + continue; + } + if (is_array($value)) { + $nested = $this->httpStatusCode($value); + if ($nested !== null) { + $status = $nested; + } + } + } + + return $status; + } + + /** + * @param array $headers + * @return mixed + */ + private function headerValue(array $headers, string $name) + { + foreach ($headers as $key => $value) { + if (is_string($key) && strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } +} diff --git a/src/PHPMax/Files/File.php b/src/PHPMax/Files/File.php new file mode 100644 index 0000000..ac55de5 --- /dev/null +++ b/src/PHPMax/Files/File.php @@ -0,0 +1,20 @@ +name = $this->inferName(); + if ($this->name === '') { + throw new UploadException('Either name, URL or path must provide a file name'); + } + } +} + diff --git a/src/PHPMax/Files/Photo.php b/src/PHPMax/Files/Photo.php new file mode 100644 index 0000000..c5ebd81 --- /dev/null +++ b/src/PHPMax/Files/Photo.php @@ -0,0 +1,58 @@ + */ + private static $mimeByExtension = [ + '.jpg' => 'image/jpeg', + '.jpeg' => 'image/jpeg', + '.png' => 'image/png', + '.gif' => 'image/gif', + '.webp' => 'image/webp', + '.bmp' => 'image/bmp', + ]; + + /** @var string */ + private $fileName = ''; + + public function __construct(?string $raw = null, ?string $path = null, ?string $url = null, ?string $name = null) + { + parent::__construct($raw, $path, $url, $name); + $this->fileName = $this->inferName(); + } + + /** + * @return array{0: string, 1: string} + */ + public function validatePhoto(): array + { + if ($this->url !== null) { + $sourceName = (string) parse_url($this->url, PHP_URL_PATH); + $extension = strtolower((string) strrchr($sourceName, '.')); + if ($extension === '' || !isset(self::$mimeByExtension[$extension])) { + throw new UploadException('Invalid photo extension: ' . ($extension !== '' ? $extension : '')); + } + + return [substr($extension, 1), self::$mimeByExtension[$extension]]; + } + + $sourceName = $this->path !== null ? $this->path : $this->fileName; + $extension = strtolower((string) strrchr($sourceName, '.')); + if ($extension === '' || !isset(self::$mimeByExtension[$extension])) { + throw new UploadException('Invalid photo extension: ' . ($extension !== '' ? $extension : '')); + } + + return [substr($extension, 1), 'image/' . substr($extension, 1)]; + } + + public function fileName(): string + { + return $this->fileName; + } +} diff --git a/src/PHPMax/Files/Video.php b/src/PHPMax/Files/Video.php new file mode 100644 index 0000000..5057a08 --- /dev/null +++ b/src/PHPMax/Files/Video.php @@ -0,0 +1,20 @@ +name = $this->inferName(); + if ($this->name === '') { + throw new UploadException('Either name, URL or path must provide a video file name'); + } + } +} + diff --git a/src/PHPMax/Formatting/Formatter.php b/src/PHPMax/Formatting/Formatter.php new file mode 100644 index 0000000..238b596 --- /dev/null +++ b/src/PHPMax/Formatting/Formatter.php @@ -0,0 +1,247 @@ + 'CODE', + '**' => 'STRONG', + '__' => 'UNDERLINE', + '~~' => 'STRIKETHROUGH', + '`' => 'MONOSPACED', + '_' => 'EMPHASIZED', + '*' => 'EMPHASIZED', + ]; + + private const MARKER_ORDER = ['```', '**', '__', '~~', '`', '_', '*']; + + private function __construct() + { + } + + /** + * @return array{0: string, 1: list} + */ + public static function formatMarkdown(string $text): array + { + $cleanText = ''; + $entities = []; + $active = []; + $i = 0; + $cleanPos = 0; + $lineStart = true; + $length = strlen($text); + + while ($i < $length) { + $parsedLink = self::parseLink($text, $i); + if ($parsedLink !== null) { + [$label, $url, $nextI] = $parsedLink; + $start = $cleanPos; + $labelLen = self::codeUnitsLen($label); + $cleanText .= $label; + $cleanPos += $labelLen; + $entities[] = new Element([ + 'type' => 'LINK', + 'from' => $start, + 'length' => $labelLen, + 'attributes' => new ElementAttributes(['url' => $url]), + ]); + $i = $nextI; + $lineStart = false; + continue; + } + + if ($lineStart && substr($text, $i, 1) === '#') { + $startI = $i; + while ($i < $length && substr($text, $i, 1) === '#') { + $i++; + } + if ($i < $length && substr($text, $i, 1) === ' ') { + $i++; + $start = $cleanPos; + while ($i < $length && substr($text, $i, 1) !== "\n") { + [$ch, $next] = self::nextChar($text, $i); + $cleanText .= $ch; + $cleanPos += self::codeUnitsLen($ch); + $i = $next; + } + $entityLength = $cleanPos - $start; + if ($entityLength > 0) { + $entities[] = new Element(['type' => 'HEADING', 'from' => $start, 'length' => $entityLength]); + } + $lineStart = false; + continue; + } + $i = $startI; + } + + if ($lineStart && substr($text, $i, 1) === '>') { + $i++; + if ($i < $length && substr($text, $i, 1) === ' ') { + $i++; + } + $start = $cleanPos; + while ($i < $length && substr($text, $i, 1) !== "\n") { + [$ch, $next] = self::nextChar($text, $i); + $cleanText .= $ch; + $cleanPos += self::codeUnitsLen($ch); + $i = $next; + } + $entityLength = $cleanPos - $start; + if ($entityLength > 0) { + $entities[] = new Element(['type' => 'QUOTE', 'from' => $start, 'length' => $entityLength]); + } + $lineStart = false; + continue; + } + + $handled = false; + foreach (self::MARKER_ORDER as $marker) { + if (substr($text, $i, strlen($marker)) !== $marker) { + continue; + } + $markerLen = strlen($marker); + if (!array_key_exists($marker, $active)) { + if ($marker === '```') { + $closing = strpos($text, $marker, $i + $markerLen); + if ($closing === false || $closing === $i + $markerLen) { + $cleanText .= $marker; + $cleanPos += $markerLen; + $i += $markerLen; + $handled = true; + break; + } + $active[$marker] = $cleanPos; + $i += $markerLen; + $lineEnd = strpos($text, "\n", $i); + if ($lineEnd !== false && $lineEnd < $closing) { + $i = $lineEnd + 1; + } + $handled = true; + break; + } + + $lineEnd = strpos($text, "\n", $i + $markerLen); + $closing = strpos($text, $marker, $i + $markerLen); + if ($lineEnd !== false && $closing !== false && $closing > $lineEnd) { + $closing = false; + } + if ($closing === false || $closing === $i + $markerLen) { + $cleanText .= $marker; + $cleanPos += $markerLen; + $i += $markerLen; + $handled = true; + break; + } + $active[$marker] = $cleanPos; + $i += $markerLen; + $handled = true; + break; + } + + $start = $active[$marker]; + $entityLength = $cleanPos - $start; + if ($entityLength > 0) { + $entities[] = new Element(['type' => self::MARKERS[$marker], 'from' => $start, 'length' => $entityLength]); + } + unset($active[$marker]); + $i += $markerLen; + $handled = true; + break; + } + + if ($handled) { + $lineStart = false; + continue; + } + + [$ch, $next] = self::nextChar($text, $i); + $cleanText .= $ch; + $lineStart = $ch === "\n"; + $cleanPos += self::codeUnitsLen($ch); + $i = $next; + } + + return [$cleanText, $entities]; + } + + /** + * @return array{0: string, 1: string, 2: int}|null + */ + private static function parseLink(string $text, int $offset): ?array + { + if (substr($text, $offset, 1) !== '[') { + return null; + } + $labelEnd = strpos($text, ']', $offset + 1); + if ($labelEnd === false || substr($text, $labelEnd + 1, 1) !== '(') { + return null; + } + $urlStart = $labelEnd + 2; + $urlEnd = strpos($text, ')', $urlStart); + if ($urlEnd === false) { + return null; + } + $label = substr($text, $offset + 1, $labelEnd - $offset - 1); + $url = substr($text, $urlStart, $urlEnd - $urlStart); + if ($label === '' || $url === '') { + return null; + } + + return [$label, $url, $urlEnd + 1]; + } + + /** + * @return array{0: string, 1: int} + */ + private static function nextChar(string $text, int $offset): array + { + if (preg_match('/./us', substr($text, $offset), $match) !== 1) { + return [substr($text, $offset, 1), $offset + 1]; + } + $ch = $match[0]; + + return [$ch, $offset + strlen($ch)]; + } + + private static function codeUnitsLen(string $text): int + { + if ($text === '') { + return 0; + } + preg_match_all('/./us', $text, $matches); + $length = 0; + foreach ($matches[0] as $char) { + $length += self::codePoint($char) > 0xFFFF ? 2 : 1; + } + + return $length; + } + + private static function codePoint(string $char): int + { + $bytes = array_map('ord', str_split($char)); + $count = count($bytes); + if ($count === 1) { + return $bytes[0]; + } + if ($count === 2) { + return (($bytes[0] & 0x1F) << 6) | ($bytes[1] & 0x3F); + } + if ($count === 3) { + return (($bytes[0] & 0x0F) << 12) | (($bytes[1] & 0x3F) << 6) | ($bytes[2] & 0x3F); + } + if ($count === 4) { + return (($bytes[0] & 0x07) << 18) | (($bytes[1] & 0x3F) << 12) | (($bytes[2] & 0x3F) << 6) | ($bytes[3] & 0x3F); + } + + return 0; + } +} + diff --git a/src/PHPMax/Protocol/Command.php b/src/PHPMax/Protocol/Command.php new file mode 100644 index 0000000..c8c0985 --- /dev/null +++ b/src/PHPMax/Protocol/Command.php @@ -0,0 +1,18 @@ +|null */ + public $payload; + /** @var array|null */ + public $raw; + + /** + * @param array|null $payload + * @param array|null $raw + */ + public function __construct(int $opcode, int $cmd = 0, ?int $seq = null, ?array $payload = null, ?array $raw = null) + { + $this->opcode = $opcode; + $this->cmd = $cmd; + $this->seq = $seq; + $this->payload = $payload; + $this->raw = $raw; + } +} + diff --git a/src/PHPMax/Protocol/Opcode.php b/src/PHPMax/Protocol/Opcode.php new file mode 100644 index 0000000..1c81424 --- /dev/null +++ b/src/PHPMax/Protocol/Opcode.php @@ -0,0 +1,178 @@ +|null */ + public $payload; + + /** + * @param array|null $payload + */ + public function __construct(int $ver, int $opcode, int $seq, ?array $payload = null, int $cmd = Command::REQUEST) + { + $this->ver = $ver; + $this->opcode = $opcode; + $this->cmd = $cmd; + $this->seq = $seq; + $this->payload = $payload; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/BinaryString.php b/src/PHPMax/Protocol/Tcp/BinaryString.php new file mode 100644 index 0000000..ab3102b --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/BinaryString.php @@ -0,0 +1,21 @@ +bytes = $bytes; + } + + public function bytes(): string + { + return $this->bytes; + } +} diff --git a/src/PHPMax/Protocol/Tcp/Lz4BlockCompression.php b/src/PHPMax/Protocol/Tcp/Lz4BlockCompression.php new file mode 100644 index 0000000..e5d9a0e --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/Lz4BlockCompression.php @@ -0,0 +1,87 @@ +> 4; + if ($litLen === 15) { + while ($pos < $len) { + $b = ord($src[$pos]); + $pos++; + $litLen += $b; + if ($b !== 255) { + break; + } + } + } + + if ($litLen > 0) { + if ($pos + $litLen > $len) { + throw new ProtocolException('LZ4: literal length out of bounds'); + } + $dst .= substr($src, $pos, $litLen); + $pos += $litLen; + if (strlen($dst) > $maxOutput) { + throw new ProtocolException('LZ4: output too large'); + } + } + + if ($pos >= $len) { + break; + } + + if ($pos + 1 >= $len) { + throw new ProtocolException('LZ4: incomplete offset'); + } + + $offset = ord($src[$pos]) | (ord($src[$pos + 1]) << 8); + $pos += 2; + if ($offset === 0) { + throw new ProtocolException('LZ4: zero offset'); + } + + $matchLen = ($token & 0x0F) + 4; + if (($token & 0x0F) === 0x0F) { + while ($pos < $len) { + $b = ord($src[$pos]); + $pos++; + $matchLen += $b; + if ($b !== 255) { + break; + } + } + } + + $matchPos = strlen($dst) - $offset; + if ($matchPos < 0) { + throw new ProtocolException('LZ4: match out of bounds'); + } + + for ($i = 0; $i < $matchLen; $i++) { + $dst .= $dst[$matchPos + ($i % $offset)]; + } + + if (strlen($dst) > $maxOutput) { + throw new ProtocolException('LZ4: output too large'); + } + } + + return $dst; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/MsgpackPayloadCodec.php b/src/PHPMax/Protocol/Tcp/MsgpackPayloadCodec.php new file mode 100644 index 0000000..f9c2f82 --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/MsgpackPayloadCodec.php @@ -0,0 +1,408 @@ +containsBinaryString($payload)) { + /** @var string $packed */ + $packed = msgpack_pack($payload); + return $packed; + } + + return $this->packValue($payload); + } + + /** + * @return mixed + */ + public function decode(string $payloadBytes) + { + if ($payloadBytes === '') { + return []; + } + if (function_exists('msgpack_unpack')) { + return msgpack_unpack($payloadBytes); + } + + $offset = 0; + return $this->unpackValue($payloadBytes, $offset); + } + + /** + * @param mixed $value + */ + private function packValue($value): string + { + if ($value === null) { + return "\xC0"; + } + if ($value === false) { + return "\xC2"; + } + if ($value === true) { + return "\xC3"; + } + if (is_int($value)) { + return $this->packInt($value); + } + if (is_float($value)) { + return "\xCB" . pack('E', $value); + } + if (is_string($value)) { + return $this->packString($value); + } + if ($value instanceof BinaryString) { + return $this->packBinary($value->bytes()); + } + if (is_array($value)) { + return $this->isList($value) ? $this->packArray($value) : $this->packMap($value); + } + + throw new ProtocolException('Unsupported MessagePack value type: ' . gettype($value)); + } + + private function packInt(int $value): string + { + if ($value >= 0 && $value <= 0x7F) { + return chr($value); + } + if ($value < 0 && $value >= -32) { + return chr(0xE0 | ($value + 32)); + } + if ($value >= 0 && $value <= 0xFF) { + return "\xCC" . pack('C', $value); + } + if ($value >= 0 && $value <= 0xFFFF) { + return "\xCD" . pack('n', $value); + } + if ($value >= 0 && $value <= 0xFFFFFFFF) { + return "\xCE" . pack('N', $value); + } + if ($value >= 0) { + return "\xCF" . $this->packUInt64($value); + } + if ($value >= -128 && $value < 0) { + return "\xD0" . pack('c', $value); + } + if ($value >= -32768 && $value < 0) { + return "\xD1" . pack('n', $value & 0xFFFF); + } + if ($value >= -2147483648 && $value < 0) { + return "\xD2" . pack('N', $value & 0xFFFFFFFF); + } + + return "\xD3" . $this->packInt64($value); + } + + private function packString(string $value): string + { + $length = strlen($value); + if ($length <= 31) { + return chr(0xA0 | $length) . $value; + } + if ($length <= 0xFF) { + return "\xD9" . pack('C', $length) . $value; + } + if ($length <= 0xFFFF) { + return "\xDA" . pack('n', $length) . $value; + } + + return "\xDB" . pack('N', $length) . $value; + } + + private function packBinary(string $value): string + { + $length = strlen($value); + if ($length <= 0xFF) { + return "\xC4" . pack('C', $length) . $value; + } + if ($length <= 0xFFFF) { + return "\xC5" . pack('n', $length) . $value; + } + + return "\xC6" . pack('N', $length) . $value; + } + + /** + * @param array $items + */ + private function packArray(array $items): string + { + $length = count($items); + if ($length <= 15) { + $out = chr(0x90 | $length); + } elseif ($length <= 0xFFFF) { + $out = "\xDC" . pack('n', $length); + } else { + $out = "\xDD" . pack('N', $length); + } + foreach ($items as $item) { + $out .= $this->packValue($item); + } + return $out; + } + + /** + * @param array $items + */ + private function packMap(array $items): string + { + $length = count($items); + if ($length <= 15) { + $out = chr(0x80 | $length); + } elseif ($length <= 0xFFFF) { + $out = "\xDE" . pack('n', $length); + } else { + $out = "\xDF" . pack('N', $length); + } + foreach ($items as $key => $value) { + $out .= $this->packValue($key); + $out .= $this->packValue($value); + } + return $out; + } + + /** + * @param mixed $value + */ + private function containsBinaryString($value): bool + { + if ($value instanceof BinaryString) { + return true; + } + if (!is_array($value)) { + return false; + } + foreach ($value as $item) { + if ($this->containsBinaryString($item)) { + return true; + } + } + return false; + } + + /** + * @return mixed + */ + private function unpackValue(string $bytes, int &$offset) + { + if ($offset >= strlen($bytes)) { + throw new ProtocolException('MessagePack: unexpected end of payload'); + } + + $prefix = ord($bytes[$offset]); + $offset++; + + if ($prefix <= 0x7F) { + return $prefix; + } + if ($prefix >= 0xE0) { + return $prefix - 0x100; + } + if (($prefix & 0xE0) === 0xA0) { + return $this->readBytes($bytes, $offset, $prefix & 0x1F); + } + if (($prefix & 0xF0) === 0x90) { + return $this->unpackArray($bytes, $offset, $prefix & 0x0F); + } + if (($prefix & 0xF0) === 0x80) { + return $this->unpackMap($bytes, $offset, $prefix & 0x0F); + } + + switch ($prefix) { + case 0xC0: + return null; + case 0xC2: + return false; + case 0xC3: + return true; + case 0xCC: + return $this->readUInt($bytes, $offset, 1); + case 0xCD: + return $this->readUInt($bytes, $offset, 2); + case 0xCE: + return $this->readUInt($bytes, $offset, 4); + case 0xCF: + return $this->readUInt64($bytes, $offset); + case 0xD0: + return $this->readInt($bytes, $offset, 1); + case 0xD1: + return $this->readInt($bytes, $offset, 2); + case 0xD2: + return $this->readInt($bytes, $offset, 4); + case 0xD3: + return $this->readInt64($bytes, $offset); + case 0xD9: + return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 1)); + case 0xDA: + return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 2)); + case 0xDB: + return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 4)); + case 0xC4: + return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 1)); + case 0xC5: + return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 2)); + case 0xDC: + return $this->unpackArray($bytes, $offset, $this->readUInt($bytes, $offset, 2)); + case 0xDD: + return $this->unpackArray($bytes, $offset, $this->readUInt($bytes, $offset, 4)); + case 0xDE: + return $this->unpackMap($bytes, $offset, $this->readUInt($bytes, $offset, 2)); + case 0xDF: + return $this->unpackMap($bytes, $offset, $this->readUInt($bytes, $offset, 4)); + case 0xCA: + $raw = $this->readBytes($bytes, $offset, 4); + $unpacked = unpack('G', $raw); + return $unpacked[1]; + case 0xCB: + $raw = $this->readBytes($bytes, $offset, 8); + $unpacked = unpack('E', $raw); + return $unpacked[1]; + } + + throw new ProtocolException('Unsupported MessagePack prefix 0x' . strtoupper(dechex($prefix))); + } + + /** + * @return array + */ + private function unpackArray(string $bytes, int &$offset, int $length): array + { + $items = []; + for ($i = 0; $i < $length; $i++) { + $items[] = $this->unpackValue($bytes, $offset); + } + return $items; + } + + /** + * @return array + */ + private function unpackMap(string $bytes, int &$offset, int $length): array + { + $items = []; + for ($i = 0; $i < $length; $i++) { + $key = $this->unpackValue($bytes, $offset); + $items[$key] = $this->unpackValue($bytes, $offset); + } + return $items; + } + + private function readUInt(string $bytes, int &$offset, int $length): int + { + $raw = $this->readBytes($bytes, $offset, $length); + if ($length === 1) { + return ord($raw); + } + if ($length === 2) { + $parts = unpack('n', $raw); + return (int) $parts[1]; + } + $parts = unpack('N', $raw); + return (int) $parts[1]; + } + + private function readInt(string $bytes, int &$offset, int $length): int + { + $value = $this->readUInt($bytes, $offset, $length); + $bits = $length * 8; + $sign = 1 << ($bits - 1); + if (($value & $sign) !== 0) { + $value -= 1 << $bits; + } + return $value; + } + + private function packUInt64(int $value): string + { + $high = intdiv($value, 4294967296); + $low = $value % 4294967296; + + return pack('N2', $high, $low); + } + + private function packInt64(int $value): string + { + if ($value >= 0) { + return $this->packUInt64($value); + } + + $absMinusOne = -($value + 1); + $high = intdiv($absMinusOne, 4294967296); + $low = $absMinusOne % 4294967296; + + return pack('N2', (~$high) & 0xFFFFFFFF, (~$low) & 0xFFFFFFFF); + } + + private function readUInt64(string $bytes, int &$offset): int + { + $raw = $this->readBytes($bytes, $offset, 8); + $parts = unpack('Nhigh/Nlow', $raw); + $high = (int) $parts['high']; + $low = (int) $parts['low']; + + if ($high > 0x7FFFFFFF) { + throw new ProtocolException('MessagePack uint64 exceeds PHP integer range'); + } + + return $high * 4294967296 + $low; + } + + private function readInt64(string $bytes, int &$offset): int + { + $raw = $this->readBytes($bytes, $offset, 8); + $parts = unpack('Nhigh/Nlow', $raw); + $high = (int) $parts['high']; + $low = (int) $parts['low']; + + if (($high & 0x80000000) === 0) { + return $high * 4294967296 + $low; + } + + if ($high === 0x80000000 && $low === 0) { + return PHP_INT_MIN; + } + + $magnitude = ((~$high) & 0xFFFFFFFF) * 4294967296 + ((~$low) & 0xFFFFFFFF) + 1; + + return -$magnitude; + } + + private function readBytes(string $bytes, int &$offset, int $length): string + { + if ($offset + $length > strlen($bytes)) { + throw new ProtocolException('MessagePack: unexpected end of payload'); + } + $raw = substr($bytes, $offset, $length); + $offset += $length; + return $raw; + } + + /** + * @param array $value + */ + private function isList(array $value): bool + { + $index = 0; + foreach ($value as $key => $_) { + if ($key !== $index) { + return false; + } + $index++; + } + return true; + } +} diff --git a/src/PHPMax/Protocol/Tcp/PackedPacket.php b/src/PHPMax/Protocol/Tcp/PackedPacket.php new file mode 100644 index 0000000..eee7373 --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/PackedPacket.php @@ -0,0 +1,20 @@ +header = $header; + $this->payloadBytes = $payloadBytes; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/TcpPacketFramer.php b/src/PHPMax/Protocol/Tcp/TcpPacketFramer.php new file mode 100644 index 0000000..b20164a --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/TcpPacketFramer.php @@ -0,0 +1,64 @@ +> 24) & 0xFF; + $payloadLen = (int) $parts['packedLen'] & 0x00FFFFFF; + $totalLen = self::HEADER_SIZE + $payloadLen; + if (strlen($data) < $totalLen) { + return null; + } + + return new PackedPacket( + new TcpPacketHeader( + (int) $parts['ver'], + (int) $parts['cmd'], + (int) $parts['seq'], + (int) $parts['opcode'], + $flags, + $payloadLen + ), + substr($data, self::HEADER_SIZE, $payloadLen) + ); + } + + public function unpackHeader(string $data): ?int + { + if (strlen($data) < self::HEADER_SIZE) { + return null; + } + + $parts = unpack('Cver/Ccmd/nseq/nopcode/NpackedLen', substr($data, 0, self::HEADER_SIZE)); + if ($parts === false) { + return null; + } + + return (int) $parts['packedLen'] & 0x00FFFFFF; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/TcpPacketHeader.php b/src/PHPMax/Protocol/Tcp/TcpPacketHeader.php new file mode 100644 index 0000000..3e50fd6 --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/TcpPacketHeader.php @@ -0,0 +1,32 @@ +ver = $ver; + $this->cmd = $cmd; + $this->seq = $seq; + $this->opcode = $opcode; + $this->flags = $flags; + $this->payloadLen = $payloadLen; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/TcpPayloadDecoder.php b/src/PHPMax/Protocol/Tcp/TcpPayloadDecoder.php new file mode 100644 index 0000000..5070a5f --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/TcpPayloadDecoder.php @@ -0,0 +1,83 @@ +serializer = $serializer ?: new MsgpackPayloadCodec(); + $this->compression = $compression ?: new Lz4BlockCompression(); + $this->zstdCompression = $zstdCompression ?: new ZstdCompression(); + } + + /** + * @return array + */ + public function decode(string $payloadBytes, int $flags = 0): array + { + if ($payloadBytes === '') { + return []; + } + + if ($flags === 0xFF) { + $payloadBytes = $this->zstdCompression->decompress($payloadBytes); + } elseif ($flags > 0x7F) { + throw new ProtocolException('invalid TCP compression factor: ' . $flags); + } elseif ($flags > 0) { + $payloadBytes = $this->compression->decompress($payloadBytes); + } + + $result = $this->serializer->decode($payloadBytes); + if (!is_array($result)) { + return []; + } + + return $this->normalizeKeys($result); + } + + /** + * @param mixed $obj + * @return mixed + */ + private function normalizeKeys($obj) + { + if (!is_array($obj)) { + return $obj; + } + $result = []; + foreach ($obj as $key => $value) { + $result[$this->normalizeKey($key)] = $this->normalizeKeys($value); + } + return $result; + } + + /** + * @param mixed $key + */ + private function normalizeKey($key): string + { + if (is_int($key)) { + return (string) $key; + } + if (is_string($key)) { + return $key; + } + return (string) $key; + } +} + diff --git a/src/PHPMax/Protocol/Tcp/TcpProtocol.php b/src/PHPMax/Protocol/Tcp/TcpProtocol.php new file mode 100644 index 0000000..3f70ba5 --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/TcpProtocol.php @@ -0,0 +1,68 @@ +framer = $framer ?: new TcpPacketFramer(); + $this->serializer = $serializer ?: new MsgpackPayloadCodec(); + $this->payloadDecoder = $payloadDecoder ?: new TcpPayloadDecoder($this->serializer); + } + + public function version(): int + { + return self::VERSION; + } + + public function encode(OutboundFrame $frame): string + { + $payloadBytes = $frame->payload !== null ? $this->serializer->encode($frame->payload) : ''; + + return $this->framer->pack( + self::VERSION, + $frame->cmd, + $frame->seq, + $frame->opcode, + 0, + $payloadBytes + ); + } + + public function decode(string $raw): InboundFrame + { + $packet = $this->framer->unpack($raw); + if ($packet === null) { + return new InboundFrame(0, 0, null, null, null); + } + + $payload = $this->payloadDecoder->decode($packet->payloadBytes, $packet->header->flags); + + return new InboundFrame( + $packet->header->opcode, + $packet->header->cmd, + $packet->header->seq, + $payload, + $payload + ); + } +} diff --git a/src/PHPMax/Protocol/Tcp/ZstdCompression.php b/src/PHPMax/Protocol/Tcp/ZstdCompression.php new file mode 100644 index 0000000..9a5a733 --- /dev/null +++ b/src/PHPMax/Protocol/Tcp/ZstdCompression.php @@ -0,0 +1,29 @@ + $maxOutput) { + throw new ProtocolException('Zstd: output too large'); + } + + return $result; + } +} + diff --git a/src/PHPMax/Protocol/Ws/WsProtocol.php b/src/PHPMax/Protocol/Ws/WsProtocol.php new file mode 100644 index 0000000..0a0c4be --- /dev/null +++ b/src/PHPMax/Protocol/Ws/WsProtocol.php @@ -0,0 +1,50 @@ + $frame->ver, + 'opcode' => $frame->opcode, + 'cmd' => $frame->cmd, + 'seq' => $frame->seq, + 'payload' => $frame->payload, + ], JSON_UNESCAPED_SLASHES); + + return $encoded === false ? '{}' : $encoded; + } + + public function decode(string $raw): InboundFrame + { + $data = json_decode($raw, true); + if (!is_array($data)) { + return new InboundFrame(0, 0, null, null, null); + } + + $payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : null; + + return new InboundFrame( + isset($data['opcode']) ? (int) $data['opcode'] : 0, + isset($data['cmd']) ? (int) $data['cmd'] : 0, + isset($data['seq']) ? (int) $data['seq'] : null, + $payload, + $data + ); + } +} diff --git a/src/PHPMax/Runtime/App.php b/src/PHPMax/Runtime/App.php new file mode 100644 index 0000000..a9f4937 --- /dev/null +++ b/src/PHPMax/Runtime/App.php @@ -0,0 +1,310 @@ +|null */ + private $chats; + /** @var array */ + private $users; + /** @var list */ + private $contacts; + /** @var array> */ + private $messages; + /** @var float */ + private $requestTimeout; + + /** + * @param ClientOptions|float|null $options + */ + public function __construct(ConnectionManager $connection, $options = null, ?SessionStoreInterface $store = null) + { + $this->connection = $connection; + if ($options instanceof ClientOptions) { + $this->options = $options; + } elseif (is_float($options) || is_int($options)) { + $this->options = new ClientOptions(['requestTimeout' => (float) $options]); + } else { + $this->options = new ClientOptions(); + } + $this->requestTimeout = $this->options->requestTimeout; + $this->store = $store ?: ($this->options->store ?: new JsonFileSessionStore($this->options->workDir, $this->options->sessionName)); + $this->session = null; + $this->profile = null; + $this->chats = null; + $this->users = []; + $this->contacts = []; + $this->messages = []; + $this->internalRouter = new Router(); + $this->connection->addEventListener([$this, 'dispatchInternalFrame']); + $this->api = new ApiFacade($this); + } + + /** + * @param array $payload + */ + public function invoke(int $opcode, array $payload, int $cmd = Command::REQUEST, ?float $timeout = null): InboundFrame + { + $seq = $this->connection->nextSeq(); + $frame = new OutboundFrame($this->connection->protocolVersion(), $opcode, $seq, $payload, $cmd); + $response = $this->connection->request($frame, $timeout !== null ? $timeout : $this->requestTimeout); + + if ($response->cmd === Command::ERROR) { + throw $this->buildApiError($response); + } + + return $response; + } + + public function options(): ClientOptions + { + return $this->options; + } + + public function store(): SessionStoreInterface + { + return $this->store; + } + + public function api(): ApiFacade + { + return $this->api; + } + + public function onInternal(string $eventType, callable $handler, callable ...$filters): void + { + $this->internalRouter->on($eventType, $handler, ...$filters); + } + + public function dispatchInternalFrame(InboundFrame $frame): void + { + $this->internalRouter->dispatchFrame($frame, $this, false); + } + + public function session(): ?SessionInfo + { + return $this->session; + } + + public function setSession(?SessionInfo $session): void + { + $this->session = $session; + } + + public function close(): void + { + $this->connection->close(); + $this->store->close(); + } + + public function applyLoginState(LoginResponse $login): LoginResponse + { + $login = Binding::bindApiModel($this, $login); + $this->profile = $login->profile; + $this->chats = []; + $this->contacts = $login->contacts; + $this->messages = Binding::bindApiModel($this, $login->messages); + + foreach ($login->chats as $chat) { + if ($chat instanceof Chat) { + $this->cacheChat($chat); + } + } + + if ($login->profile !== null && $login->profile->contact !== null) { + $this->cacheUser($login->profile->contact); + } + + foreach ($login->contacts as $contact) { + if ($contact instanceof User) { + $this->cacheUser($contact); + } + } + + return $login; + } + + public function clearState(): void + { + $this->profile = null; + $this->chats = null; + $this->users = []; + $this->contacts = []; + $this->messages = []; + } + + public function me(): ?Profile + { + return $this->profile; + } + + public function setProfile(Profile $profile): Profile + { + $profile = Binding::bindApiModel($this, $profile); + $this->profile = $profile; + if ($profile->contact !== null) { + $this->cacheUser($profile->contact); + } + + return $profile; + } + + /** + * @return list|null + */ + public function chats(): ?array + { + return $this->chats; + } + + /** + * @return list + */ + public function contacts(): array + { + return $this->contacts; + } + + /** + * @return array> + */ + public function messages(): array + { + return $this->messages; + } + + public function cachedChat(int $chatId): ?Chat + { + foreach ($this->chats !== null ? $this->chats : [] as $chat) { + if ($chat->id === $chatId) { + return $chat; + } + } + + return null; + } + + public function cacheChat(Chat $chat): Chat + { + $chat = Binding::bindApiModel($this, $chat); + if ($chat->id === null) { + return $chat; + } + + if ($this->chats === null) { + $this->chats = [$chat]; + return $chat; + } + + foreach ($this->chats as $index => $cached) { + if ($cached->id === $chat->id) { + $this->chats[$index] = $chat; + return $chat; + } + } + + $this->chats[] = $chat; + + return $chat; + } + + public function removeCachedChat(int $chatId): void + { + if ($this->chats === null) { + return; + } + + $this->chats = array_values(array_filter($this->chats, static function (Chat $chat) use ($chatId): bool { + return $chat->id !== $chatId; + })); + } + + public function cachedUser(int $userId): ?User + { + return $this->users[$userId] ?? null; + } + + public function cacheUser(User $user): User + { + $user = Binding::bindApiModel($this, $user); + if ($user->id !== null) { + $this->users[$user->id] = $user; + } + + return $user; + } + + public function removeCachedUser(int $userId): void + { + unset($this->users[$userId]); + } + + public function connection(): ConnectionManager + { + return $this->connection; + } + + private function buildApiError(InboundFrame $response): ApiException + { + $payload = is_array($response->payload) ? $response->payload : []; + + try { + $error = MaxApiError::fromArray($payload); + + return new ApiException( + $response->opcode, + $error->error, + $error->title, + $error->message, + $error->localizedMessage, + $payload + ); + } catch (ValidationException $e) { + return new ApiException( + $response->opcode, + 'unknown_error', + 'Unknown error', + $e->getMessage(), + $e->getMessage(), + $payload + ); + } + } +} diff --git a/src/PHPMax/Runtime/ConnectionManager.php b/src/PHPMax/Runtime/ConnectionManager.php new file mode 100644 index 0000000..7fb7e6a --- /dev/null +++ b/src/PHPMax/Runtime/ConnectionManager.php @@ -0,0 +1,138 @@ + */ + private $eventListeners; + /** @var int */ + private $seq = -1; + /** @var bool */ + private $open = false; + + public function __construct(TransportInterface $transport, ?FrameProtocolInterface $protocol = null, ?FrameReaderInterface $reader = null) + { + $this->transport = $transport; + $this->protocol = $protocol ?: new TcpProtocol(); + $this->reader = $reader ?: new TcpFrameReader(); + $this->onEvent = null; + $this->eventListeners = []; + } + + public function setEventHandler(?callable $handler): void + { + $this->onEvent = $handler; + } + + public function addEventListener(callable $handler): void + { + $this->eventListeners[] = $handler; + } + + public function open(): void + { + if ($this->open) { + return; + } + $this->transport->connect(); + $this->open = true; + } + + public function close(): void + { + $this->transport->close(); + $this->open = false; + } + + public function send(OutboundFrame $frame): void + { + if (!$this->isOpen()) { + throw new ProtocolException('Connection is not open'); + } + $this->transport->send($this->protocol->encode($frame)); + } + + public function request(OutboundFrame $frame, float $timeout): InboundFrame + { + if (!$this->isOpen()) { + throw new ProtocolException('Connection is not open'); + } + + $this->transport->send($this->protocol->encode($frame)); + $deadline = microtime(true) + $timeout; + + while (true) { + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + throw new ProtocolException('Request timed out for seq=' . $frame->seq); + } + + $inbound = $this->readFrame($remaining); + if ( + ($inbound->cmd === Command::RESPONSE || $inbound->cmd === Command::ERROR) + && $inbound->seq === $frame->seq + ) { + return $inbound; + } + + $this->dispatchEvent($inbound); + } + } + + public function readFrame(float $timeout): InboundFrame + { + if (!$this->isOpen()) { + throw new ProtocolException('Connection is not open'); + } + + return $this->protocol->decode($this->reader->read($this->transport, $timeout)); + } + + public function protocolVersion(): int + { + return $this->protocol->version(); + } + + public function nextSeq(): int + { + $this->seq = ($this->seq + 1) % 0x10000; + return $this->seq; + } + + public function isOpen(): bool + { + return $this->open && $this->transport->connected(); + } + + public function dispatchEvent(InboundFrame $frame): void + { + foreach ($this->eventListeners as $listener) { + call_user_func($listener, $frame); + } + + if ($this->onEvent === null) { + return; + } + + call_user_func($this->onEvent, $frame); + } +} diff --git a/src/PHPMax/Runtime/ExecutionBudget.php b/src/PHPMax/Runtime/ExecutionBudget.php new file mode 100644 index 0000000..2b57503 --- /dev/null +++ b/src/PHPMax/Runtime/ExecutionBudget.php @@ -0,0 +1,37 @@ +deadline = microtime(true) + max(0.0, $seconds); + } + + public static function fromRequestedSeconds(int $seconds, float $safetyMargin): self + { + $maxExecutionTime = (int) ini_get('max_execution_time'); + if ($maxExecutionTime > 0) { + $seconds = min($seconds, max(0, $maxExecutionTime - (int) ceil($safetyMargin))); + } + + return new self((float) $seconds); + } + + public function remaining(): float + { + return max(0.0, $this->deadline - microtime(true)); + } + + public function expired(): bool + { + return $this->remaining() <= 0.0; + } +} + diff --git a/src/PHPMax/Runtime/FrameReaderInterface.php b/src/PHPMax/Runtime/FrameReaderInterface.php new file mode 100644 index 0000000..412ed00 --- /dev/null +++ b/src/PHPMax/Runtime/FrameReaderInterface.php @@ -0,0 +1,12 @@ +framer = $framer ?: new TcpPacketFramer(); + } + + public function read(TransportInterface $transport, float $timeout): string + { + $header = $transport->recv(TcpPacketFramer::HEADER_SIZE, $timeout); + $payloadLen = $this->framer->unpackHeader($header); + if ($payloadLen === null) { + throw new ProtocolException('Failed to unpack TCP packet header'); + } + + return $header . $transport->recv($payloadLen, $timeout); + } +} diff --git a/src/PHPMax/Runtime/WebSocketFrameReader.php b/src/PHPMax/Runtime/WebSocketFrameReader.php new file mode 100644 index 0000000..a199975 --- /dev/null +++ b/src/PHPMax/Runtime/WebSocketFrameReader.php @@ -0,0 +1,21 @@ +recvMessage($timeout); + } +} diff --git a/src/PHPMax/Session/JsonFileSessionStore.php b/src/PHPMax/Session/JsonFileSessionStore.php new file mode 100644 index 0000000..24ad911 --- /dev/null +++ b/src/PHPMax/Session/JsonFileSessionStore.php @@ -0,0 +1,185 @@ +assertSafeFileName($fileName); + + if (!is_dir($workDir) && !mkdir($workDir, 0700, true) && !is_dir($workDir)) { + throw new PHPMaxException('Unable to create session directory: ' . $workDir); + } + if (!is_writable($workDir)) { + throw new PHPMaxException('Session directory is not writable: ' . $workDir); + } + + $this->path = rtrim($workDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName; + } + + public function saveSession(SessionInfo $sessionInfo): void + { + $sessions = $this->readSessions(); + $replaced = false; + foreach ($sessions as $index => $session) { + if (($session['token'] ?? null) === $sessionInfo->token) { + $sessions[$index] = $sessionInfo->toArray(); + $replaced = true; + break; + } + } + if (!$replaced) { + $sessions[] = $sessionInfo->toArray(); + } + $this->writeSessions($sessions); + } + + public function updateToken(string $oldToken, string $newToken): void + { + $sessions = $this->readSessions(); + foreach ($sessions as $index => $session) { + if (($session['token'] ?? null) === $oldToken) { + $sessions[$index]['token'] = $newToken; + $this->writeSessions($sessions); + return; + } + } + } + + public function loadSession(): ?SessionInfo + { + $sessions = $this->readSessions(); + if ($sessions === []) { + return null; + } + + return SessionInfo::fromArray($sessions[0]); + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + foreach ($this->readSessions() as $session) { + if (($session['deviceId'] ?? $session['device_id'] ?? null) === $deviceId) { + return SessionInfo::fromArray($session); + } + } + + return null; + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + foreach ($this->readSessions() as $session) { + if (($session['phone'] ?? null) === $phone) { + return SessionInfo::fromArray($session); + } + } + + return null; + } + + public function deleteSession(string $token): void + { + $sessions = []; + foreach ($this->readSessions() as $session) { + if (($session['token'] ?? null) !== $token) { + $sessions[] = $session; + } + } + $this->writeSessions($sessions); + } + + public function deleteAllSessions(): void + { + $this->writeSessions([]); + } + + public function close(): void + { + } + + private function assertSafeFileName(string $fileName): void + { + if ( + $fileName === '' + || $fileName === '.' + || $fileName === '..' + || strpos($fileName, '/') !== false + || strpos($fileName, '\\') !== false + || strpos($fileName, "\0") !== false + ) { + throw new PHPMaxException('Session file name must be a plain file name'); + } + } + + /** + * @return list> + */ + private function readSessions(): array + { + if (!is_file($this->path)) { + return []; + } + + $json = file_get_contents($this->path); + if ($json === false || $json === '') { + return []; + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new PHPMaxException('Invalid session store JSON: ' . $this->path); + } + + if (isset($data['sessions']) && is_array($data['sessions'])) { + return array_values(array_filter($data['sessions'], 'is_array')); + } + + return [$data]; + } + + /** + * @param list> $sessions + */ + private function writeSessions(array $sessions): void + { + $payload = json_encode(['sessions' => $sessions], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($payload === false) { + throw new PHPMaxException('Failed to encode session store JSON'); + } + + $lockPath = $this->path . '.lock'; + $lock = fopen($lockPath, 'c'); + if ($lock === false) { + throw new PHPMaxException('Unable to open session lock file: ' . $lockPath); + } + + try { + if (!flock($lock, LOCK_EX)) { + throw new PHPMaxException('Unable to lock session store: ' . $this->path); + } + + $tmpPath = $this->path . '.' . getmypid() . '.tmp'; + if (file_put_contents($tmpPath, $payload . PHP_EOL, LOCK_EX) === false) { + throw new PHPMaxException('Unable to write temporary session store: ' . $tmpPath); + } + @chmod($tmpPath, 0600); + if (!rename($tmpPath, $this->path)) { + @unlink($tmpPath); + throw new PHPMaxException('Unable to replace session store atomically: ' . $this->path); + } + @chmod($this->path, 0600); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } +} diff --git a/src/PHPMax/Session/SQLiteSessionStore.php b/src/PHPMax/Session/SQLiteSessionStore.php new file mode 100644 index 0000000..c25a24f --- /dev/null +++ b/src/PHPMax/Session/SQLiteSessionStore.php @@ -0,0 +1,278 @@ +assertSafeFileName($fileName); + + if (!class_exists(PDO::class) || !in_array('sqlite', PDO::getAvailableDrivers(), true)) { + throw new PHPMaxException('PDO SQLite driver is required for SQLiteSessionStore'); + } + + if (!is_dir($workDir) && !mkdir($workDir, 0700, true) && !is_dir($workDir)) { + throw new PHPMaxException('Unable to create session directory: ' . $workDir); + } + if (!is_writable($workDir)) { + throw new PHPMaxException('Session directory is not writable: ' . $workDir); + } + + $this->path = rtrim($workDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName; + $this->pdo = null; + } + + public function saveSession(SessionInfo $sessionInfo): void + { + $sync = $sessionInfo->sync instanceof SyncState ? $sessionInfo->sync : new SyncState(); + + $this->execute( + 'INSERT OR REPLACE INTO sessions ( + token, + device_id, + phone, + mt_instance_id, + chats_sync, + contacts_sync, + drafts_sync, + presence_sync, + config_hash + ) VALUES ( + :token, + :device_id, + :phone, + :mt_instance_id, + :chats_sync, + :contacts_sync, + :drafts_sync, + :presence_sync, + :config_hash + )', + [ + ':token' => (string) $sessionInfo->token, + ':device_id' => (string) $sessionInfo->deviceId, + ':phone' => (string) $sessionInfo->phone, + ':mt_instance_id' => (string) $sessionInfo->mtInstanceId, + ':chats_sync' => (int) $sync->chatsSync, + ':contacts_sync' => (int) $sync->contactsSync, + ':drafts_sync' => (int) $sync->draftsSync, + ':presence_sync' => (int) $sync->presenceSync, + ':config_hash' => (string) $sync->configHash, + ] + ); + } + + public function updateToken(string $oldToken, string $newToken): void + { + $this->execute( + 'UPDATE sessions SET token = :new_token WHERE token = :old_token', + [ + ':new_token' => $newToken, + ':old_token' => $oldToken, + ] + ); + } + + public function loadSession(): ?SessionInfo + { + $row = $this->fetchOne( + 'SELECT token, device_id, phone, mt_instance_id, chats_sync, contacts_sync, drafts_sync, presence_sync, config_hash + FROM sessions + ORDER BY rowid ASC + LIMIT 1', + [] + ); + + return $row === null ? null : $this->rowToSession($row); + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + $row = $this->fetchOne( + 'SELECT token, device_id, phone, mt_instance_id, chats_sync, contacts_sync, drafts_sync, presence_sync, config_hash + FROM sessions + WHERE device_id = :device_id + ORDER BY rowid ASC + LIMIT 1', + [':device_id' => $deviceId] + ); + + return $row === null ? null : $this->rowToSession($row); + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + $row = $this->fetchOne( + 'SELECT token, device_id, phone, mt_instance_id, chats_sync, contacts_sync, drafts_sync, presence_sync, config_hash + FROM sessions + WHERE phone = :phone + ORDER BY rowid ASC + LIMIT 1', + [':phone' => $phone] + ); + + return $row === null ? null : $this->rowToSession($row); + } + + public function deleteSession(string $token): void + { + $this->execute('DELETE FROM sessions WHERE token = :token', [':token' => $token]); + } + + public function deleteAllSessions(): void + { + $this->execute('DELETE FROM sessions'); + } + + public function close(): void + { + $this->pdo = null; + } + + private function assertSafeFileName(string $fileName): void + { + if ( + $fileName === '' + || $fileName === '.' + || $fileName === '..' + || strpos($fileName, '/') !== false + || strpos($fileName, '\\') !== false + || strpos($fileName, "\0") !== false + ) { + throw new PHPMaxException('Session file name must be a plain file name'); + } + } + + /** + * @return array|null + */ + private function fetchOne(string $sql, array $parameters): ?array + { + try { + $statement = $this->connection()->prepare($sql); + $statement->execute($parameters); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + throw new PHPMaxException('SQLite session store query failed: ' . $e->getMessage(), 0, $e); + } + + return is_array($row) ? $row : null; + } + + /** + * @param array $parameters + */ + private function execute(string $sql, array $parameters = []): void + { + try { + $statement = $this->connection()->prepare($sql); + $statement->execute($parameters); + } catch (PDOException $e) { + throw new PHPMaxException('SQLite session store write failed: ' . $e->getMessage(), 0, $e); + } + } + + private function connection(): PDO + { + if ($this->pdo instanceof PDO) { + return $this->pdo; + } + + try { + $this->pdo = new PDO('sqlite:' . $this->path); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec('PRAGMA busy_timeout = 5000'); + $this->initializeDatabase($this->pdo); + } catch (PDOException $e) { + $this->pdo = null; + throw new PHPMaxException('Unable to open SQLite session store: ' . $this->path, 0, $e); + } + + @chmod($this->path, 0600); + + return $this->pdo; + } + + private function initializeDatabase(PDO $pdo): void + { + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS sessions ( + token TEXT NOT NULL PRIMARY KEY, + device_id TEXT NOT NULL, + phone TEXT NOT NULL, + mt_instance_id TEXT NOT NULL DEFAULT \'\', + chats_sync INTEGER NOT NULL DEFAULT -1, + contacts_sync INTEGER NOT NULL DEFAULT -1, + drafts_sync INTEGER NOT NULL DEFAULT -1, + presence_sync INTEGER NOT NULL DEFAULT -1, + config_hash TEXT NOT NULL DEFAULT \'\' + )' + ); + + $this->ensureColumn($pdo, 'mt_instance_id', 'TEXT NOT NULL DEFAULT \'\''); + $this->ensureColumn($pdo, 'chats_sync', 'INTEGER NOT NULL DEFAULT -1'); + $this->ensureColumn($pdo, 'contacts_sync', 'INTEGER NOT NULL DEFAULT -1'); + $this->ensureColumn($pdo, 'drafts_sync', 'INTEGER NOT NULL DEFAULT -1'); + $this->ensureColumn($pdo, 'presence_sync', 'INTEGER NOT NULL DEFAULT -1'); + $this->ensureColumn($pdo, 'config_hash', 'TEXT NOT NULL DEFAULT \'\''); + + $statement = $pdo->prepare('UPDATE sessions SET config_hash = :hash WHERE config_hash = \'\''); + $statement->execute([':hash' => SyncState::DEFAULT_CONFIG_HASH]); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_sessions_device_id ON sessions (device_id)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_sessions_phone ON sessions (phone)'); + } + + private function ensureColumn(PDO $pdo, string $name, string $definition): void + { + $statement = $pdo->query('PRAGMA table_info(sessions)'); + if ($statement === false) { + throw new PHPMaxException('Unable to inspect SQLite session table'); + } + + $columns = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (isset($row['name'])) { + $columns[(string) $row['name']] = true; + } + } + + if (!isset($columns[$name])) { + $pdo->exec('ALTER TABLE sessions ADD COLUMN ' . $name . ' ' . $definition); + } + } + + /** + * @param array $row + */ + private function rowToSession(array $row): SessionInfo + { + return new SessionInfo([ + 'token' => (string) $row['token'], + 'deviceId' => (string) $row['device_id'], + 'phone' => (string) $row['phone'], + 'mtInstanceId' => (string) ($row['mt_instance_id'] ?? ''), + 'sync' => new SyncState([ + 'chatsSync' => (int) ($row['chats_sync'] ?? -1), + 'contactsSync' => (int) ($row['contacts_sync'] ?? -1), + 'draftsSync' => (int) ($row['drafts_sync'] ?? -1), + 'presenceSync' => (int) ($row['presence_sync'] ?? -1), + 'configHash' => ($row['config_hash'] ?? '') !== '' + ? $row['config_hash'] + : SyncState::DEFAULT_CONFIG_HASH, + ]), + ]); + } +} diff --git a/src/PHPMax/Session/SessionInfo.php b/src/PHPMax/Session/SessionInfo.php new file mode 100644 index 0000000..34d53de --- /dev/null +++ b/src/PHPMax/Session/SessionInfo.php @@ -0,0 +1,36 @@ + ['type' => 'string', 'required' => true], + 'deviceId' => ['type' => 'string', 'required' => true], + 'phone' => ['type' => 'string', 'required' => true], + 'mtInstanceId' => ['type' => 'string', 'default' => ''], + 'sync' => ['type' => SyncState::class, 'default' => static function (): SyncState { + return new SyncState(); + }], + ]; + } +} + diff --git a/src/PHPMax/Session/SessionStoreInterface.php b/src/PHPMax/Session/SessionStoreInterface.php new file mode 100644 index 0000000..0fcabb4 --- /dev/null +++ b/src/PHPMax/Session/SessionStoreInterface.php @@ -0,0 +1,24 @@ + */ + private $extra = []; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->hydrate($data); + } + + /** + * @param array $data + * @return static + */ + public static function fromArray(array $data) + { + return new static($data); + } + + /** + * @return array> + */ + protected static function schema(): array + { + return []; + } + + /** + * @return array + */ + public function toArray(bool $excludeNull = true): array + { + $result = []; + foreach (static::schema() as $property => $definition) { + $key = isset($definition['payload']) ? (string) $definition['payload'] : (string) $property; + $value = property_exists($this, $property) ? $this->{$property} : null; + if ($excludeNull && $value === null) { + continue; + } + $result[$key] = self::serializeValue($value, $excludeNull); + } + + foreach ($this->extra as $key => $value) { + if ($excludeNull && $value === null) { + continue; + } + if (!array_key_exists($key, $result)) { + $result[$key] = self::serializeValue($value, $excludeNull); + } + } + + return $result; + } + + /** + * @return array + */ + public function extra(): array + { + return $this->extra; + } + + /** + * @param array $data + */ + private function hydrate(array $data): void + { + $data = static::normalizeInput($data); + $usedKeys = []; + foreach (static::schema() as $property => $definition) { + $found = false; + $value = null; + foreach ($this->candidateKeys((string) $property, $definition) as $key) { + if (array_key_exists($key, $data)) { + $value = $data[$key]; + $found = true; + $usedKeys[$key] = true; + break; + } + } + + if (!$found) { + if (array_key_exists('default', $definition)) { + $this->{$property} = $this->resolveDefault($definition['default']); + continue; + } + if (!empty($definition['required'])) { + throw new ValidationException('Missing required field `' . $property . '` for ' . static::class); + } + $this->{$property} = null; + continue; + } + + $this->{$property} = $this->castValue($value, $definition); + } + + if ($this->preservesExtraFields()) { + foreach ($data as $key => $value) { + if (!isset($usedKeys[$key])) { + $this->extra[(string) $key] = $value; + } + } + } + } + + private function preservesExtraFields(): bool + { + return strncmp(static::class, 'PHPMax\\Domain\\', strlen('PHPMax\\Domain\\')) === 0; + } + + /** + * @param array $data + * @return array + */ + protected static function normalizeInput(array $data): array + { + return $data; + } + + /** + * @param array $definition + * @return list + */ + private function candidateKeys(string $property, array $definition): array + { + $keys = [$property, self::snakeToCamel($property), self::camelToSnake($property)]; + if (isset($definition['payload'])) { + array_unshift($keys, (string) $definition['payload']); + } + if (isset($definition['aliases']) && is_array($definition['aliases'])) { + foreach ($definition['aliases'] as $alias) { + array_unshift($keys, (string) $alias); + } + } + + return array_values(array_unique($keys)); + } + + /** + * @param mixed $default + * @return mixed + */ + private function resolveDefault($default) + { + return is_object($default) && is_callable($default) ? $default() : $default; + } + + /** + * @param array $definition + * @return mixed + */ + private function castValue($value, array $definition) + { + if ($value === null) { + return null; + } + + if (isset($definition['factory']) && is_callable($definition['factory'])) { + return $definition['factory']($value); + } + + $type = isset($definition['type']) ? (string) $definition['type'] : 'mixed'; + + if ($type === 'mixed') { + return $value; + } + if ($type === 'int') { + return self::castInt($value); + } + if ($type === 'string') { + return self::castString($value); + } + if ($type === 'bool') { + return self::castBool($value); + } + if ($type === 'array') { + if (!is_array($value)) { + throw new ValidationException('Expected array value for ' . static::class); + } + return $value; + } + if (strpos($type, 'list<') === 0 && substr($type, -1) === '>') { + $class = substr($type, 5, -1); + if (!is_array($value) || !self::isListArray($value)) { + throw new ValidationException('Expected list value for ' . static::class); + } + $items = []; + foreach ($value as $item) { + $items[] = self::castListItem($class, $item); + } + return $items; + } + if (strpos($type, 'map-list<') === 0 && substr($type, -1) === '>') { + $class = substr($type, 9, -1); + if (!is_array($value)) { + throw new ValidationException('Expected map-list value for ' . static::class); + } + $map = []; + foreach ($value as $key => $items) { + if (!is_array($items) || !self::isListArray($items)) { + throw new ValidationException('Expected list items for map-list field in ' . static::class); + } + $map[$key] = []; + foreach ($items as $item) { + $map[$key][] = self::castListItem($class, $item); + } + } + return $map; + } + + if (is_a($type, self::class, true)) { + return $this->castClass($type, $value); + } + + return $value; + } + + /** + * @return mixed + */ + private function castClass(string $class, $value) + { + if ($value instanceof $class) { + return $value; + } + if (!is_array($value)) { + throw new ValidationException('Expected array for model `' . $class . '`'); + } + return $class::fromArray($value); + } + + /** + * @param mixed $value + * @return mixed + */ + private static function castListItem(string $type, $value) + { + if ($type === 'mixed') { + return $value; + } + if ($type === 'int') { + return self::castInt($value); + } + if ($type === 'string') { + return self::castString($value); + } + if ($type === 'bool') { + return self::castBool($value); + } + if ($type === 'array') { + if (!is_array($value)) { + throw new ValidationException('Expected array list item'); + } + + return $value; + } + if (is_a($type, self::class, true)) { + if ($value instanceof $type) { + return $value; + } + if (!is_array($value)) { + throw new ValidationException('Expected array for model `' . $type . '`'); + } + + return $type::fromArray($value); + } + + throw new ValidationException('Unsupported list item type `' . $type . '`'); + } + + /** + * @return mixed + */ + private static function serializeValue($value, bool $excludeNull) + { + if ($value instanceof self) { + return $value->toArray($excludeNull); + } + if (is_array($value)) { + $result = []; + foreach ($value as $key => $item) { + if ($excludeNull && $item === null) { + continue; + } + $result[$key] = self::serializeValue($item, $excludeNull); + } + return $result; + } + return $value; + } + + private static function snakeToCamel(string $value): string + { + return preg_replace_callback('/_([a-z])/', static function (array $matches): string { + return strtoupper($matches[1]); + }, $value) ?? $value; + } + + private static function camelToSnake(string $value): string + { + return strtolower(preg_replace('/(? $value + */ + protected static function isListArray(array $value): bool + { + $expected = 0; + foreach ($value as $key => $_) { + if ($key !== $expected) { + return false; + } + $expected++; + } + + return true; + } + + /** + * @param mixed $value + */ + private static function castInt($value): int + { + if (is_bool($value)) { + return $value ? 1 : 0; + } + if (is_int($value)) { + return $value; + } + if (is_float($value)) { + if (floor($value) !== $value) { + throw new ValidationException('Expected integer-compatible value'); + } + + return (int) $value; + } + if (is_string($value)) { + $trimmed = trim($value); + if (preg_match('/^[+-]?\d+$/', $trimmed) === 1) { + return (int) $trimmed; + } + if (preg_match('/^[+-]?\d+\.0+$/', $trimmed) === 1) { + return (int) $trimmed; + } + } + + throw new ValidationException('Expected integer-compatible value'); + } + + /** + * @param mixed $value + */ + private static function castString($value): string + { + if (is_string($value)) { + return $value; + } + if ($value === false) { + return ''; + } + + throw new ValidationException('Expected string value for ' . static::class); + } + + /** + * @param mixed $value + */ + private static function castBool($value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + if ($value === 1) { + return true; + } + if ($value === 0) { + return false; + } + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on', 'y'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off', 'n'], true)) { + return false; + } + } + + throw new ValidationException('Expected boolean-compatible value'); + } +} diff --git a/src/PHPMax/Transport/MessageTransportInterface.php b/src/PHPMax/Transport/MessageTransportInterface.php new file mode 100644 index 0000000..4c24336 --- /dev/null +++ b/src/PHPMax/Transport/MessageTransportInterface.php @@ -0,0 +1,10 @@ + 65535) { + throw new ProtocolException('Invalid proxy host or port'); + } + + $this->scheme = $scheme; + $this->host = $host; + $this->port = $port; + $this->username = $username; + $this->password = $password; + } + + public static function fromUrl(?string $url): ?self + { + if ($url === null || trim($url) === '') { + return null; + } + + $parts = parse_url($url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new ProtocolException('Invalid proxy URL'); + } + + $scheme = strtolower((string) $parts['scheme']); + $port = isset($parts['port']) ? (int) $parts['port'] : self::defaultPort($scheme); + $username = isset($parts['user']) ? rawurldecode((string) $parts['user']) : null; + $password = isset($parts['pass']) ? rawurldecode((string) $parts['pass']) : null; + + return new self($scheme, (string) $parts['host'], $port, $username, $password); + } + + public function scheme(): string + { + return $this->scheme; + } + + public function host(): string + { + return $this->host; + } + + public function port(): int + { + return $this->port; + } + + public function username(): ?string + { + return $this->username; + } + + public function password(): ?string + { + return $this->password; + } + + public function authority(): string + { + return $this->host . ':' . $this->port; + } + + public function streamTarget(): string + { + return ($this->scheme === 'https' ? 'tls' : 'tcp') . '://' . $this->authority(); + } + + public function basicAuthorizationHeader(): ?string + { + if ($this->username === null) { + return null; + } + + return 'Proxy-Authorization: Basic ' . base64_encode($this->username . ':' . (string) $this->password); + } + + public function curlType(): int + { + if ($this->scheme === 'socks5' || $this->scheme === 'socks5h') { + return defined('CURLPROXY_SOCKS5_HOSTNAME') ? CURLPROXY_SOCKS5_HOSTNAME : 7; + } + + return defined('CURLPROXY_HTTP') ? CURLPROXY_HTTP : 0; + } + + public function curlProxyUrl(): string + { + return $this->scheme . '://' . $this->authority(); + } + + public function curlUserPassword(): ?string + { + if ($this->username === null) { + return null; + } + + return $this->username . ':' . (string) $this->password; + } + + public function isHttpConnect(): bool + { + return $this->scheme === 'http' || $this->scheme === 'https'; + } + + public function isSocks5(): bool + { + return $this->scheme === 'socks5' || $this->scheme === 'socks5h'; + } + + private static function defaultPort(string $scheme): int + { + if ($scheme === 'http') { + return 8080; + } + if ($scheme === 'https') { + return 443; + } + if ($scheme === 'socks5' || $scheme === 'socks5h') { + return 1080; + } + + throw new ProtocolException('Unsupported proxy scheme: ' . $scheme); + } +} diff --git a/src/PHPMax/Transport/ProxyConnector.php b/src/PHPMax/Transport/ProxyConnector.php new file mode 100644 index 0000000..999ac94 --- /dev/null +++ b/src/PHPMax/Transport/ProxyConnector.php @@ -0,0 +1,238 @@ +proxy = $proxy; + } + + /** + * @return resource + */ + public function connect(string $targetHost, int $targetPort, bool $targetSsl, float $timeout) + { + $this->assertEndpoint($targetHost, $targetPort); + $timeout = $this->normalizeTimeout($timeout); + + $stream = $this->connectProxy($targetHost, $timeout); + + if ($this->proxy->isHttpConnect()) { + $this->httpConnect($stream, $targetHost, $targetPort, $timeout); + } elseif ($this->proxy->isSocks5()) { + $this->socks5Connect($stream, $targetHost, $targetPort, $timeout); + } else { + throw new ProtocolException('Unsupported proxy scheme: ' . $this->proxy->scheme()); + } + + if ($targetSsl) { + stream_context_set_option($stream, 'ssl', 'SNI_enabled', true); + stream_context_set_option($stream, 'ssl', 'peer_name', $targetHost); + stream_context_set_option($stream, 'ssl', 'verify_peer', true); + stream_context_set_option($stream, 'ssl', 'verify_peer_name', true); + $enabled = @stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + if ($enabled !== true) { + throw new ProtocolException('Failed to enable TLS over proxy connection'); + } + } + + return $stream; + } + + /** + * @return resource + */ + private function connectProxy(string $targetHost, float $timeout) + { + $errno = 0; + $errstr = ''; + $context = stream_context_create([ + 'ssl' => [ + 'SNI_enabled' => true, + 'peer_name' => $this->proxy->host(), + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $stream = @stream_socket_client($this->proxy->streamTarget(), $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $context); + if (!is_resource($stream)) { + throw new ProtocolException(sprintf('Failed to connect to proxy %s: [%d] %s', $this->proxy->authority(), $errno, $errstr)); + } + stream_set_blocking($stream, true); + + return $stream; + } + + /** + * @param resource $stream + */ + private function httpConnect($stream, string $targetHost, int $targetPort, float $timeout): void + { + $target = $targetHost . ':' . $targetPort; + $headers = [ + 'CONNECT ' . $target . ' HTTP/1.1', + 'Host: ' . $target, + ]; + $auth = $this->proxy->basicAuthorizationHeader(); + if ($auth !== null) { + $headers[] = $auth; + } + $headers[] = ''; + $headers[] = ''; + $this->writeAll($stream, implode("\r\n", $headers)); + + $response = $this->readHeaders($stream, $timeout); + $firstLine = strtok($response, "\r\n"); + if ($firstLine === false || !preg_match('/^HTTP\/\S+\s+2\d\d\b/', $firstLine)) { + throw new ProtocolException('HTTP proxy CONNECT failed'); + } + } + + /** + * @param resource $stream + */ + private function socks5Connect($stream, string $targetHost, int $targetPort, float $timeout): void + { + $methods = $this->proxy->username() !== null ? "\x00\x02" : "\x00"; + $this->writeAll($stream, "\x05" . chr(strlen($methods)) . $methods); + $selection = $this->readExact($stream, 2, $timeout); + if ($selection[0] !== "\x05") { + throw new ProtocolException('Invalid SOCKS5 proxy greeting'); + } + $method = ord($selection[1]); + if ($method === 0x02) { + $this->socks5Authenticate($stream, $timeout); + } elseif ($method !== 0x00) { + throw new ProtocolException('SOCKS5 proxy rejected authentication methods'); + } + + $hostLength = strlen($targetHost); + if ($hostLength > 255) { + throw new ProtocolException('SOCKS5 target host is too long'); + } + $request = "\x05\x01\x00\x03" . chr($hostLength) . $targetHost . pack('n', $targetPort); + $this->writeAll($stream, $request); + $head = $this->readExact($stream, 4, $timeout); + if ($head[0] !== "\x05" || ord($head[1]) !== 0x00) { + throw new ProtocolException('SOCKS5 proxy CONNECT failed'); + } + $atype = ord($head[3]); + if ($atype === 0x01) { + $this->readExact($stream, 4, $timeout); + } elseif ($atype === 0x03) { + $length = ord($this->readExact($stream, 1, $timeout)); + $this->readExact($stream, $length, $timeout); + } elseif ($atype === 0x04) { + $this->readExact($stream, 16, $timeout); + } else { + throw new ProtocolException('Invalid SOCKS5 response address type'); + } + $this->readExact($stream, 2, $timeout); + } + + /** + * @param resource $stream + */ + private function socks5Authenticate($stream, float $timeout): void + { + $username = (string) $this->proxy->username(); + $password = (string) $this->proxy->password(); + if (strlen($username) > 255 || strlen($password) > 255) { + throw new ProtocolException('SOCKS5 username/password is too long'); + } + $this->writeAll($stream, "\x01" . chr(strlen($username)) . $username . chr(strlen($password)) . $password); + $response = $this->readExact($stream, 2, $timeout); + if ($response[0] !== "\x01" || $response[1] !== "\x00") { + throw new ProtocolException('SOCKS5 authentication failed'); + } + } + + /** + * @param resource $stream + */ + private function readHeaders($stream, float $timeout): string + { + $headers = ''; + while (strpos($headers, "\r\n\r\n") === false) { + $headers .= $this->readExact($stream, 1, $timeout); + if (strlen($headers) > 16384) { + throw new ProtocolException('Proxy response headers are too large'); + } + } + + return $headers; + } + + /** + * @param resource $stream + */ + private function readExact($stream, int $length, float $timeout): string + { + $timeout = $this->normalizeTimeout($timeout); + $seconds = (int) floor($timeout); + $microseconds = (int) max(0, ($timeout - $seconds) * 1000000); + stream_set_timeout($stream, $seconds, $microseconds); + + $data = ''; + while (strlen($data) < $length) { + $chunk = fread($stream, $length - strlen($data)); + if ($chunk === false) { + throw new ProtocolException('Failed to read from proxy'); + } + if ($chunk === '') { + $meta = stream_get_meta_data($stream); + if (!empty($meta['timed_out'])) { + throw new ProtocolException('Timed out reading from proxy'); + } + if (feof($stream)) { + throw new ProtocolException('Proxy closed connection'); + } + continue; + } + $data .= $chunk; + } + + return $data; + } + + /** + * @param resource $stream + */ + private function writeAll($stream, string $data): void + { + $written = 0; + $length = strlen($data); + while ($written < $length) { + $chunk = fwrite($stream, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + throw new ProtocolException('Failed to write to proxy'); + } + $written += $chunk; + } + } + + private function assertEndpoint(string $host, int $port): void + { + if (trim($host) === '' || $port <= 0 || $port > 65535) { + throw new ProtocolException('Invalid proxy target host or port'); + } + } + + private function normalizeTimeout(float $timeout): float + { + if ($timeout <= 0.0) { + return 1.0; + } + + return max(0.001, $timeout); + } +} diff --git a/src/PHPMax/Transport/TcpTransport.php b/src/PHPMax/Transport/TcpTransport.php new file mode 100644 index 0000000..3f46cc3 --- /dev/null +++ b/src/PHPMax/Transport/TcpTransport.php @@ -0,0 +1,159 @@ +assertEndpoint($host, $port); + + $this->host = $host; + $this->port = $port; + $this->useSsl = $useSsl; + $this->connectTimeout = $this->normalizeTimeout($connectTimeout); + $this->proxy = ProxyConfig::fromUrl($proxy); + $this->stream = null; + } + + public function connect(): void + { + if ($this->connected()) { + return; + } + + if ($this->proxy !== null) { + $stream = (new ProxyConnector($this->proxy))->connect($this->host, $this->port, $this->useSsl, $this->connectTimeout); + } else { + $scheme = $this->useSsl ? 'tls' : 'tcp'; + $target = sprintf('%s://%s:%d', $scheme, $this->host, $this->port); + $errno = 0; + $errstr = ''; + $context = stream_context_create([ + 'ssl' => [ + 'SNI_enabled' => true, + 'peer_name' => $this->host, + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $stream = @stream_socket_client($target, $errno, $errstr, $this->connectTimeout, STREAM_CLIENT_CONNECT, $context); + if (!is_resource($stream)) { + throw new ProtocolException(sprintf('Failed to connect to %s: [%d] %s', $target, $errno, $errstr)); + } + } + + stream_set_blocking($stream, true); + $this->stream = $stream; + } + + public function close(): void + { + if (is_resource($this->stream)) { + fclose($this->stream); + } + $this->stream = null; + } + + public function send(string $data): void + { + $stream = $this->requireStream(); + $written = 0; + $length = strlen($data); + while ($written < $length) { + $chunk = fwrite($stream, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + throw new ProtocolException('Failed to write to TCP transport'); + } + $written += $chunk; + } + } + + public function recv(int $length, float $timeout): string + { + if ($length < 0) { + throw new ProtocolException('TCP recv length must not be negative'); + } + if ($length === 0) { + return ''; + } + + $stream = $this->requireStream(); + $timeout = $this->normalizeTimeout($timeout); + $seconds = (int) floor($timeout); + $microseconds = (int) max(0, ($timeout - $seconds) * 1000000); + stream_set_timeout($stream, $seconds, $microseconds); + + $data = ''; + while (strlen($data) < $length) { + $chunk = fread($stream, $length - strlen($data)); + if ($chunk === false) { + $meta = stream_get_meta_data($stream); + if (!empty($meta['timed_out'])) { + throw new ProtocolException('Timed out reading from TCP transport'); + } + throw new ProtocolException('Failed to read from TCP transport'); + } + if ($chunk === '') { + $meta = stream_get_meta_data($stream); + if (!empty($meta['timed_out'])) { + throw new ProtocolException('Timed out reading from TCP transport'); + } + if (feof($stream)) { + throw new ProtocolException('TCP transport closed by peer'); + } + continue; + } + $data .= $chunk; + } + + return $data; + } + + public function connected(): bool + { + return is_resource($this->stream) && !feof($this->stream); + } + + /** + * @return resource + */ + private function requireStream() + { + if (!$this->connected()) { + throw new ProtocolException('TCP transport is not connected'); + } + + return $this->stream; + } + + private function assertEndpoint(string $host, int $port): void + { + if (trim($host) === '' || $port <= 0 || $port > 65535) { + throw new ProtocolException('Invalid TCP transport host or port'); + } + } + + private function normalizeTimeout(float $timeout): float + { + return max(0.001, $timeout); + } +} diff --git a/src/PHPMax/Transport/TransportInterface.php b/src/PHPMax/Transport/TransportInterface.php new file mode 100644 index 0000000..4f0e94e --- /dev/null +++ b/src/PHPMax/Transport/TransportInterface.php @@ -0,0 +1,19 @@ +url = $url; + $this->connectTimeout = $this->normalizeTimeout($connectTimeout); + $this->origin = $origin; + $this->proxy = ProxyConfig::fromUrl($proxy); + $this->stream = null; + $this->host = null; + } + + public function connect(): void + { + if ($this->connected()) { + return; + } + + $parts = parse_url($this->url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new ProtocolException('Invalid WebSocket URL: ' . $this->url); + } + + $scheme = strtolower((string) $parts['scheme']); + if ($scheme !== 'ws' && $scheme !== 'wss') { + throw new ProtocolException('Unsupported WebSocket URL scheme: ' . $scheme); + } + + $host = (string) $parts['host']; + $port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'wss' ? 443 : 80); + $this->assertEndpoint($host, $port); + $path = isset($parts['path']) && $parts['path'] !== '' ? (string) $parts['path'] : '/'; + if (isset($parts['query']) && $parts['query'] !== '') { + $path .= '?' . $parts['query']; + } + + if ($this->proxy !== null) { + $stream = (new ProxyConnector($this->proxy))->connect($host, $port, $scheme === 'wss', $this->connectTimeout); + } else { + $contextOptions = []; + if ($scheme === 'wss') { + $contextOptions['ssl'] = [ + 'SNI_enabled' => true, + 'peer_name' => $host, + 'verify_peer' => true, + 'verify_peer_name' => true, + ]; + } + + $target = sprintf('%s://%s:%d', $scheme === 'wss' ? 'tls' : 'tcp', $host, $port); + $errno = 0; + $errstr = ''; + $stream = @stream_socket_client( + $target, + $errno, + $errstr, + $this->connectTimeout, + STREAM_CLIENT_CONNECT, + stream_context_create($contextOptions) + ); + if (!is_resource($stream)) { + throw new ProtocolException(sprintf('Failed to connect to %s: [%d] %s', $target, $errno, $errstr)); + } + } + + stream_set_blocking($stream, true); + $this->stream = $stream; + $this->host = $host; + + $this->handshake($path, $host, $port, $scheme === 'wss'); + } + + public function close(): void + { + if ($this->connected()) { + try { + $this->sendFrame('', 0x8); + } catch (ProtocolException $e) { + } + } + if (is_resource($this->stream)) { + fclose($this->stream); + } + $this->stream = null; + $this->host = null; + } + + public function send(string $data): void + { + $this->sendFrame($data, 0x1); + } + + public function recv(int $length, float $timeout): string + { + return $this->recvMessage($timeout); + } + + public function recvMessage(float $timeout): string + { + $timeout = $this->normalizeTimeout($timeout); + $message = ''; + $started = false; + + while (true) { + $frame = $this->readFrame($timeout); + $opcode = $frame['opcode']; + + if ($opcode === 0x8) { + $this->close(); + throw new ProtocolException('WebSocket transport closed by peer'); + } + if ($opcode === 0x9) { + $this->sendFrame($frame['payload'], 0xA); + continue; + } + if ($opcode === 0xA) { + continue; + } + if ($opcode === 0x2) { + throw new ProtocolException('Binary WebSocket messages are not supported'); + } + if ($opcode === 0x1) { + if ($started) { + throw new ProtocolException('Unexpected WebSocket data frame before fragmented message is complete'); + } + + $message = $frame['payload']; + if ($frame['fin']) { + return $this->requireUtf8TextMessage($message); + } + + $started = true; + continue; + } + if ($opcode === 0x0) { + if (!$started) { + throw new ProtocolException('Unexpected WebSocket continuation frame'); + } + + $message .= $frame['payload']; + if ($frame['fin']) { + return $this->requireUtf8TextMessage($message); + } + continue; + } + } + } + + public function connected(): bool + { + return is_resource($this->stream) && !feof($this->stream); + } + + private function handshake(string $path, string $host, int $port, bool $secure): void + { + $key = base64_encode(random_bytes(16)); + $defaultPort = $secure ? 443 : 80; + $hostHeader = $port === $defaultPort ? $host : $host . ':' . $port; + $request = implode("\r\n", [ + 'GET ' . $path . ' HTTP/1.1', + 'Host: ' . $hostHeader, + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: ' . $key, + 'Sec-WebSocket-Version: 13', + 'Origin: ' . $this->origin, + '', + '', + ]); + + $this->writeAll($request); + $this->validateHandshakeResponse($this->readHttpHeaders($this->connectTimeout), $key); + } + + private function validateHandshakeResponse(string $headers, string $key): void + { + $lines = preg_split('/\r\n/', trim($headers)); + if ($lines === false || $lines === [] || !preg_match('/^HTTP\/1\.[01]\s+101(?:\s|$)/', $lines[0])) { + throw new ProtocolException('Invalid WebSocket handshake response'); + } + + $parsed = []; + foreach (array_slice($lines, 1) as $line) { + $pos = strpos($line, ':'); + if ($pos === false) { + continue; + } + $name = strtolower(trim(substr($line, 0, $pos))); + $value = trim(substr($line, $pos + 1)); + $parsed[$name] = isset($parsed[$name]) ? $parsed[$name] . ', ' . $value : $value; + } + + if (strtolower($parsed['upgrade'] ?? '') !== 'websocket') { + throw new ProtocolException('Invalid WebSocket upgrade header'); + } + + if (!$this->headerContainsToken($parsed['connection'] ?? '', 'upgrade')) { + throw new ProtocolException('Invalid WebSocket connection header'); + } + + $expected = base64_encode(sha1($key . self::GUID, true)); + $actual = $parsed['sec-websocket-accept'] ?? ''; + if (!hash_equals($expected, $actual)) { + throw new ProtocolException('Invalid WebSocket accept key'); + } + } + + private function headerContainsToken(string $header, string $token): bool + { + foreach (explode(',', $header) as $part) { + if (strcasecmp(trim($part), $token) === 0) { + return true; + } + } + + return false; + } + + private function sendFrame(string $payload, int $opcode): void + { + $length = strlen($payload); + if (!$this->isKnownOpcode($opcode) || $opcode === 0x0) { + throw new ProtocolException('Unsupported WebSocket opcode: ' . $opcode); + } + if ($this->isControlOpcode($opcode) && $length > 125) { + throw new ProtocolException('WebSocket control frame payload is too large'); + } + + $header = chr(0x80 | ($opcode & 0x0F)); + if ($length <= 125) { + $header .= chr(0x80 | $length); + } elseif ($length <= 0xFFFF) { + $header .= chr(0x80 | 126) . pack('n', $length); + } else { + $header .= chr(0x80 | 127) . $this->packUInt64($length); + } + + $mask = random_bytes(4); + $this->writeAll($header . $mask . $this->applyMask($payload, $mask)); + } + + /** + * @return array{fin: bool, opcode: int, payload: string} + */ + private function readFrame(float $timeout): array + { + $head = $this->readExact(2, $timeout); + $first = ord($head[0]); + $second = ord($head[1]); + $fin = ($first & 0x80) !== 0; + if (($first & 0x70) !== 0) { + throw new ProtocolException('WebSocket reserved bits are not supported'); + } + + $opcode = $first & 0x0F; + if (!$this->isKnownOpcode($opcode)) { + throw new ProtocolException('Unsupported WebSocket opcode: ' . $opcode); + } + + $masked = ($second & 0x80) !== 0; + if ($masked) { + throw new ProtocolException('Masked WebSocket frames from server are not allowed'); + } + + $lengthCode = $second & 0x7F; + $length = $lengthCode; + + if ($lengthCode === 126) { + $parts = unpack('nlen', $this->readExact(2, $timeout)); + $length = (int) $parts['len']; + if ($length < 126) { + throw new ProtocolException('WebSocket frame uses non-minimal payload length encoding'); + } + } elseif ($lengthCode === 127) { + $parts = unpack('Nhigh/Nlow', $this->readExact(8, $timeout)); + $high = (int) $parts['high']; + $low = (int) $parts['low']; + if ($high > 0x7FFFFFFF) { + throw new ProtocolException('WebSocket frame too large for PHP integer range'); + } + $length = $high * 4294967296 + $low; + if ($length <= 0xFFFF) { + throw new ProtocolException('WebSocket frame uses non-minimal payload length encoding'); + } + } + + if ($this->isControlOpcode($opcode)) { + if (!$fin) { + throw new ProtocolException('WebSocket control frames must not be fragmented'); + } + if ($length > 125) { + throw new ProtocolException('WebSocket control frame payload is too large'); + } + } + + if ($length > self::MAX_FRAME_PAYLOAD_BYTES) { + throw new ProtocolException('WebSocket frame payload is too large'); + } + + $payload = $length > 0 ? $this->readExact($length, $timeout) : ''; + + return ['fin' => $fin, 'opcode' => $opcode, 'payload' => $payload]; + } + + private function isKnownOpcode(int $opcode): bool + { + return $opcode === 0x0 + || $opcode === 0x1 + || $opcode === 0x2 + || $opcode === 0x8 + || $opcode === 0x9 + || $opcode === 0xA; + } + + private function isControlOpcode(int $opcode): bool + { + return $opcode >= 0x8; + } + + private function requireUtf8TextMessage(string $message): string + { + if ($message === '' || preg_match('//u', $message) === 1) { + return $message; + } + + throw new ProtocolException('WebSocket text message is not valid UTF-8'); + } + + private function readHttpHeaders(float $timeout): string + { + $headers = ''; + while (strpos($headers, "\r\n\r\n") === false) { + $headers .= $this->readExact(1, $timeout); + if (strlen($headers) > 16384) { + throw new ProtocolException('WebSocket handshake headers are too large'); + } + } + + return $headers; + } + + private function readExact(int $length, float $timeout): string + { + if ($length < 0) { + throw new ProtocolException('WebSocket read length must not be negative'); + } + if ($length === 0) { + return ''; + } + + $stream = $this->requireStream(); + $timeout = $this->normalizeTimeout($timeout); + $seconds = (int) floor($timeout); + $microseconds = (int) max(0, ($timeout - $seconds) * 1000000); + stream_set_timeout($stream, $seconds, $microseconds); + + $data = ''; + while (strlen($data) < $length) { + $chunk = fread($stream, $length - strlen($data)); + if ($chunk === false) { + throw new ProtocolException('Failed to read from WebSocket transport'); + } + if ($chunk === '') { + $meta = stream_get_meta_data($stream); + if (!empty($meta['timed_out'])) { + throw new ProtocolException('Timed out reading from WebSocket transport'); + } + if (feof($stream)) { + throw new ProtocolException('WebSocket transport closed by peer'); + } + continue; + } + $data .= $chunk; + } + + return $data; + } + + private function writeAll(string $data): void + { + $stream = $this->requireStream(); + $written = 0; + $length = strlen($data); + while ($written < $length) { + $chunk = fwrite($stream, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + throw new ProtocolException('Failed to write to WebSocket transport'); + } + $written += $chunk; + } + } + + private function applyMask(string $payload, string $mask): string + { + $out = ''; + $length = strlen($payload); + for ($i = 0; $i < $length; $i++) { + $out .= $payload[$i] ^ $mask[$i % 4]; + } + + return $out; + } + + private function packUInt64(int $value): string + { + $high = intdiv($value, 4294967296); + $low = $value % 4294967296; + + return pack('N2', $high, $low); + } + + /** + * @return resource + */ + private function requireStream() + { + if (!$this->connected()) { + throw new ProtocolException('WebSocket transport is not connected'); + } + + return $this->stream; + } + + private function assertEndpoint(string $host, int $port): void + { + if (trim($host) === '' || $port <= 0 || $port > 65535) { + throw new ProtocolException('Invalid WebSocket transport host or port'); + } + } + + private function normalizeTimeout(float $timeout): float + { + return max(0.001, $timeout); + } +} diff --git a/src/PHPMax/WebClient.php b/src/PHPMax/WebClient.php new file mode 100644 index 0000000..4c4e5e3 --- /dev/null +++ b/src/PHPMax/WebClient.php @@ -0,0 +1,43 @@ +userAgent->deviceType !== DeviceType::WEB) { + $options->userAgent = MobileUserAgentPayload::defaultWeb(); + } + if ($options->authFlow === null) { + $options->authFlow = new QrAuthFlow($qrHandler ?: new ConsoleQrHandler()); + } + + $connection = $connection ?: new ConnectionManager( + new WebSocketTransport($options->wsUrl, $options->connectTimeout, 'https://web.max.ru', $options->proxy), + new WsProtocol(), + new WebSocketFrameReader() + ); + + parent::__construct($options, $connection, $router); + } +} diff --git a/tests-php/Account/AccountServiceTest.php b/tests-php/Account/AccountServiceTest.php new file mode 100644 index 0000000..b3d89c3 --- /dev/null +++ b/tests-php/Account/AccountServiceTest.php @@ -0,0 +1,266 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake account chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class AccountTestStore implements SessionStoreInterface +{ + /** @var list */ + public $updated = []; + + public function saveSession(SessionInfo $sessionInfo): void + { + } + + public function updateToken(string $oldToken, string $newToken): void + { + $this->updated[] = [$oldToken, $newToken]; + } + + public function loadSession(): ?SessionInfo + { + return null; + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + return null; + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + return null; + } + + public function deleteSession(string $token): void + { + } + + public function deleteAllSessions(): void + { + } + + public function close(): void + { + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $folderUpdate = static function (string $id, string $title, int $sync): array { + return [ + 'foldersOrder' => [$id], + 'folder' => [ + 'sourceId' => 1, + 'include' => [10, 20], + 'options' => [], + 'updateTime' => 100, + 'id' => $id, + 'filters' => [], + 'title' => $title, + ], + 'folderSync' => $sync, + ]; + }; + $frameChunks = static function (array $payload, int $opcode, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, Command::RESPONSE)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (AccountServiceTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $chunks = array_merge( + $frameChunks(['url' => 'https://upload.test/profile'], Opcode::PHOTO_UPLOAD, 0), + $frameChunks(['profile' => [ + 'contact' => ['id' => 77, 'names' => [['name' => 'Me', 'type' => 'NICK']]], + 'profileOptions' => ['showPhone' => false], + ]], Opcode::PROFILE, 1), + $frameChunks($folderUpdate('created-folder', 'Created', 101), Opcode::FOLDERS_UPDATE, 2), + $frameChunks([ + 'foldersOrder' => ['created-folder'], + 'folders' => [$folderUpdate('created-folder', 'Created', 101)['folder']], + 'allFilterExcludeFolders' => [], + 'folderSync' => 101, + ], Opcode::FOLDERS_GET, 3), + $frameChunks($folderUpdate('folder-1', 'Updated', 102), Opcode::FOLDERS_UPDATE, 4), + $frameChunks($folderUpdate('folder-1', 'Deleted', 103), Opcode::FOLDERS_DELETE, 5), + $frameChunks(['token' => 'new-token'], Opcode::SESSIONS_CLOSE, 6), + $frameChunks([], Opcode::LOGOUT, 7) + ); + + $transport = new AccountServiceTestTransport($chunks); + $store = new AccountTestStore(); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0, $store); + $service = $app->api()->account; + + $assertSame('https://upload.test/profile', $service->requestProfilePhotoUploadUrl()); + $assertSame(['count' => 1, 'profile' => true], $decodeSent($transport, 0)->payload); + + $assertSame(true, $service->changeProfile('First', 'Last', 'About', 'photo-token')); + $assertSame([ + 'firstName' => 'First', + 'lastName' => 'Last', + 'description' => 'About', + 'photoToken' => 'photo-token', + 'avatarType' => AvatarType::USER_AVATAR, + ], $decodeSent($transport, 1)->payload); + $assertSame(77, $service->profile()->contact->id); + $assertSame(77, $app->me()->contact->id, 'changeProfile must update App profile state'); + $assertSame(77, $app->api()->users->getCachedUser(77)->id, 'changeProfile must update user cache'); + $assertSame(77, $app->cachedUser(77)->id, 'changeProfile must update shared App user cache'); + + $created = $service->createFolder('Created', [10, 20], [['type' => 'unread']]); + $createPayload = $decodeSent($transport, 2)->payload; + $assert(strlen($createPayload['id']) >= 32, 'createFolder must generate a folder id'); + $assertSame('Created', $createPayload['title']); + $assertSame([10, 20], $createPayload['include']); + $assertSame([['type' => 'unread']], $createPayload['filters']); + $assertSame(101, $created->folderSync); + $assertSame('created-folder', $created->folder->id); + + $folders = $service->getFolders(101); + $assertSame(['folderSync' => 101], $decodeSent($transport, 3)->payload); + $assertSame('created-folder', $folders->folders[0]->id); + $assertSame(101, $folders->folderSync); + + $updated = $service->updateFolder('folder-1', 'Updated', [30], [], [['pinned' => true]]); + $assertSame([ + 'id' => 'folder-1', + 'title' => 'Updated', + 'include' => [30], + 'filters' => [], + 'options' => [['pinned' => true]], + ], $decodeSent($transport, 4)->payload); + $assertSame(102, $updated->folderSync); + + $deleted = $service->deleteFolder('folder-1'); + $assertSame(['folderIds' => ['folder-1']], $decodeSent($transport, 5)->payload); + $assertSame(103, $deleted->folderSync); + + $assertSame(false, $service->closeAllSessions(), 'No current session means no close request'); + $app->setSession(new SessionInfo([ + 'token' => 'old-token', + 'deviceId' => 'device', + 'phone' => '+79990000000', + 'mtInstanceId' => 'mt', + 'sync' => new SyncState([ + 'chatsSync' => 11, + 'contactsSync' => 22, + 'draftsSync' => 33, + 'presenceSync' => 44, + 'configHash' => 'hash-before-rotation', + ]), + ])); + $assertSame(true, $service->closeAllSessions()); + $assertSame([], $decodeSent($transport, 6)->payload); + $assertSame([['old-token', 'new-token']], $store->updated); + $rotatedSession = $app->session(); + $assert($rotatedSession instanceof SessionInfo, 'closeAllSessions must keep a current session after token rotation'); + $assertSame('new-token', $rotatedSession->token); + $assertSame('device', $rotatedSession->deviceId); + $assertSame('+79990000000', $rotatedSession->phone); + $assertSame('mt', $rotatedSession->mtInstanceId); + $assert($rotatedSession->sync instanceof SyncState, 'closeAllSessions must preserve sync state'); + $assertSame(11, $rotatedSession->sync->chatsSync); + $assertSame(22, $rotatedSession->sync->contactsSync); + $assertSame(33, $rotatedSession->sync->draftsSync); + $assertSame(44, $rotatedSession->sync->presenceSync); + $assertSame('hash-before-rotation', $rotatedSession->sync->configHash); + + $assertSame(true, $service->logout()); + $assertSame([], $decodeSent($transport, 7)->payload); + + $makeService = static function (array $chunks) use ($protocol): array { + $transport = new AccountServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + + return [$transport, $app->api()->account]; + }; + + [, $emptyCreateService] = $makeService($frameChunks([], Opcode::FOLDERS_UPDATE, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyCreateService): void { + $emptyCreateService->createFolder('Broken', [1]); + }, 'createFolder must require a folder update payload like PyMax require_payload_model'); + + [, $emptyListService] = $makeService($frameChunks([], Opcode::FOLDERS_GET, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyListService): void { + $emptyListService->getFolders(); + }, 'getFolders must require a folder list payload like PyMax require_payload_model'); + + [, $emptyUpdateService] = $makeService($frameChunks([], Opcode::FOLDERS_UPDATE, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyUpdateService): void { + $emptyUpdateService->updateFolder('folder-1', 'Broken'); + }, 'updateFolder must require a folder update payload like PyMax require_payload_model'); + + [, $emptyDeleteService] = $makeService($frameChunks([], Opcode::FOLDERS_DELETE, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyDeleteService): void { + $emptyDeleteService->deleteFolder('folder-1'); + }, 'deleteFolder must require a folder update payload like PyMax require_payload_model'); +}; diff --git a/tests-php/Auth/AuthServiceTest.php b/tests-php/Auth/AuthServiceTest.php new file mode 100644 index 0000000..9a9777a --- /dev/null +++ b/tests-php/Auth/AuthServiceTest.php @@ -0,0 +1,535 @@ + */ + private $chunks; + /** @var list */ + public $sent = []; + /** @var bool */ + private $connected = false; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake auth chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class InMemoryAuthStore implements SessionStoreInterface +{ + /** @var list */ + public $saved = []; + /** @var list */ + public $updated = []; + /** @var SessionInfo|null */ + public $session; + + public function __construct(?SessionInfo $session = null) + { + $this->session = $session; + } + + public function saveSession(SessionInfo $sessionInfo): void + { + $this->session = $sessionInfo; + $this->saved[] = $sessionInfo; + } + + public function updateToken(string $oldToken, string $newToken): void + { + $this->updated[] = [$oldToken, $newToken]; + if ($this->session !== null && $this->session->token === $oldToken) { + $this->session = new SessionInfo([ + 'token' => $newToken, + 'deviceId' => $this->session->deviceId, + 'phone' => $this->session->phone, + 'mtInstanceId' => $this->session->mtInstanceId, + 'sync' => $this->session->sync, + ]); + } + } + + public function loadSession(): ?SessionInfo + { + return $this->session; + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + return $this->session !== null && $this->session->deviceId === $deviceId ? $this->session : null; + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + return $this->session !== null && $this->session->phone === $phone ? $this->session : null; + } + + public function deleteSession(string $token): void + { + if ($this->session !== null && $this->session->token === $token) { + $this->session = null; + } + } + + public function deleteAllSessions(): void + { + $this->session = null; + } + + public function close(): void + { + } +} + +final class StaticAuthCodeProvider implements SmsCodeProviderInterface +{ + /** @var list */ + public $phones = []; + + public function getCode(string $phone): string + { + $this->phones[] = $phone; + return '111111'; + } +} + +final class StaticAuthPasswordProvider implements PasswordProviderInterface +{ + /** @var list */ + public $hints = []; + + public function getPassword(?string $hint = null): string + { + $this->hints[] = $hint; + return 'secret'; + } +} + +final class StaticAuthQrHandler implements QrHandlerInterface +{ + /** @var list */ + public $urls = []; + + public function showQr(string $qrUrl): void + { + $this->urls[] = $qrUrl; + } +} + +final class StaticAuthEmailCodeProvider implements EmailCodeProviderInterface +{ + /** @var list */ + public $emails = []; + + public function getCode(string $email): string + { + $this->emails[] = $email; + return '222222'; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $frameChunks = static function (array $payload, int $opcode, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, Command::RESPONSE)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (AuthTestTransport $transport, int $index = 0) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $transport = new AuthTestTransport(array_merge( + $frameChunks([ + 'token' => 'sms-token', + 'codeLength' => 6, + 'requestMaxDuration' => 60, + 'requestCountLeft' => 2, + 'altActionDuration' => 5, + ], Opcode::AUTH_REQUEST, 0), + $frameChunks(['tokenAttrs' => ['LOGIN' => ['token' => 'login-token']]], Opcode::AUTH, 1) + )); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, new ClientOptions(['store' => new InMemoryAuthStore(), 'requestTimeout' => 1.0])); + $auth = $app->api()->auth; + + $start = $auth->requestCode('+79990000000'); + $result = $auth->sendCode($start->token, '111111'); + $assertSame('sms-token', $start->token); + $assertSame('login-token', $result->loginToken()); + $assertSame(Opcode::AUTH_REQUEST, $decodeSent($transport, 0)->opcode); + $assertSame('+79990000000', $decodeSent($transport, 0)->payload['phone']); + $assertSame('START_AUTH', $decodeSent($transport, 0)->payload['type']); + $assertSame('111111', $decodeSent($transport, 1)->payload['verifyCode']); + $assertSame('CHECK_CODE', $decodeSent($transport, 1)->payload['authTokenType']); + + $emptyAuthTransport = new AuthTestTransport($frameChunks([], Opcode::AUTH_REQUEST, 0)); + $emptyAuthManager = new ConnectionManager($emptyAuthTransport, $protocol); + $emptyAuthManager->open(); + $emptyAuthApp = new App($emptyAuthManager, new ClientOptions(['store' => new InMemoryAuthStore(), 'requestTimeout' => 1.0])); + $assertThrows(PHPMaxException::class, static function () use ($emptyAuthApp): void { + $emptyAuthApp->api()->auth->requestCode('+79990000000'); + }, 'Auth response models must reject empty payloads like PyMax require_payload_model'); + + $loginStore = new InMemoryAuthStore(new SessionInfo([ + 'token' => 'local-token', + 'deviceId' => 'device-test', + 'phone' => '+79990000000', + 'sync' => new SyncState(['chatsSync' => 1, 'contactsSync' => 2, 'draftsSync' => 3, 'presenceSync' => 4]), + ])); + $loginTransport = new AuthTestTransport($frameChunks([ + 'profile' => ['contact' => ['id' => 42, 'names' => [['name' => 'Max', 'type' => 'NICK']]]], + 'chats' => [], + 'messages' => [], + 'contacts' => [], + 'token' => 'server-token', + 'time' => 777, + 'config' => ['hash' => 'cfg-hash'], + ], Opcode::LOGIN, 0)); + $loginManager = new ConnectionManager($loginTransport, $protocol); + $loginManager->open(); + $loginOptions = new ClientOptions([ + 'store' => $loginStore, + 'mtInstanceId' => 'mt-test', + 'deviceId' => 'device-test', + 'requestTimeout' => 1.0, + ]); + $loginApp = new App($loginManager, $loginOptions); + $loginApp->setSession($loginStore->loadSession()); + $login = $loginApp->api()->auth->mobileLogin(); + $sentLogin = $decodeSent($loginTransport, 0); + $assertSame(Opcode::LOGIN, $sentLogin->opcode); + $assertSame('local-token', $sentLogin->payload['token']); + $assertSame(DeviceType::ANDROID, $sentLogin->payload['userAgent']['deviceType']); + $assertSame('server-token', $login->token); + $assertSame(777, $loginApp->session()->sync->chatsSync); + $assertSame(777, $loginApp->session()->sync->contactsSync); + $assertSame(777, $loginApp->session()->sync->draftsSync); + $assertSame(777, $loginApp->session()->sync->presenceSync); + $assertSame('cfg-hash', $loginApp->session()->sync->configHash); + $assertSame('mt-test', $loginApp->session()->mtInstanceId); + $savedLoginSession = $loginStore->saved[count($loginStore->saved) - 1]; + $assertSame($loginApp->session(), $savedLoginSession); + $assertSame('local-token', $savedLoginSession->token); + $assertSame('device-test', $savedLoginSession->deviceId); + $assertSame('+79990000000', $savedLoginSession->phone); + $assertSame('mt-test', $savedLoginSession->mtInstanceId); + $assertSame(777, $savedLoginSession->sync->contactsSync); + $assertSame(777, $savedLoginSession->sync->draftsSync); + $assertSame(777, $savedLoginSession->sync->presenceSync); + + $emptyLoginStore = new InMemoryAuthStore(new SessionInfo([ + 'token' => 'local-token-empty', + 'deviceId' => 'device-empty', + 'phone' => '+79990000000', + ])); + $emptyLoginTransport = new AuthTestTransport($frameChunks([], Opcode::LOGIN, 0)); + $emptyLoginManager = new ConnectionManager($emptyLoginTransport, $protocol); + $emptyLoginManager->open(); + $emptyLoginApp = new App($emptyLoginManager, new ClientOptions([ + 'store' => $emptyLoginStore, + 'deviceId' => 'device-empty', + 'requestTimeout' => 1.0, + ])); + $emptyLoginApp->setSession($emptyLoginStore->loadSession()); + $assertThrows(PHPMaxException::class, static function () use ($emptyLoginApp): void { + $emptyLoginApp->api()->auth->mobileLogin(); + }, 'Empty LOGIN payload must fail before LoginResponse hydration'); + $assertSame([], $emptyLoginStore->saved, 'Failed LOGIN response must not update session store'); + + $webUa = new MobileUserAgentPayload([ + 'deviceType' => DeviceType::WEB, + 'appVersion' => '26.5.5', + 'osVersion' => 'Linux', + 'timezone' => 'Europe/Moscow', + 'screen' => '1080x1920 1.0x', + 'locale' => 'ru', + 'deviceName' => 'Chrome', + 'deviceLocale' => 'ru', + ]); + $webStore = new InMemoryAuthStore(new SessionInfo(['token' => 'web-token', 'deviceId' => 'web-device', 'phone' => ''])); + $webTransport = new AuthTestTransport($frameChunks([ + 'profile' => ['contact' => ['id' => 44, 'names' => []]], + 'token' => 'web-server-token', + ], Opcode::LOGIN, 0)); + $webManager = new ConnectionManager($webTransport, $protocol); + $webManager->open(); + $webApp = new App($webManager, new ClientOptions(['store' => $webStore, 'userAgent' => $webUa, 'requestTimeout' => 1.0])); + $webApp->setSession($webStore->loadSession()); + $webApp->api()->auth->login($webUa); + $webPayload = $decodeSent($webTransport, 0)->payload; + $assert(!isset($webPayload['userAgent']), 'Web login must not send mobile userAgent payload'); + $assertSame(40, $webPayload['chatsCount']); + + $flowTransport = new AuthTestTransport(array_merge( + $frameChunks([ + 'token' => 'sms-token', + 'codeLength' => 6, + 'requestMaxDuration' => 60, + 'requestCountLeft' => 1, + 'altActionDuration' => 0, + ], Opcode::AUTH_REQUEST, 0), + $frameChunks(['passwordChallenge' => ['trackId' => 'track-1', 'hint' => 'hint']], Opcode::AUTH, 1), + $frameChunks(['tokenAttrs' => ['LOGIN' => ['token' => '2fa-token']]], Opcode::AUTH_LOGIN_CHECK_PASSWORD, 2) + )); + $flowManager = new ConnectionManager($flowTransport, $protocol); + $flowManager->open(); + $flowApp = new App($flowManager, new ClientOptions(['store' => new InMemoryAuthStore(), 'requestTimeout' => 1.0])); + $codeProvider = new StaticAuthCodeProvider(); + $passwordProvider = new StaticAuthPasswordProvider(); + $flow = new SmsAuthFlow($codeProvider, $passwordProvider); + $authResult = $flow->authenticate($flowApp->api()->auth, new ClientOptions(['phone' => '+79990000000'])); + $assertSame('2fa-token', $authResult->token); + $assertSame(['+79990000000'], $codeProvider->phones); + $assertSame(['hint'], $passwordProvider->hints); + + $passwordResponse = \PHPMax\Domain\Auth\CheckPasswordResponse::fromArray([ + 'tokenAttrs' => ['LOGIN' => ['token' => '2fa-token-with-error-false']], + 'error' => false, + ]); + $assertSame('2fa-token-with-error-false', $passwordResponse->loginToken()); + $assertSame(false, $passwordResponse->error); + $userWithFalseStrings = \PHPMax\Domain\User::fromArray([ + 'id' => 42, + 'country' => false, + 'baseRawUrl' => false, + 'baseUrl' => false, + 'status' => false, + 'description' => false, + 'link' => false, + ]); + $assertSame('', $userWithFalseStrings->country); + $assertSame('', $userWithFalseStrings->baseUrl); + $assertSame('', $userWithFalseStrings->status); + + $nowMs = (int) floor(microtime(true) * 1000); + $qrTransport = new AuthTestTransport(array_merge( + $frameChunks([ + 'expiresAt' => $nowMs + 60000, + 'pollingInterval' => 250, + 'qrLink' => 'max://qr-login/track-1', + 'trackId' => 'track-1', + 'ttl' => 60000, + ], Opcode::GET_QR, 0), + $frameChunks([ + 'status' => [ + 'expiresAt' => $nowMs + 60000, + 'loginAvailable' => true, + ], + ], Opcode::GET_QR_STATUS, 1), + $frameChunks(['tokenAttrs' => ['LOGIN' => ['token' => 'qr-login-token']]], Opcode::LOGIN_BY_QR, 2), + $frameChunks([], Opcode::AUTH_QR_APPROVE, 3) + )); + $qrManager = new ConnectionManager($qrTransport, $protocol); + $qrManager->open(); + $qrApp = new App($qrManager, new ClientOptions(['requestTimeout' => 1.0])); + + $qrInfo = $qrApp->api()->auth->requestQr(); + $qrStatus = $qrApp->api()->auth->checkQr($qrInfo->trackId); + $qrResult = $qrApp->api()->auth->confirmQr($qrInfo->trackId); + $qrApproved = $qrApp->api()->auth->authorizeQrLogin($qrInfo->qrLink); + $assertSame('max://qr-login/track-1', $qrInfo->qrLink); + $assertSame('track-1', $qrInfo->trackId); + $assertSame(true, $qrStatus->status->loginAvailable); + $assertSame('qr-login-token', $qrResult->loginToken()); + $assertSame(true, $qrApproved); + $assertSame(Opcode::GET_QR, $decodeSent($qrTransport, 0)->opcode); + $assertSame([], $decodeSent($qrTransport, 0)->payload); + $assertSame(Opcode::GET_QR_STATUS, $decodeSent($qrTransport, 1)->opcode); + $assertSame(['trackId' => 'track-1'], $decodeSent($qrTransport, 1)->payload); + $assertSame(Opcode::LOGIN_BY_QR, $decodeSent($qrTransport, 2)->opcode); + $assertSame(['trackId' => 'track-1'], $decodeSent($qrTransport, 2)->payload); + $assertSame(Opcode::AUTH_QR_APPROVE, $decodeSent($qrTransport, 3)->opcode); + $assertSame(['qrLink' => 'max://qr-login/track-1'], $decodeSent($qrTransport, 3)->payload); + + $qrFlowTransport = new AuthTestTransport(array_merge( + $frameChunks([ + 'expiresAt' => $nowMs + 60000, + 'pollingInterval' => 1, + 'qrLink' => 'max://qr-login/track-2', + 'trackId' => 'track-2', + 'ttl' => 60000, + ], Opcode::GET_QR, 0), + $frameChunks([ + 'status' => [ + 'expiresAt' => $nowMs + 60000, + 'loginAvailable' => true, + ], + ], Opcode::GET_QR_STATUS, 1), + $frameChunks(['tokenAttrs' => ['LOGIN' => ['token' => 'qr-flow-token']]], Opcode::LOGIN_BY_QR, 2) + )); + $qrFlowManager = new ConnectionManager($qrFlowTransport, $protocol); + $qrFlowManager->open(); + $qrFlowApp = new App($qrFlowManager, new ClientOptions(['requestTimeout' => 1.0, 'qrPollTimeout' => 1.0])); + $qrHandler = new StaticAuthQrHandler(); + $qrFlow = new QrAuthFlow($qrHandler, new StaticAuthPasswordProvider()); + $qrFlowResult = $qrFlow->authenticate($qrFlowApp->api()->auth, $qrFlowApp->options()); + $assertSame('qr-flow-token', $qrFlowResult->token); + $assertSame(['max://qr-login/track-2'], $qrHandler->urls); + $assertSame(3, count($qrFlowTransport->sent)); + $assertSame(Opcode::GET_QR, $decodeSent($qrFlowTransport, 0)->opcode); + $assertSame(Opcode::GET_QR_STATUS, $decodeSent($qrFlowTransport, 1)->opcode); + $assertSame(Opcode::LOGIN_BY_QR, $decodeSent($qrFlowTransport, 2)->opcode); + + $twoFactorTransport = new AuthTestTransport(array_merge( + $frameChunks(['trackId' => '2fa-track'], Opcode::AUTH_CREATE_TRACK, 0), + $frameChunks([], Opcode::AUTH_VALIDATE_PASSWORD, 1), + $frameChunks([], Opcode::AUTH_VERIFY_EMAIL, 2), + $frameChunks([], Opcode::AUTH_CHECK_EMAIL, 3), + $frameChunks([], Opcode::AUTH_VALIDATE_HINT, 4), + $frameChunks([], Opcode::AUTH_SET_2FA, 5) + )); + $twoFactorManager = new ConnectionManager($twoFactorTransport, $protocol); + $twoFactorManager->open(); + $twoFactorApp = new App($twoFactorManager, new ClientOptions(['requestTimeout' => 1.0])); + $emailProvider = new StaticAuthEmailCodeProvider(); + $assert($twoFactorApp->api()->auth->setTwoFactor('new-password', 'me@example.test', 'my hint', $emailProvider)); + $assertSame(['me@example.test'], $emailProvider->emails); + $assertSame(Opcode::AUTH_CREATE_TRACK, $decodeSent($twoFactorTransport, 0)->opcode); + $assertSame(['type' => 0], $decodeSent($twoFactorTransport, 0)->payload); + $assertSame(Opcode::AUTH_VALIDATE_PASSWORD, $decodeSent($twoFactorTransport, 1)->opcode); + $assertSame(['trackId' => '2fa-track', 'password' => 'new-password'], $decodeSent($twoFactorTransport, 1)->payload); + $assertSame(Opcode::AUTH_VERIFY_EMAIL, $decodeSent($twoFactorTransport, 2)->opcode); + $assertSame(['trackId' => '2fa-track', 'email' => 'me@example.test'], $decodeSent($twoFactorTransport, 2)->payload); + $assertSame(Opcode::AUTH_CHECK_EMAIL, $decodeSent($twoFactorTransport, 3)->opcode); + $assertSame(['trackId' => '2fa-track', 'verifyCode' => '222222'], $decodeSent($twoFactorTransport, 3)->payload); + $assertSame(Opcode::AUTH_VALIDATE_HINT, $decodeSent($twoFactorTransport, 4)->opcode); + $assertSame(['trackId' => '2fa-track', 'hint' => 'my hint'], $decodeSent($twoFactorTransport, 4)->payload); + $setPayload = $decodeSent($twoFactorTransport, 5)->payload; + $assertSame(Opcode::AUTH_SET_2FA, $decodeSent($twoFactorTransport, 5)->opcode); + $assertSame([0, 3, 4], $setPayload['expectedCapabilities']); + $assertSame('2fa-track', $setPayload['trackId']); + $assertSame('new-password', $setPayload['password']); + $assertSame('my hint', $setPayload['hint']); + + $removeTransport = new AuthTestTransport(array_merge( + $frameChunks(['trackId' => 'remove-track'], Opcode::AUTH_CREATE_TRACK, 0), + $frameChunks([], Opcode::AUTH_CHECK_PASSWORD, 1), + $frameChunks([], Opcode::AUTH_SET_2FA, 2) + )); + $removeManager = new ConnectionManager($removeTransport, $protocol); + $removeManager->open(); + $removeApp = new App($removeManager, new ClientOptions(['requestTimeout' => 1.0])); + $assert($removeApp->api()->auth->removeTwoFactor('old-password')); + $assertSame(Opcode::AUTH_CHECK_PASSWORD, $decodeSent($removeTransport, 1)->opcode); + $assertSame(['trackId' => 'remove-track', 'password' => 'old-password'], $decodeSent($removeTransport, 1)->payload); + $removePayload = $decodeSent($removeTransport, 2)->payload; + $assertSame(['trackId' => 'remove-track', 'remove2fa' => true, 'expectedCapabilities' => [5]], $removePayload); + + $changeTransport = new AuthTestTransport(array_merge( + $frameChunks(['trackId' => 'change-track'], Opcode::AUTH_CREATE_TRACK, 0), + $frameChunks([], Opcode::AUTH_CHECK_PASSWORD, 1), + $frameChunks([], Opcode::AUTH_VALIDATE_PASSWORD, 2), + $frameChunks([], Opcode::AUTH_SET_2FA, 3) + )); + $changeManager = new ConnectionManager($changeTransport, $protocol); + $changeManager->open(); + $changeApp = new App($changeManager, new ClientOptions(['requestTimeout' => 1.0])); + $assert($changeApp->api()->auth->changePassword('old-password', 'new-password')); + $assertSame(['trackId' => 'change-track', 'password' => 'old-password'], $decodeSent($changeTransport, 1)->payload); + $assertSame(['trackId' => 'change-track', 'password' => 'new-password'], $decodeSent($changeTransport, 2)->payload); + $assertSame([ + 'expectedCapabilities' => [1], + 'trackId' => 'change-track', + 'password' => 'new-password', + ], $decodeSent($changeTransport, 3)->payload); + + $assert($changeApp->api()->auth->profileHasTwoFactor([2]), 'Profile option 2 must mean 2FA enabled'); + $assert(!$changeApp->api()->auth->profileHasTwoFactor([1, 3, 4]), 'Profile without option 2 must mean 2FA disabled'); + $assert(!$changeApp->api()->auth->checkTwoFactor(), 'Missing App profile must mean 2FA disabled'); + $changeApp->setProfile(PHPMax\Domain\Profile::fromArray([ + 'contact' => ['id' => 501, 'names' => []], + 'profileOptions' => [2], + ])); + $assert($changeApp->api()->auth->checkTwoFactor(), 'AuthService::checkTwoFactor must read App profile options like PyMax check_2fa'); + + $handshakeRaw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, Opcode::SESSION_INIT, 0, [], Command::RESPONSE)); + $profileRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::LOGIN, + 1, + [ + 'profile' => ['contact' => ['id' => 500, 'names' => []], 'profileOptions' => [2]], + 'chats' => [], + 'messages' => [], + 'contacts' => [], + 'token' => 'client-2fa-token', + 'time' => 1000, + 'config' => ['hash' => 'cfg'], + ], + Command::RESPONSE + )); + $clientTransport = new AuthTestTransport([ + substr($handshakeRaw, 0, 10), + substr($handshakeRaw, 10), + substr($profileRaw, 0, 10), + substr($profileRaw, 10), + ]); + $client = new Client(new ClientOptions([ + 'token' => 'client-token', + 'store' => new InMemoryAuthStore(), + 'requestTimeout' => 1.0, + ]), new ConnectionManager($clientTransport, $protocol)); + $client->open(); + $assert($client->checkTwoFactor(), 'Client shortcut must read current login profile options'); +}; diff --git a/tests-php/Bots/BotsServiceTest.php b/tests-php/Bots/BotsServiceTest.php new file mode 100644 index 0000000..d39e747 --- /dev/null +++ b/tests-php/Bots/BotsServiceTest.php @@ -0,0 +1,112 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake bot chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $frameChunks = static function (array $payload, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::WEB_APP_INIT_DATA, + $seq, + $payload, + Command::RESPONSE + )); + + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (BotsServiceTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $transport = new BotsServiceTestTransport(array_merge( + $frameChunks(['queryId' => 'query-1', 'url' => 'https://bot.test/app?query=query-1'], 0), + $frameChunks(['queryId' => 'query-2', 'url' => 'https://bot.test/app?query=query-2'], 1) + )); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, new ClientOptions(['requestTimeout' => 1.0])); + + $initData = $app->api()->bots->getInitData(100, 200, 'start'); + $assertSame('query-1', $initData->queryId); + $assertSame('https://bot.test/app?query=query-1', $initData->url); + $sent = $decodeSent($transport, 0); + $assertSame(Opcode::WEB_APP_INIT_DATA, $sent->opcode); + $assertSame(['botId' => 100, 'chatId' => 200, 'startParam' => 'start'], $sent->payload); + + $client = new Client(new ClientOptions(['requestTimeout' => 1.0]), $manager); + $clientInitData = $client->getBotInitData(101); + $assertSame('query-2', $clientInitData->queryId); + $clientSent = $decodeSent($transport, 1); + $assertSame(Opcode::WEB_APP_INIT_DATA, $clientSent->opcode); + $assertSame(['botId' => 101], $clientSent->payload); + + $emptyTransport = new BotsServiceTestTransport($frameChunks([], 0)); + $emptyManager = new ConnectionManager($emptyTransport, $protocol); + $emptyManager->open(); + $emptyApp = new App($emptyManager, new ClientOptions(['requestTimeout' => 1.0])); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyApp): void { + $emptyApp->api()->bots->getInitData(102); + }, 'getInitData must require a payload like PyMax require_payload_model'); +}; diff --git a/tests-php/Chats/ChatServiceTest.php b/tests-php/Chats/ChatServiceTest.php new file mode 100644 index 0000000..572c9a2 --- /dev/null +++ b/tests-php/Chats/ChatServiceTest.php @@ -0,0 +1,276 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake chat chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $chat = static function (int $id, string $title = 'chat'): array { + return [ + 'id' => $id, + 'type' => 'CHAT', + 'status' => 'ACTIVE', + 'owner' => 1, + 'title' => $title, + 'participantsCount' => 3, + 'lastEventTime' => 1000 + $id, + ]; + }; + $frameChunks = static function (array $payload, int $opcode, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, Command::RESPONSE)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (ChatServiceTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $chunks = array_merge( + $frameChunks([ + 'id' => 700, + 'chatId' => 10, + 'time' => 111, + 'type' => 'SYSTEM', + 'text' => 'created', + 'chat' => $chat(10, 'created'), + ], Opcode::MSG_SEND, 0), + $frameChunks(['chat' => $chat(10, 'invited')], Opcode::CHAT_MEMBERS_UPDATE, 1), + $frameChunks(['chat' => $chat(10, 'removed')], Opcode::CHAT_MEMBERS_UPDATE, 2), + $frameChunks(['chat' => $chat(10, 'settings')], Opcode::CHAT_UPDATE, 3), + $frameChunks(['chat' => $chat(10, 'profile')], Opcode::CHAT_UPDATE, 4), + $frameChunks(['chat' => $chat(20, 'joined')], Opcode::CHAT_JOIN, 5), + $frameChunks(['chat' => $chat(21, 'resolved')], Opcode::LINK_INFO, 6), + $frameChunks(['chat' => array_merge($chat(10, 'reworked'), ['link' => 'join/new'])], Opcode::CHAT_UPDATE, 7), + $frameChunks(['chats' => [$chat(30, 'loaded')]], Opcode::CHAT_INFO, 8), + $frameChunks(['chats' => [$chat(40, 'page')]], Opcode::CHATS_LIST, 9), + $frameChunks(['members' => [[ + 'contact' => ['id' => 5, 'names' => []], + 'presence' => ['status' => 2, 'seen' => 999], + ]]], Opcode::CHAT_MEMBERS, 10), + $frameChunks(['chat' => $chat(10, 'confirmed')], Opcode::CHAT_MEMBERS_UPDATE, 11), + $frameChunks(['chat' => $chat(10, 'declined')], Opcode::CHAT_MEMBERS_UPDATE, 12), + $frameChunks([], Opcode::CHAT_LEAVE, 13), + $frameChunks([], Opcode::CHAT_DELETE, 14) + ); + + $transport = new ChatServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + $service = $app->api()->chats; + + $created = $service->createGroup('Team', [1, 2], false); + $assertSame(10, $created[0]->id); + $assertSame(700, $created[1]->id); + $assertSame(10, $app->cachedChat(10)->id); + $createPayload = $decodeSent($transport, 0)->payload; + $assertSame(Opcode::MSG_SEND, $decodeSent($transport, 0)->opcode); + $assertSame(false, $createPayload['notify']); + $assertSame('CONTROL', $createPayload['message']['attaches'][0]['_type']); + $assertSame('new', $createPayload['message']['attaches'][0]['event']); + $assertSame('CHAT', $createPayload['message']['attaches'][0]['chatType']); + $assertSame('Team', $createPayload['message']['attaches'][0]['title']); + $assertSame([1, 2], $createPayload['message']['attaches'][0]['userIds']); + + $invited = $service->inviteUsersToGroup(10, [3, 4], false); + $assertSame('invited', $invited->title); + $assertSame('invited', $app->cachedChat(10)->title); + $assertSame([ + 'chatId' => 10, + 'userIds' => [3, 4], + 'showHistory' => false, + 'operation' => ChatMemberOperation::ADD, + ], $decodeSent($transport, 1)->payload); + + $assertSame(true, $service->removeUsersFromGroup(10, [4], 86400)); + $assertSame([ + 'chatId' => 10, + 'userIds' => [4], + 'operation' => ChatMemberOperation::REMOVE, + 'cleanMsgPeriod' => 86400, + ], $decodeSent($transport, 2)->payload); + + $service->changeGroupSettings(10, true, null, false, null, true); + $settingsPayload = $decodeSent($transport, 3)->payload; + $assertSame(10, $settingsPayload['chatId']); + $assertSame([ + ChatOption::ALL_CAN_PIN_MESSAGE => true, + ChatOption::ONLY_ADMIN_CAN_ADD_MEMBER => false, + ChatOption::MEMBERS_CAN_SEE_PRIVATE_LINK => true, + ], $settingsPayload['options']); + + $service->changeGroupProfile(10, 'New name', 'Description'); + $assertSame([ + 'chatId' => 10, + 'theme' => 'New name', + 'description' => 'Description', + ], $decodeSent($transport, 4)->payload); + + $joined = $service->joinGroup('https://max.ru/join/abc'); + $assertSame(20, $joined->id); + $assertSame(['link' => 'join/abc'], $decodeSent($transport, 5)->payload); + $assertThrows(InvalidArgumentException::class, static function () use ($service): void { + $service->joinGroup('bad-link'); + }); + + $resolved = $service->resolveGroupByLink('https://max.ru/join/xyz'); + $assertSame(21, $resolved->id); + $assertSame(['link' => 'join/xyz'], $decodeSent($transport, 6)->payload); + $assertThrows(InvalidArgumentException::class, static function () use ($service): void { + $service->resolveGroupByLink('bad-link'); + }); + + $reworked = $service->reworkInviteLink(10); + $assertSame('join/new', $reworked->link); + $assertSame(['revokePrivateLink' => true, 'chatId' => 10], $decodeSent($transport, 7)->payload); + + $chats = $service->getChats([10, 30]); + $assertSame([10, 30], [$chats[0]->id, $chats[1]->id]); + $assertSame(['chatIds' => [30]], $decodeSent($transport, 8)->payload, 'Cached chat must not be requested again'); + + $page = $service->fetchChats(123); + $assertSame(40, $page[0]->id); + $assertSame(40, $app->cachedChat(40)->id); + $assertSame(['marker' => 123], $decodeSent($transport, 9)->payload); + + $members = $service->getJoinRequests(10, 5); + $assertSame(5, $members[0]->contact->id); + $assertSame(2, $members[0]->presence->status); + $assertSame(['chatId' => 10, 'type' => 'JOIN_REQUEST', 'count' => 5], $decodeSent($transport, 10)->payload); + + $confirmed = $service->confirmJoinRequest(10, 6, true); + $assertSame('confirmed', $confirmed->title); + $assertSame([ + 'chatId' => 10, + 'userIds' => [6], + 'type' => 'JOIN_REQUEST', + 'showHistory' => true, + 'operation' => ChatMemberOperation::ADD, + ], $decodeSent($transport, 11)->payload); + + $declined = $service->declineJoinRequests(10, [7, 8]); + $assertSame('declined', $declined->title); + $assertSame([ + 'chatId' => 10, + 'userIds' => [7, 8], + 'type' => 'JOIN_REQUEST', + 'operation' => ChatMemberOperation::REMOVE, + ], $decodeSent($transport, 12)->payload); + + $service->leaveGroup(10); + $assertSame(['chatId' => 10], $decodeSent($transport, 13)->payload); + $assertSame(null, $app->cachedChat(10)); + + $service->deleteChat(30, 777, false); + $assertSame(['chatId' => 30, 'lastEventTime' => 777, 'forAll' => false], $decodeSent($transport, 14)->payload); + $assertSame(null, $app->cachedChat(30)); + + $makeService = static function (array $chunks) use ($protocol): array { + $transport = new ChatServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + + return [$transport, $app->api()->chats]; + }; + + [$channelTransport, $channelService] = $makeService($frameChunks(['chat' => $chat(50, 'channel')], Opcode::CHAT_JOIN, 0)); + $channel = $channelService->joinChannel('channel/invite'); + $assertSame(50, $channel->id); + $assertSame(['link' => 'channel/invite'], $decodeSent($channelTransport, 0)->payload); + + [$zeroMarkerTransport, $zeroMarkerService] = $makeService($frameChunks(['chats' => [$chat(60, 'zero-marker')]], Opcode::CHATS_LIST, 0)); + $beforeMarker = (int) floor(microtime(true) * 1000); + $zeroMarkerPage = $zeroMarkerService->fetchChats(0); + $afterMarker = (int) floor(microtime(true) * 1000); + $zeroMarkerPayload = $decodeSent($zeroMarkerTransport, 0)->payload; + $assertSame(60, $zeroMarkerPage[0]->id); + $assert($zeroMarkerPayload['marker'] !== 0, 'fetchChats(0) must mirror PyMax marker-or-now behavior'); + $assert( + $zeroMarkerPayload['marker'] >= $beforeMarker && $zeroMarkerPayload['marker'] <= $afterMarker, + 'fetchChats(0) must send the current timestamp marker' + ); + + [, $badChatListService] = $makeService($frameChunks(['chats' => [123]], Opcode::CHATS_LIST, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badChatListService): void { + $badChatListService->fetchChats(123); + }, 'Malformed chat list items must fail fast like PyMax parse_payload_list'); + + $emptyChatChunks = array_merge( + $frameChunks(['chat' => []], Opcode::MSG_SEND, 0), + $frameChunks(['chat' => []], Opcode::CHAT_MEMBERS_UPDATE, 1), + $frameChunks(['chat' => []], Opcode::CHAT_MEMBERS_UPDATE, 2), + $frameChunks(['chat' => []], Opcode::CHAT_MEMBERS_UPDATE, 3) + ); + $emptyChatTransport = new ChatServiceTestTransport($emptyChatChunks); + $emptyChatManager = new ConnectionManager($emptyChatTransport, $protocol); + $emptyChatManager->open(); + $emptyChatApp = new App($emptyChatManager, 1.0); + $emptyChatService = $emptyChatApp->api()->chats; + $emptyChatService->cacheExternalChat(\PHPMax\Domain\Chat::fromArray($chat(80, 'existing'))); + + $assertSame(null, $emptyChatService->createGroup('Empty chat item')); + $assertSame(null, $emptyChatService->inviteUsersToGroup(80, [1])); + $assertSame(true, $emptyChatService->removeUsersFromGroup(80, [1], 0)); + $assertSame(null, $emptyChatService->confirmJoinRequest(80, 1)); + $assertSame('existing', $emptyChatApp->cachedChat(80)->title, 'Empty optional chat item must not overwrite cached chat'); +}; diff --git a/tests-php/Client/ClientLifecycleTest.php b/tests-php/Client/ClientLifecycleTest.php new file mode 100644 index 0000000..96b826d --- /dev/null +++ b/tests-php/Client/ClientLifecycleTest.php @@ -0,0 +1,567 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake chunks left'); + } + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class InMemoryClientStore implements SessionStoreInterface +{ + /** @var SessionInfo|null */ + public $session; + /** @var list */ + public $saved = []; + /** @var list */ + public $updated = []; + /** @var list */ + public $deleted = []; + /** @var int */ + public $closes = 0; + + public function __construct(?SessionInfo $session = null) + { + $this->session = $session; + } + + public function saveSession(SessionInfo $sessionInfo): void + { + $this->session = $sessionInfo; + $this->saved[] = $sessionInfo; + } + + public function updateToken(string $oldToken, string $newToken): void + { + $this->updated[] = [$oldToken, $newToken]; + if ($this->session !== null && $this->session->token === $oldToken) { + $this->session = new SessionInfo([ + 'token' => $newToken, + 'deviceId' => $this->session->deviceId, + 'phone' => $this->session->phone, + 'mtInstanceId' => $this->session->mtInstanceId, + 'sync' => $this->session->sync, + ]); + } + } + + public function loadSession(): ?SessionInfo + { + return $this->session; + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + return $this->session !== null && $this->session->deviceId === $deviceId ? $this->session : null; + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + return $this->session !== null && $this->session->phone === $phone ? $this->session : null; + } + + public function deleteSession(string $token): void + { + $this->deleted[] = $token; + if ($this->session !== null && $this->session->token === $token) { + $this->session = null; + } + } + + public function deleteAllSessions(): void + { + $this->deleted[] = '*'; + $this->session = null; + } + + public function close(): void + { + $this->closes++; + } +} + +final class ReconnectClientTransport implements TransportInterface +{ + /** @var string */ + private $header; + /** @var string */ + private $payload; + /** @var bool */ + private $connected = false; + /** @var int */ + public $connects = 0; + /** @var int */ + public $closes = 0; + /** @var int */ + private $recvCount = 0; + + public function __construct(string $rawFrame) + { + $this->header = substr($rawFrame, 0, 10); + $this->payload = substr($rawFrame, 10); + } + + public function connect(): void + { + $this->connects++; + $this->connected = true; + } + + public function close(): void + { + $this->closes++; + $this->connected = false; + } + + public function send(string $data): void + { + } + + public function recv(int $length, float $timeout): string + { + if ($this->connects === 1) { + throw new ProtocolException('TCP transport closed by peer'); + } + + $this->recvCount++; + if ($this->recvCount === 1) { + return $this->header; + } + if ($this->recvCount === 2) { + $this->connected = false; + return $this->payload; + } + + throw new ProtocolException('Timed out reading from TCP transport'); + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class PingClientTransport implements TransportInterface +{ + /** @var TcpProtocol */ + private $protocol; + /** @var bool */ + private $connected = false; + /** @var list */ + private $chunks = []; + /** @var bool */ + private $closeWhenChunksDrained; + /** @var bool */ + private $closeOnIdleTimeout; + /** @var list */ + public $sent = []; + + public function __construct(TcpProtocol $protocol, bool $closeOnIdleTimeout = false) + { + $this->protocol = $protocol; + $this->closeWhenChunksDrained = false; + $this->closeOnIdleTimeout = $closeOnIdleTimeout; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + $frame = $this->protocol->decode($data); + if ($frame->opcode !== Opcode::PING) { + return; + } + + $responseRaw = $this->protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + $frame->seq !== null ? $frame->seq : 0, + ['ok' => true], + Command::RESPONSE + )); + $this->chunks = [substr($responseRaw, 0, 10), substr($responseRaw, 10)]; + $this->closeWhenChunksDrained = true; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + if ($this->closeOnIdleTimeout) { + $this->connected = false; + } + throw new ProtocolException('Timed out reading from TCP transport'); + } + + if ($this->closeWhenChunksDrained && count($this->chunks) === 0) { + $this->connected = false; + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $assertSame(30.0, (new ClientOptions())->pingInterval); + $assertSame(0.0, (new ClientOptions(['pingInterval' => -1.0]))->pingInterval); + $boundedOptions = new ClientOptions([ + 'requestTimeout' => -1.0, + 'connectTimeout' => 0.0, + 'executionSafetyMargin' => -10.0, + ]); + $assertSame(0.001, $boundedOptions->requestTimeout); + $assertSame(0.001, $boundedOptions->connectTimeout); + $assertSame(0.0, $boundedOptions->executionSafetyMargin); + + $responseRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + 0, + ['ok' => true], + Command::RESPONSE + )); + $transport = new FakeClientTransport([ + substr($responseRaw, 0, 10), + substr($responseRaw, 10), + ]); + $manager = new ConnectionManager($transport, $protocol); + $starts = 0; + $router = new Router(); + $router->onStart(static function (Client $client) use (&$starts): void { + $starts++; + }); + + $client = new Client(new ClientOptions(['requestTimeout' => 1.0]), $manager, $router); + $client->open(); + $assertSame(1, $starts); + $assert($client->connection()->isOpen(), 'Client must open injected connection'); + + $response = $client->invoke(Opcode::PING, ['interactive' => true]); + $assertSame(['ok' => true], $response->payload); + $assertSame(1, count($transport->sent)); + + $client->close(); + $assert(!$client->connection()->isOpen(), 'Client must close injected connection'); + + $closeStore = new InMemoryClientStore(); + $closeTransport = new FakeClientTransport([]); + $closeClient = new Client(new ClientOptions(['store' => $closeStore]), new ConnectionManager($closeTransport, $protocol)); + $closeClient->open(); + $closeClient->close(); + $assertSame(1, $closeStore->closes, 'Client::close must close configured session store'); + $assert(!$closeClient->connection()->isOpen(), 'Client::close must close connection through App lifecycle'); + + $startFailureStore = new InMemoryClientStore(); + $startFailureTransport = new FakeClientTransport([]); + $startFailureRouter = new Router(); + $startFailureRouter->onStart(static function (): void { + throw new RuntimeException('startup boom'); + }); + $startFailureClient = new Client( + new ClientOptions(['store' => $startFailureStore]), + new ConnectionManager($startFailureTransport, $protocol), + $startFailureRouter + ); + $assertThrows(RuntimeException::class, static function () use ($startFailureClient): void { + $startFailureClient->open(); + }, 'Unhandled onStart failure must propagate after cleanup'); + $assert(!$startFailureClient->connection()->isOpen(), 'open() must close connection after unhandled onStart failure'); + $assertSame(1, $startFailureStore->closes, 'open() must close session store after unhandled onStart failure'); + + $calls = []; + $router2 = new Router(); + $router2->onRaw( + static function (InboundFrame $frame, Client $client) use (&$calls): void { + $calls[] = $frame->opcode; + }, + static function (InboundFrame $frame): bool { + return $frame->opcode === Opcode::PING; + } + ); + $client2 = new Client(new ClientOptions(), new ConnectionManager(new FakeClientTransport([]), $protocol), $router2); + $router2->dispatchRaw(new InboundFrame(Opcode::NOTIF_TYPING), $client2); + $router2->dispatchRaw(new InboundFrame(Opcode::PING), $client2); + $assertSame([Opcode::PING], $calls); + + $openCloseTransport = new FakeClientTransport([]); + $openCloseStore = new InMemoryClientStore(); + $client3 = new Client(new ClientOptions(['store' => $openCloseStore]), new ConnectionManager($openCloseTransport, $protocol)); + $result = $client3->withOpenSession(static function (Client $client): string { + return $client->connection()->isOpen() ? 'open' : 'closed'; + }); + $assertSame('open', $result); + $assert(!$client3->connection()->isOpen(), 'withOpenSession must close after callback'); + $assertSame(1, $openCloseStore->closes, 'withOpenSession must close configured session store'); + + $handshakeRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::SESSION_INIT, + 0, + [], + Command::RESPONSE + )); + $loginRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::LOGIN, + 1, + [ + 'profile' => ['contact' => ['id' => 77, 'names' => [['name' => 'Me', 'type' => 'FIRST']]]], + 'chats' => [ + ['id' => 700, 'type' => 'CHAT', 'status' => 'ACTIVE', 'owner' => 77, 'title' => 'Team'], + ], + 'messages' => [ + 700 => [ + ['id' => 900, 'chatId' => 700, 'time' => 100, 'type' => 'USER', 'text' => 'from login'], + ], + ], + 'contacts' => [ + ['id' => 88, 'names' => [['name' => 'Friend', 'type' => 'FIRST']]], + ], + 'token' => 'server-token', + 'time' => 999, + 'config' => ['hash' => 'cfg'], + ], + Command::RESPONSE + )); + $replyRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::MSG_SEND, + 2, + ['id' => 901, 'chatId' => 700, 'time' => 101, 'type' => 'USER', 'text' => 'reply'], + Command::RESPONSE + )); + $tokenTransport = new FakeClientTransport([ + substr($handshakeRaw, 0, 10), + substr($handshakeRaw, 10), + substr($loginRaw, 0, 10), + substr($loginRaw, 10), + substr($replyRaw, 0, 10), + substr($replyRaw, 10), + ]); + $tokenStore = new InMemoryClientStore(); + $tokenClient = new Client(new ClientOptions([ + 'token' => 'local-token', + 'phone' => '+79990000000', + 'deviceId' => 'device-token', + 'mtInstanceId' => 'mt-token', + 'store' => $tokenStore, + 'requestTimeout' => 1.0, + ]), new ConnectionManager($tokenTransport, $protocol)); + $tokenClient->open(); + $assertSame('server-token', $tokenClient->loginResponse()->token); + $assertSame('server-token', $tokenClient->app()->session()->token); + $assertSame([['local-token', 'server-token']], $tokenStore->updated); + $assertSame(2, count($tokenTransport->sent)); + $assertSame(Opcode::SESSION_INIT, $protocol->decode($tokenTransport->sent[0])->opcode); + $assertSame('mt-token', $protocol->decode($tokenTransport->sent[0])->payload['mt_instanceid']); + $assertSame('device-token', $protocol->decode($tokenTransport->sent[0])->payload['deviceId']); + $assertSame(Opcode::LOGIN, $protocol->decode($tokenTransport->sent[1])->opcode); + $assertSame(77, $tokenClient->me()->contact->id); + $assertSame(700, $tokenClient->chats()[0]->id); + $assertSame(88, $tokenClient->contacts()[0]->id); + $assertSame(900, $tokenClient->messages()[700][0]->id); + $assertSame(77, $tokenClient->app()->me()->contact->id); + $assertSame(700, $tokenClient->app()->chats()[0]->id); + $assertSame(88, $tokenClient->app()->contacts()[0]->id); + $assertSame(900, $tokenClient->app()->messages()[700][0]->id); + $assertSame(700, $tokenClient->app()->cachedChat(700)->id); + $assertSame(88, $tokenClient->app()->cachedUser(88)->id); + $assertSame(700, $tokenClient->getChat(700)->id, 'Login chats must seed chat cache'); + $assertSame(88, $tokenClient->getCachedUser(88)->id, 'Login contacts must seed user cache'); + $reply = $tokenClient->messages()[700][0]->reply('reply', null, false); + $assertSame(901, $reply->id, 'Login messages must be bound to message service'); + $assertSame(Opcode::MSG_SEND, $protocol->decode($tokenTransport->sent[2])->opcode); + $assertSame(['type' => 'REPLY', 'messageId' => 900], $protocol->decode($tokenTransport->sent[2])->payload['message']['link']); + $tokenClient->relogin(true, false); + $assertSame(1, $tokenStore->closes, 'relogin must close session store through App lifecycle'); + $assertSame(['server-token'], $tokenStore->deleted); + $assertSame(null, $tokenClient->loginResponse()); + $assertSame(null, $tokenClient->me()); + $assertSame(null, $tokenClient->app()->me()); + $assertSame(null, $tokenClient->app()->chats()); + $assertSame([], $tokenClient->contacts()); + $assertSame([], $tokenClient->messages()); + $assertSame([], $tokenClient->app()->contacts()); + $assertSame([], $tokenClient->app()->messages()); + $assertSame(null, $tokenClient->app()->cachedChat(700)); + $assertSame(null, $tokenClient->app()->cachedUser(88)); + $assertSame(null, $tokenClient->app()->session()); + $assertSame(null, $tokenClient->app()->options()->token); + + $savedLoginRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::LOGIN, + 1, + [ + 'profile' => ['contact' => ['id' => 79, 'names' => []]], + 'chats' => [], + 'messages' => [], + 'contacts' => [], + ], + Command::RESPONSE + )); + $savedSessionStore = new InMemoryClientStore(new SessionInfo([ + 'token' => 'saved-token', + 'deviceId' => 'saved-device', + 'phone' => '+79990000001', + 'mtInstanceId' => 'saved-mt', + 'sync' => new SyncState(['chatsSync' => 10]), + ])); + $savedSessionTransport = new FakeClientTransport([ + substr($handshakeRaw, 0, 10), + substr($handshakeRaw, 10), + substr($savedLoginRaw, 0, 10), + substr($savedLoginRaw, 10), + ]); + $savedSessionClient = new Client(new ClientOptions([ + 'token' => 'config-token-must-not-win', + 'deviceId' => 'config-device', + 'mtInstanceId' => 'config-mt', + 'store' => $savedSessionStore, + 'requestTimeout' => 1.0, + ]), new ConnectionManager($savedSessionTransport, $protocol)); + $savedSessionClient->open(); + $savedHandshake = $protocol->decode($savedSessionTransport->sent[0]); + $assertSame(Opcode::SESSION_INIT, $savedHandshake->opcode); + $assertSame('saved-mt', $savedHandshake->payload['mt_instanceid'], 'Saved session mt_instance_id must be reused for handshake like PyMax'); + $assertSame('saved-device', $savedHandshake->payload['deviceId'], 'Saved session device id must be reused for handshake'); + $assertSame('saved-mt', $savedSessionClient->app()->options()->mtInstanceId); + $assertSame('saved-mt', $savedSessionClient->app()->session()->mtInstanceId); + $assertSame('saved-token', $protocol->decode($savedSessionTransport->sent[1])->payload['token']); + $assertSame([], $savedSessionStore->updated); + + $neverLoggedClient = new Client(new ClientOptions(), new ConnectionManager(new FakeClientTransport([]), $protocol)); + $assertThrows(PHPMaxException::class, static function () use ($neverLoggedClient): void { + $neverLoggedClient->relogin(false, false); + }); + + $eventRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::NOTIF_TYPING, + 22, + ['chatId' => 900, 'userId' => 901], + Command::REQUEST + )); + $reconnectTransport = new ReconnectClientTransport($eventRaw); + $reconnectManager = new ConnectionManager($reconnectTransport, $protocol); + $reconnectRouter = new Router(); + $reconnectStarts = 0; + $reconnectDisconnects = []; + $reconnectEvents = []; + $reconnectRouter->onStart(static function () use (&$reconnectStarts): void { + $reconnectStarts++; + }); + $reconnectRouter->onDisconnect(static function (Throwable $exception, bool $reconnect, float $delay) use (&$reconnectDisconnects): void { + $reconnectDisconnects[] = [$exception->getMessage(), $reconnect, $delay]; + }); + $reconnectRouter->onRaw(static function (InboundFrame $frame) use (&$reconnectEvents): void { + $reconnectEvents[] = $frame->opcode; + }); + $reconnectClient = new Client(new ClientOptions([ + 'requestTimeout' => 1.0, + 'executionSafetyMargin' => 0.0, + 'reconnectDelay' => 0.0, + ]), $reconnectManager, $reconnectRouter); + $reconnectClient->open(); + $reconnectClient->runFor(1); + $assertSame(2, $reconnectTransport->connects, 'runFor must reconnect after non-timeout protocol failure'); + $assertSame(2, $reconnectStarts, 'onStart must be emitted after initial open and reconnect'); + $assertSame([['TCP transport closed by peer', true, 0.0]], $reconnectDisconnects); + $assertSame([Opcode::NOTIF_TYPING], $reconnectEvents); + + $pingTransport = new PingClientTransport($protocol); + $pingClient = new Client(new ClientOptions([ + 'requestTimeout' => 0.01, + 'executionSafetyMargin' => 0.0, + 'pingInterval' => 0.001, + ]), new ConnectionManager($pingTransport, $protocol)); + $pingClient->open(); + $pingClient->runFor(1); + $assertSame(1, count($pingTransport->sent), 'runFor must send heartbeat ping when idle deadline is reached'); + $pingFrame = $protocol->decode($pingTransport->sent[0]); + $assertSame(Opcode::PING, $pingFrame->opcode); + $assertSame(['interactive' => true], $pingFrame->payload); + + $disabledPingTransport = new PingClientTransport($protocol, true); + $disabledPingClient = new Client(new ClientOptions([ + 'requestTimeout' => 0.01, + 'executionSafetyMargin' => 0.0, + 'pingInterval' => 0.0, + ]), new ConnectionManager($disabledPingTransport, $protocol)); + $disabledPingClient->open(); + $disabledPingClient->runFor(1); + $assertSame(0, count($disabledPingTransport->sent), 'pingInterval=0.0 must disable runFor heartbeat'); +}; diff --git a/tests-php/Contracts/ContractManifestTest.php b/tests-php/Contracts/ContractManifestTest.php new file mode 100644 index 0000000..af65288 --- /dev/null +++ b/tests-php/Contracts/ContractManifestTest.php @@ -0,0 +1,129 @@ +getConstants(); + $opcodeConstants = (new ReflectionClass(Opcode::class))->getConstants(); + $eventTypeConstants = (new ReflectionClass(EventType::class))->getConstants(); + ksort($commandConstants); + ksort($opcodeConstants); + ksort($eventTypeConstants); + + $assertSame($commandConstants, $manifest['commands']); + $assertSame($opcodeConstants, $manifest['opcodes']); + $assertSame($eventTypeConstants, $manifest['event_types']); + + $assertSame(Opcode::SESSION_INIT, $manifest['opcodes']['SESSION_INIT']); + $assertSame(Opcode::LOGIN, $manifest['opcodes']['LOGIN']); + $assertSame(Opcode::MSG_SEND, $manifest['opcodes']['MSG_SEND']); + $assertSame(Opcode::NOTIF_ATTACH, $manifest['opcodes']['NOTIF_ATTACH']); + $assertSame(Opcode::GET_QR, $manifest['opcodes']['GET_QR']); + $assertSame(Opcode::LOGIN_BY_QR, $manifest['opcodes']['LOGIN_BY_QR']); + $assertSame(Command::REQUEST, $manifest['commands']['REQUEST']); + $assertSame(EventType::MESSAGE_NEW, $manifest['event_types']['MESSAGE_NEW']); + $assertSame(DeviceType::ANDROID, $manifest['api_enums']['session.DeviceType']['ANDROID']); + $assertSame(ChatPayloadKey::MEMBERS, $manifest['api_enums']['chats.ChatPayloadKey']['MEMBERS']); + $assertSame(MessagePayloadKey::MESSAGES_REACTIONS, $manifest['api_enums']['messages.MessagePayloadKey']['MESSAGES_REACTIONS']); + + $assertSame('resolve_message', $manifest['event_map']['NOTIF_MESSAGE']); + $assertSame('resolve_message', $manifest['event_map']['MSG_EDIT']); + $assertSame('resolve_attach', $manifest['event_map']['NOTIF_ATTACH']); + $assertSame('resolve_reaction_update', $manifest['event_map']['NOTIF_MSG_REACTIONS_CHANGED']); + + $assert(isset($manifest['payload_models']['auth.RequestCodePayload'])); + $requestCode = $manifest['payload_models']['auth.RequestCodePayload']; + $assert(in_array('phone', $requestCode['fields'], true)); + $assert(in_array('type', $requestCode['fields'], true)); + $assertSame('phone', $requestCode['payload_keys']['phone']); + $assertSame('type', $requestCode['payload_keys']['type']); + + $assert(isset($manifest['payload_models']['messages.SendMessagePayload'])); + $sendMessage = $manifest['payload_models']['messages.SendMessagePayload']; + $assert(in_array('chat_id', $sendMessage['fields'], true)); + $assert(in_array('message', $sendMessage['fields'], true)); + $assertSame('chatId', $sendMessage['payload_keys']['chat_id']); + $assertSame('message', $sendMessage['payload_keys']['message']); + + $assert(isset($manifest['payload_models']['messages.ChatHistoryPayload'])); + $assertSame('from', $manifest['payload_models']['messages.ChatHistoryPayload']['payload_keys']['from_']); + $assertSame('itemType', $manifest['payload_models']['messages.ChatHistoryPayload']['payload_keys']['item_type']); + + $assert(isset($manifest['payload_models']['uploads.AttachPhotoPayload'])); + $assertSame('_type', $manifest['payload_models']['uploads.AttachPhotoPayload']['payload_keys']['type']); + $assertSame('photoToken', $manifest['payload_models']['uploads.AttachPhotoPayload']['payload_keys']['photo_token']); + + $assert(isset($manifest['payload_models']['session.MobileHandshakePayload'])); + $assertSame('mt_instanceid', $manifest['payload_models']['session.MobileHandshakePayload']['payload_keys']['mt_instance_id']); + + $assert(isset($manifest['payload_models']['uploads.UploadPayload'])); + $uploadPayload = $manifest['payload_models']['uploads.UploadPayload']; + $assert(in_array('count', $uploadPayload['fields'], true)); + $assert(in_array('profile', $uploadPayload['fields'], true)); + $assertSame('count', $uploadPayload['payload_keys']['count']); + $assertSame('profile', $uploadPayload['payload_keys']['profile']); + + $assert(isset($manifest['service_methods']) && is_array($manifest['service_methods'])); + $assertSame('sendMessage', $manifest['service_methods']['messages']['send_message']); + $assertSame('fetchHistory', $manifest['service_methods']['messages']['fetch_history']); + $assertSame('setTwoFactor', $manifest['service_methods']['auth']['set_2fa']); + $assertSame('checkTwoFactor', $manifest['service_methods']['auth']['check_2fa']); + $assertSame('requestProfilePhotoUploadUrl', $manifest['service_methods']['self']['request_profile_photo_upload_url']); + $assertSame('uploadVideo', $manifest['service_methods']['uploads']['upload_video']); + $assert(isset($manifest['service_method_params']) && is_array($manifest['service_method_params'])); + $assertSame( + ['passwordOld', 'passwordNew'], + $manifest['service_method_params']['auth']['change_password']['php_params'] + ); + $assertSame( + ['chatId', 'forward', 'backward', 'backwardTime', 'forwardTime', 'from', 'itemType', 'getChat', 'getMessages', 'interactive'], + $manifest['service_method_params']['messages']['fetch_history']['php_params'] + ); + + $assert(isset($manifest['client_methods']) && is_array($manifest['client_methods'])); + $assertSame('sendMessage', $manifest['client_methods']['send_message']); + $assertSame('getBotInitData', $manifest['client_methods']['get_bot_init_data']); + $assertSame('checkTwoFactor', $manifest['client_methods']['check_2fa']); + $assertSame('requestProfilePhotoUploadUrl', $manifest['client_methods']['request_profile_photo_upload_url']); + $assert(isset($manifest['client_method_params']) && is_array($manifest['client_method_params'])); + $assertSame( + ['passwordOld', 'passwordNew'], + $manifest['client_method_params']['change_password']['php_params'] + ); + $assertSame('fromTime', $manifest['client_method_params']['fetch_history']['php_params'][5]); + + $assert(isset($manifest['domain_models']) && is_array($manifest['domain_models'])); + $assertSame('firstName', $manifest['domain_models']['Name']['payload_keys']['first_name']); + $assertSame('lastName', $manifest['domain_models']['Name']['payload_keys']['last_name']); + $assertSame('reactionInfo', $manifest['domain_models']['Message']['payload_keys']['reaction_info']); + $assertSame('prevMessageId', $manifest['domain_models']['Message']['payload_keys']['prev_message_id']); + $assertSame('lastEventTime', $manifest['domain_models']['Chat']['payload_keys']['last_event_time']); + $assertSame('codeLength', $manifest['domain_models']['StartAuthResponse']['payload_keys']['code_length']); + $assertSame('LOGIN', $manifest['domain_models']['TokenAttrs']['payload_keys']['login']); + $assertSame('REGISTER', $manifest['domain_models']['TokenAttrs']['payload_keys']['register_token']); + $assertSame('chats_sync', $manifest['domain_models']['SyncState']['payload_keys']['chats_sync']); + $assertSame('config_hash', $manifest['domain_models']['SyncOverrides']['payload_keys']['config_hash']); + $assertSame('_type', $manifest['domain_models']['VideoAttachment']['payload_keys']['type']); + $assertSame('videoType', $manifest['domain_models']['VideoAttachment']['payload_keys']['video_type']); + $assertSame('url', $manifest['domain_models']['FileRequest']['payload_keys']['url']); + $assertSame('from', $manifest['domain_models']['Element']['payload_keys']['from_']); + + $assert(isset($manifest['event_models']) && is_array($manifest['event_models'])); + $assertSame('messageIds', $manifest['event_models']['MessageDeleteEvent']['payload_keys']['message_ids']); + $assertSame('chatId', $manifest['event_models']['MessageDeleteEvent']['payload_keys']['chat_id']); + $assertSame('totalCount', $manifest['event_models']['ReactionUpdateEvent']['payload_keys']['total_count']); + $assertSame('fileId', $manifest['event_models']['FileUploadSignal']['payload_keys']['file_id']); +}; diff --git a/tests-php/Dispatch/ErrorScopeTest.php b/tests-php/Dispatch/ErrorScopeTest.php new file mode 100644 index 0000000..06db760 --- /dev/null +++ b/tests-php/Dispatch/ErrorScopeTest.php @@ -0,0 +1,198 @@ +connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + } + + public function recv(int $length, float $timeout): string + { + throw new ProtocolException('TCP transport closed by peer'); + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $client = new stdClass(); + $assertThrows(InvalidArgumentException::class, static function (): void { + (new Router())->onError(static function (): void { + }, 'invalid'); + }, 'Invalid error scope must fail fast instead of becoming global'); + $assertThrows(InvalidArgumentException::class, static function (): void { + (new Client(new ClientOptions(['requestTimeout' => 1.0])))->onError(static function (): void { + }, 'invalid'); + }, 'Client::onError must preserve Router scope validation'); + + $root = new Router(); + $child = new Router(); + $sibling = new Router(); + $root->includeRouter($child)->includeRouter($sibling); + + $calls = []; + $root->onError(static function (Throwable $exception, ErrorContext $context) use (&$calls, $child, $client, $assert): void { + $calls[] = ['root-global', $exception->getMessage(), $context->eventType, $context->event->id]; + $assert($context->router === $child, 'Failed router must be the child router'); + $assert($context->client === $client, 'ErrorContext must keep client object'); + $assert(is_callable($context->handler), 'ErrorContext must expose failed handler'); + }); + $child->onError(static function (Throwable $exception, ErrorContext $context) use (&$calls): void { + $calls[] = ['child-local', $context->eventType, $context->event->chatId]; + }, ErrorScope::LOCAL); + $sibling->onError(static function () use (&$calls): void { + $calls[] = ['sibling-local-should-not-run']; + }, ErrorScope::LOCAL); + $child->onMessage(static function (Message $message): void { + throw new RuntimeException('message boom ' . $message->id); + }); + $root->onRaw(static function (InboundFrame $frame) use (&$calls): void { + $calls[] = ['raw-after-handled-error', $frame->opcode]; + }); + + $root->dispatchFrame(new InboundFrame(Opcode::NOTIF_MESSAGE, Command::REQUEST, 1, [ + 'chatId' => 777, + 'message' => ['id' => 42, 'time' => 100, 'type' => 'USER', 'text' => 'hi'], + ]), $client); + + $assertSame([ + ['root-global', 'message boom 42', EventType::MESSAGE_NEW, 42], + ['child-local', EventType::MESSAGE_NEW, 777], + ['raw-after-handled-error', Opcode::NOTIF_MESSAGE], + ], $calls); + + $unhandled = new Router(); + $unhandled->onTyping(static function (TypingEvent $event): void { + throw new RuntimeException('unhandled typing'); + }); + $assertThrows(RuntimeException::class, static function () use ($unhandled, $client): void { + $unhandled->dispatchFrame(new InboundFrame(Opcode::NOTIF_TYPING, Command::REQUEST, 2, [ + 'chatId' => 1, + 'userId' => 2, + ]), $client); + }); + + $filterCalls = []; + $filterRouter = new Router(); + $filterRouter->onError(static function (Throwable $exception, ErrorContext $context) use (&$filterCalls): void { + $filterCalls[] = [$exception->getMessage(), $context->eventType, is_callable($context->handler)]; + }, ErrorScope::LOCAL); + $filterRouter->onTyping( + static function (): void { + throw new RuntimeException('handler should not run'); + }, + static function (): bool { + throw new LogicException('filter boom'); + } + ); + $filterRouter->dispatchFrame(new InboundFrame(Opcode::NOTIF_TYPING, Command::REQUEST, 3, [ + 'chatId' => 3, + 'userId' => 4, + ]), $client); + $assertSame([['filter boom', EventType::TYPING, true]], $filterCalls); + + $brokenErrorHandler = new Router(); + $brokenErrorHandler->onError(static function (): void { + throw new RuntimeException('error handler boom'); + }); + $brokenErrorHandler->onTyping(static function (): void { + throw new InvalidArgumentException('original error'); + }); + $assertThrows(InvalidArgumentException::class, static function () use ($brokenErrorHandler, $client): void { + $brokenErrorHandler->dispatchFrame(new InboundFrame(Opcode::NOTIF_TYPING, Command::REQUEST, 4, [ + 'chatId' => 5, + 'userId' => 6, + ]), $client); + }); + + $startCalls = []; + $startRoot = new Router(); + $startChild = new Router(); + $startRoot->includeRouter($startChild); + $startRoot->onError(static function (Throwable $exception, ErrorContext $context) use (&$startCalls): void { + $startCalls[] = ['start-root', $context->eventType, $context->event]; + }); + $startChild->onError(static function (Throwable $exception, ErrorContext $context) use (&$startCalls, $startChild): void { + $startCalls[] = ['start-child-local', $context->router === $startChild]; + }, ErrorScope::LOCAL); + $startChild->onStart(static function (): void { + throw new RuntimeException('start boom'); + }); + $startRoot->emitStart($client); + $assertSame([ + ['start-root', EventType::ON_START, null], + ['start-child-local', true], + ], $startCalls); + + $disconnectCalls = []; + $disconnectRoot = new Router(); + $disconnectChild = new Router(); + $disconnectSibling = new Router(); + $disconnectRoot->includeRouter($disconnectChild)->includeRouter($disconnectSibling); + $disconnectRoot->onDisconnect(static function (Throwable $exception, bool $reconnect, float $delay) use (&$disconnectCalls): void { + $disconnectCalls[] = ['root', $exception->getMessage(), $reconnect, $delay]; + }); + $disconnectChild->onDisconnect(static function () use (&$disconnectCalls): void { + $disconnectCalls[] = ['child-before-error']; + throw new RuntimeException('disconnect handler boom'); + }); + $disconnectSibling->onDisconnect(static function (Throwable $exception, bool $reconnect, float $delay) use (&$disconnectCalls): void { + $disconnectCalls[] = ['sibling', $reconnect, $delay]; + }); + $disconnectRoot->emitDisconnect(new RuntimeException('network down'), true, 1.5); + $assertSame([ + ['root', 'network down', true, 1.5], + ['child-before-error'], + ['sibling', true, 1.5], + ], $disconnectCalls); + + $runtimeDisconnectCalls = []; + $runtimeTransport = new ErrorScopeDisconnectTransport(); + $runtimeClient = new Client( + new ClientOptions(['requestTimeout' => 1.0, 'executionSafetyMargin' => 0.0, 'reconnect' => false]), + new ConnectionManager($runtimeTransport, new TcpProtocol()) + ); + $runtimeClient->onDisconnect(static function (Throwable $exception, bool $reconnect, float $delay) use (&$runtimeDisconnectCalls): void { + $runtimeDisconnectCalls[] = [$exception->getMessage(), $reconnect, $delay]; + }); + $runtimeClient->open(); + $assertThrows(ProtocolException::class, static function () use ($runtimeClient): void { + $runtimeClient->runFor(1); + }); + $assertSame([['TCP transport closed by peer', false, 0.0]], $runtimeDisconnectCalls); + $assert(!$runtimeClient->connection()->isOpen(), 'runFor must close connection after non-timeout protocol failure'); +}; diff --git a/tests-php/Dispatch/EventDispatchTest.php b/tests-php/Dispatch/EventDispatchTest.php new file mode 100644 index 0000000..87506c1 --- /dev/null +++ b/tests-php/Dispatch/EventDispatchTest.php @@ -0,0 +1,309 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake dispatch chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $resolver = new EventResolver(); + $assertSame(null, $resolver->resolve(new InboundFrame(Opcode::NOTIF_TYPING, Command::RESPONSE, null, [ + 'chatId' => 1, + 'userId' => 2, + ])), 'Non-request frames must not resolve to typed events'); + $assertSame(null, $resolver->resolve(new InboundFrame(999999, Command::REQUEST, null, []))); + + $client = new stdClass(); + $router = new Router(); + $seen = []; + $router + ->onMessage(static function (Message $message) use (&$seen): void { + $seen[] = ['message', $message->id, $message->chatId, $message->text]; + }, static function (Message $message): bool { + return $message->text === 'hello'; + }) + ->onMessageEdit(static function (Message $message) use (&$seen): void { + $seen[] = ['edit', $message->id]; + }) + ->onMessageDelete(static function (MessageDeleteEvent $event) use (&$seen): void { + $seen[] = ['delete', $event->chatId, $event->messageIds, $event->ttl]; + }) + ->onChatUpdate(static function (Chat $chat) use (&$seen): void { + $seen[] = ['chat', $chat->id, $chat->title]; + }) + ->onTyping(static function (TypingEvent $event) use (&$seen): void { + $seen[] = ['typing', $event->chatId, $event->userId]; + }) + ->onMessageRead(static function (MessageReadEvent $event) use (&$seen): void { + $seen[] = ['read', $event->chatId, $event->userId, $event->mark, $event->setAsUnread]; + }) + ->onPresence(static function (PresenceEvent $event) use (&$seen): void { + $seen[] = ['presence', $event->userId, $event->presence->status, $event->presence->seen]; + }) + ->onReactionUpdate(static function (ReactionUpdateEvent $event) use (&$seen): void { + $seen[] = ['reaction', $event->messageId, $event->chatId, $event->totalCount, $event->counters[0]->reaction]; + }) + ->onFileReady(static function (FileUploadSignal $event) use (&$seen): void { + $seen[] = ['file', $event->fileId]; + }) + ->onVideoReady(static function (VideoUploadSignal $event) use (&$seen): void { + $seen[] = ['video', $event->videoId]; + }) + ->onRaw(static function (InboundFrame $frame) use (&$seen): void { + $seen[] = ['raw', $frame->opcode]; + }); + + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_MESSAGE, Command::REQUEST, 10, [ + 'chatId' => 100, + 'message' => ['id' => 1, 'time' => 111, 'type' => 'USER', 'text' => 'hello'], + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::MSG_EDIT, Command::REQUEST, 11, [ + 'chatId' => 100, + 'message' => ['id' => 1, 'time' => 112, 'type' => 'USER', 'text' => 'edited', 'status' => 'EDITED'], + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_MESSAGE, Command::REQUEST, 12, [ + 'chatId' => 100, + 'message' => ['id' => 2, 'time' => 113, 'type' => 'USER', 'text' => 'removed', 'status' => 'REMOVED'], + 'ttl' => true, + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_MSG_DELETE, Command::REQUEST, 13, [ + 'chat' => ['id' => 101, 'type' => 'CHAT', 'status' => 'ACTIVE', 'owner' => 7], + 'messageIds' => [3, '4'], + 'ttl' => false, + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_CHAT, Command::REQUEST, 14, [ + 'chat' => ['id' => 101, 'type' => 'CHAT', 'status' => 'ACTIVE', 'owner' => 7, 'title' => 'group'], + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_TYPING, Command::REQUEST, 15, [ + 'chatId' => 101, + 'userId' => 9, + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_MARK, Command::REQUEST, 16, [ + 'setAsUnread' => false, + 'chatId' => 101, + 'userId' => 9, + 'mark' => 555, + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_PRESENCE, Command::REQUEST, 17, [ + 'presence' => ['status' => 1, 'seen' => 444], + 'userId' => 9, + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_MSG_REACTIONS_CHANGED, Command::REQUEST, 18, [ + 'messageId' => '1', + 'chatId' => 101, + 'totalCount' => 2, + 'counters' => [['count' => 2, 'reaction' => '👍']], + ]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_ATTACH, Command::REQUEST, 19, ['fileId' => 77]), $client); + $router->dispatchFrame(new InboundFrame(Opcode::NOTIF_ATTACH, Command::REQUEST, 20, ['videoId' => 88]), $client); + + $assertSame([ + ['message', 1, 100, 'hello'], + ['raw', Opcode::NOTIF_MESSAGE], + ['edit', 1], + ['raw', Opcode::MSG_EDIT], + ['delete', 100, [2], true], + ['raw', Opcode::NOTIF_MESSAGE], + ['delete', 101, [3, 4], false], + ['raw', Opcode::NOTIF_MSG_DELETE], + ['chat', 101, 'group'], + ['raw', Opcode::NOTIF_CHAT], + ['typing', 101, 9], + ['raw', Opcode::NOTIF_TYPING], + ['read', 101, 9, 555, false], + ['raw', Opcode::NOTIF_MARK], + ['presence', 9, 1, 444], + ['raw', Opcode::NOTIF_PRESENCE], + ['reaction', '1', 101, 2, '👍'], + ['raw', Opcode::NOTIF_MSG_REACTIONS_CHANGED], + ['file', 77], + ['raw', Opcode::NOTIF_ATTACH], + ['video', 88], + ['raw', Opcode::NOTIF_ATTACH], + ], $seen); + + $falseyPayloadSeen = []; + $falseyPayloadRouter = new Router(); + $falseyPayloadRouter->onChatUpdate(static function ($event) use (&$falseyPayloadSeen): void { + $falseyPayloadSeen[] = [ + $event instanceof InboundFrame, + $event instanceof InboundFrame ? $event->opcode : null, + ]; + }); + $falseyPayloadRouter->dispatchFrame(new InboundFrame(Opcode::NOTIF_CHAT, Command::REQUEST, 140, []), $client, false); + $assertSame([[true, Opcode::NOTIF_CHAT]], $falseyPayloadSeen, 'Empty known event payload must keep PyMax raw-frame fallback'); + + $strictChatSeen = []; + $strictChatRouter = new Router(); + $strictChatRouter + ->onChatUpdate(static function (Chat $chat): void { + }) + ->onRaw(static function () use (&$strictChatSeen): void { + $strictChatSeen[] = 'raw'; + }); + $assertThrows(ValidationException::class, static function () use ($strictChatRouter, $client): void { + $strictChatRouter->dispatchFrame(new InboundFrame(Opcode::NOTIF_CHAT, Command::REQUEST, 141, [ + 'id' => 102, + 'type' => 'CHAT', + 'status' => 'ACTIVE', + 'owner' => 7, + ]), $client); + }, 'CHAT_UPDATE must require nested `chat` payload like PyMax'); + $assertThrows(ValidationException::class, static function () use ($strictChatRouter, $client): void { + $strictChatRouter->dispatchFrame(new InboundFrame(Opcode::NOTIF_CHAT, Command::REQUEST, 142, [ + 'chat' => 'invalid', + ]), $client); + }, 'CHAT_UPDATE scalar `chat` payload must fail before handler dispatch'); + $assertThrows(ValidationException::class, static function () use ($strictChatRouter, $client): void { + $strictChatRouter->dispatchFrame(new InboundFrame(Opcode::NOTIF_CHAT, Command::REQUEST, 143, [ + 'chat' => [], + ]), $client); + }, 'CHAT_UPDATE empty nested `chat` payload must fail model validation'); + $assertSame([], $strictChatSeen, 'Malformed typed event payload must not continue to raw fallback'); + + $protocol = new TcpProtocol(); + $internalTransport = new EventDispatchTestTransport([]); + $internalManager = new ConnectionManager($internalTransport, $protocol); + $internalApp = new App($internalManager, 1.0); + $internalRouter = new Router(); + $internalClient = new class($internalApp) { + private $app; + + public function __construct(App $app) + { + $this->app = $app; + } + + public function app(): App + { + return $this->app; + } + }; + $internalSeen = []; + $internalApp->onInternal(EventType::MESSAGE_NEW, static function (Message $message) use (&$internalSeen): void { + $internalSeen[] = ['internal', $message->id]; + }); + $internalRouter + ->onMessage(static function (Message $message) use (&$internalSeen): void { + $internalSeen[] = ['user', $message->id]; + }) + ->onRaw(static function (InboundFrame $frame) use (&$internalSeen): void { + $internalSeen[] = ['raw', $frame->opcode]; + }); + $internalManager->setEventHandler(static function (InboundFrame $frame) use ($internalRouter, $internalClient): void { + $internalRouter->dispatchFrame($frame, $internalClient); + }); + $internalManager->dispatchEvent(new InboundFrame(Opcode::NOTIF_MESSAGE, Command::REQUEST, 21, [ + 'chatId' => 303, + 'message' => ['id' => 73, 'time' => 1001, 'type' => 'USER', 'text' => 'internal-first'], + ])); + $assertSame([ + ['internal', 73], + ['user', 73], + ['raw', Opcode::NOTIF_MESSAGE], + ], $internalSeen, 'Internal event handlers must run before user handlers and must not receive raw fallback'); + + $chunkFrame = static function (int $opcode, int $seq, array $payload, int $cmd = Command::REQUEST) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, $cmd)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $chunks = array_merge( + $chunkFrame(Opcode::NOTIF_MESSAGE, 999, [ + 'chatId' => 202, + 'message' => ['id' => 42, 'time' => 1000, 'type' => 'USER', 'text' => 'event-before-response'], + ]), + $chunkFrame(Opcode::PING, 0, ['ok' => true], Command::RESPONSE) + ); + $transport = new EventDispatchTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $runtimeSeen = []; + $runtimeRouter = new Router(); + $runtimeRouter + ->onMessage(static function (Message $message) use (&$runtimeSeen): void { + $runtimeSeen[] = ['message', $message->id, $message->chatId]; + }) + ->onRaw(static function (InboundFrame $frame) use (&$runtimeSeen): void { + $runtimeSeen[] = ['raw', $frame->opcode]; + }); + $runtimeClient = new Client(new ClientOptions(['requestTimeout' => 1.0]), $manager, $runtimeRouter); + $runtimeClient->open(); + $response = $runtimeClient->invoke(Opcode::PING, ['interactive' => true]); + + $assertSame(['ok' => true], $response->payload); + $assertSame([ + ['message', 42, 202], + ['raw', Opcode::NOTIF_MESSAGE], + ], $runtimeSeen); +}; diff --git a/tests-php/Domain/DomainBindingTest.php b/tests-php/Domain/DomainBindingTest.php new file mode 100644 index 0000000..15de2d3 --- /dev/null +++ b/tests-php/Domain/DomainBindingTest.php @@ -0,0 +1,359 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake domain binding chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class DomainBindingFakeUploader implements HttpUploaderInterface +{ + /** @var list> */ + public $multipart = []; + /** @var list> */ + public $streams = []; + + public function uploadMultipart( + string $url, + string $fieldName, + string $contents, + string $filename, + string $contentType + ): HttpUploadResponse { + $params = []; + parse_str((string) parse_url($url, PHP_URL_QUERY), $params); + $photoId = (string) ($params['photoIds'] ?? 'photo'); + $this->multipart[] = [ + 'url' => $url, + 'fieldName' => $fieldName, + 'contents' => $contents, + 'filename' => $filename, + 'contentType' => $contentType, + ]; + + return new HttpUploadResponse(200, json_encode([ + 'photos' => [ + $photoId => ['token' => 'token-' . $photoId], + ], + ])); + } + + public function uploadStream( + string $url, + array $headers, + iterable $chunks, + int $contentLength + ): HttpUploadResponse { + $this->streams[] = [ + 'url' => $url, + 'headers' => $headers, + 'chunks' => is_array($chunks) ? $chunks : iterator_to_array($chunks), + 'contentLength' => $contentLength, + ]; + + return new HttpUploadResponse(200, ''); + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $assertThrows(RuntimeException::class, static function (): void { + Message::fromArray(['id' => 1, 'chatId' => 2, 'time' => 3, 'type' => 'USER'])->reply('x'); + }); + $assertThrows(RuntimeException::class, static function (): void { + Chat::fromArray(['id' => 1, 'type' => 'DIALOG', 'status' => 'ACTIVE', 'owner' => 2])->answer('x'); + }); + + $protocol = new TcpProtocol(); + $message = static function (int $id, int $chatId, string $text = 'text', array $extra = []): array { + return array_merge([ + 'id' => $id, + 'chatId' => $chatId, + 'time' => 1000 + $id, + 'type' => 'USER', + 'text' => $text, + ], $extra); + }; + $chat = static function (int $id, string $type = 'CHAT', array $extra = []) use ($message): array { + return array_merge([ + 'id' => $id, + 'type' => $type, + 'status' => 'ACTIVE', + 'owner' => 1, + 'title' => 'chat-' . $id, + 'lastEventTime' => 1700 + $id, + 'pinnedMessage' => $message(77, $id, 'pinned'), + ], $extra); + }; + $frameChunks = static function (array $payload, int $opcode, int $seq, int $cmd = Command::RESPONSE) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, $cmd)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (DomainBindingTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $chunks = array_merge( + $frameChunks(['messages' => [$message(10, 500, 'source')]], Opcode::MSG_GET, 0), + $frameChunks($message(11, 500, 'reply'), Opcode::MSG_SEND, 1), + $frameChunks($message(12, 500, 'answer'), Opcode::MSG_SEND, 2), + $frameChunks($message(13, 600, 'forward'), Opcode::MSG_SEND, 3), + $frameChunks([], Opcode::CHAT_UPDATE, 4), + $frameChunks(['message' => $message(10, 500, 'edited', ['status' => 'EDITED'])], Opcode::MSG_EDIT, 5), + $frameChunks([], Opcode::MSG_DELETE, 6), + $frameChunks(['unread' => 0, 'mark' => 12345], Opcode::CHAT_MARK, 7), + $frameChunks(['reactionInfo' => ['totalCount' => 1, 'counters' => [['count' => 1, 'reaction' => '👍']], 'yourReaction' => '👍']], Opcode::MSG_REACTION, 8), + $frameChunks(['reactionInfo' => ['totalCount' => 0, 'counters' => []]], Opcode::MSG_CANCEL_REACTION, 9), + $frameChunks(['messagesReactions' => ['10' => ['totalCount' => 2, 'counters' => []]]], Opcode::MSG_GET_REACTIONS, 10), + $frameChunks(['chats' => [$chat(700)]], Opcode::CHAT_INFO, 11), + $frameChunks($message(78, 700, 'chat answer'), Opcode::MSG_SEND, 12), + $frameChunks(['messages' => [$message(79, 700, 'history')]], Opcode::CHAT_HISTORY, 13), + $frameChunks(['messages' => [$message(80, 700, 'single')]], Opcode::MSG_GET, 14), + $frameChunks(['messages' => [$message(81, 700, 'many')]], Opcode::MSG_GET, 15), + $frameChunks([], Opcode::CHAT_UPDATE, 16), + $frameChunks(['chat' => $chat(700, 'CHAT', ['title' => 'invited'])], Opcode::CHAT_MEMBERS_UPDATE, 17), + $frameChunks(['chat' => $chat(700, 'CHAT', ['title' => 'removed'])], Opcode::CHAT_MEMBERS_UPDATE, 18), + $frameChunks(['chat' => $chat(700, 'CHAT', ['title' => 'settings'])], Opcode::CHAT_UPDATE, 19), + $frameChunks(['chat' => $chat(700, 'CHAT', ['link' => 'join/new'])], Opcode::CHAT_UPDATE, 20), + $frameChunks([], Opcode::CHAT_DELETE, 21), + $frameChunks(['url' => 'https://upload.test/photo?photoIds=message-helper'], Opcode::PHOTO_UPLOAD, 22), + $frameChunks($message(90, 500, 'message photo'), Opcode::MSG_SEND, 23), + $frameChunks(['url' => 'https://upload.test/photo?photoIds=chat-helper'], Opcode::PHOTO_UPLOAD, 24), + $frameChunks($message(91, 700, 'chat photo'), Opcode::MSG_SEND, 25), + $frameChunks(['info' => [['url' => 'https://upload.test/video-helper', 'videoId' => 901, 'token' => 'message-video-token']]], Opcode::VIDEO_UPLOAD, 26), + $frameChunks(['videoId' => 901], Opcode::NOTIF_ATTACH, 9010, Command::REQUEST), + $frameChunks($message(93, 500, 'message video'), Opcode::MSG_SEND, 27), + $frameChunks(['info' => [['url' => 'https://upload.test/file-helper', 'fileId' => 902, 'token' => 'chat-file-token']]], Opcode::FILE_UPLOAD, 28), + $frameChunks(['fileId' => 902], Opcode::NOTIF_ATTACH, 9020, Command::REQUEST), + $frameChunks($message(94, 700, 'chat file'), Opcode::MSG_SEND, 29) + ); + + $transport = new DomainBindingTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $uploader = new DomainBindingFakeUploader(); + $app = new App($manager, new ClientOptions([ + 'requestTimeout' => 1.0, + 'httpUploader' => $uploader, + ])); + + $message = $app->api()->messages->getMessage(500, 10); + $assertSame(10, $message->id); + + $reply = $message->reply('reply **bold**', null, false); + $assertSame(11, $reply->id); + $assertSame(['type' => 'REPLY', 'messageId' => 10], $decodeSent($transport, 1)->payload['message']['link']); + $assertSame(false, $decodeSent($transport, 1)->payload['notify']); + + $answer = $message->answer('answer', 99, null, true); + $assertSame(12, $answer->id); + $assertSame(['type' => 'REPLY', 'messageId' => 99], $decodeSent($transport, 2)->payload['message']['link']); + + $forward = $message->forward(600, false); + $assertSame(13, $forward->id); + $forwardPayload = $decodeSent($transport, 3)->payload; + $assertSame(600, $forwardPayload['chatId']); + $assertSame('10', $forwardPayload['message']['link']['messageId']); + $assertSame(500, $forwardPayload['message']['link']['chatId']); + $assertSame(false, $forwardPayload['notify']); + + $assertSame(true, $message->pin(false)); + $assertSame(['chatId' => 500, 'notifyPin' => false, 'pinMessageId' => 10], $decodeSent($transport, 4)->payload); + + $edited = $message->edit('edited'); + $assertSame('EDITED', $edited->status); + $assertSame(true, $message->delete(true)); + $assertSame(['chatId' => 500, 'messageIds' => [10], 'forMe' => true], $decodeSent($transport, 6)->payload); + + $read = $message->read(); + $assertSame(12345, $read->mark); + $reaction = $message->react('👍'); + $assertSame(1, $reaction->totalCount); + $removedReaction = $message->unreact(); + $assertSame(0, $removedReaction->totalCount); + $reactions = $message->getReactions(); + $assertSame(2, $reactions['10']->totalCount); + + $chat = $app->api()->chats->getChat(700); + $assertSame(700, $chat->id); + $assertSame(77, $chat->pinnedMessage->id); + $assertSame(['chatIds' => [700]], $decodeSent($transport, 11)->payload); + $assert($chat->isGroup(), 'Loaded chat must be detected as group'); + $assert(!$chat->isDialog(), 'Loaded chat must not be detected as dialog'); + + $chatAnswer = $chat->answer('hello', null, null, false); + $assertSame(78, $chatAnswer->id); + $assertSame(700, $decodeSent($transport, 12)->payload['chatId']); + $assertSame(false, $decodeSent($transport, 12)->payload['notify']); + + $history = $chat->history(0, 1, 0, 0, 123, ItemType::DELAYED, true, true, true); + $assertSame(79, $history[0]->id); + $historyPayload = $decodeSent($transport, 13)->payload; + $assertSame(123, $historyPayload['from']); + $assertSame(ItemType::DELAYED, $historyPayload['itemType']); + + $single = $chat->getMessage(80); + $assertSame(80, $single->id); + $many = $chat->getMessages([81]); + $assertSame(81, $many[0]->id); + + $assertSame(true, $chat->pinMessage(82, false)); + $assertSame(['chatId' => 700, 'notifyPin' => false, 'pinMessageId' => 82], $decodeSent($transport, 16)->payload); + + $invited = $chat->invite([1, 2], false); + $assertSame('invited', $invited->title); + $assertSame([ + 'chatId' => 700, + 'userIds' => [1, 2], + 'showHistory' => false, + 'operation' => ChatMemberOperation::ADD, + ], $decodeSent($transport, 17)->payload); + + $assertSame(true, $chat->removeUsers([2], 0)); + $assertSame([ + 'chatId' => 700, + 'userIds' => [2], + 'operation' => ChatMemberOperation::REMOVE, + 'cleanMsgPeriod' => 0, + ], $decodeSent($transport, 18)->payload); + + $chat->updateSettings(true, null, false, null, null); + $assertSame([ + ChatOption::ALL_CAN_PIN_MESSAGE => true, + ChatOption::ONLY_ADMIN_CAN_ADD_MEMBER => false, + ], $decodeSent($transport, 19)->payload['options']); + + $reworked = $chat->reworkInviteLink(); + $assertSame('join/new', $reworked->link); + + $chat->delete(false); + $assertSame(['chatId' => 700, 'lastEventTime' => 2400, 'forAll' => false], $decodeSent($transport, 21)->payload); + + $messagePhotoAnswer = $message->answer('message photo', null, [Photo::fromRaw('image-bytes', 'answer.png')]); + $assertSame(90, $messagePhotoAnswer->id); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 22)->payload); + $assertSame([ + '_type' => 'PHOTO', + 'photoToken' => 'token-message-helper', + ], $decodeSent($transport, 23)->payload['message']['attaches'][0]); + + $chatPhotoAnswer = $chat->answer('chat photo', null, [Photo::fromRaw('image-bytes', 'chat.png')]); + $assertSame(91, $chatPhotoAnswer->id); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 24)->payload); + $assertSame([ + '_type' => 'PHOTO', + 'photoToken' => 'token-chat-helper', + ], $decodeSent($transport, 25)->payload['message']['attaches'][0]); + $assertSame('image.png', $uploader->multipart[0]['filename']); + $assertSame('image.png', $uploader->multipart[1]['filename']); + $assertSame('image/png', $uploader->multipart[0]['contentType']); + + $messageVideoAnswer = $message->answer('message video', null, [Video::fromRaw('video-bytes', 'answer.mp4')]); + $assertSame(93, $messageVideoAnswer->id); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 26)->payload); + $assertSame('https://upload.test/video-helper', $uploader->streams[0]['url']); + $assertSame('attachment; filename=answer.mp4', $uploader->streams[0]['headers']['Content-Disposition']); + $assertSame('0-10/11', $uploader->streams[0]['headers']['Content-Range']); + $assertSame(['video-bytes'], $uploader->streams[0]['chunks']); + $assertSame([ + '_type' => 'VIDEO', + 'videoId' => 901, + 'token' => 'message-video-token', + ], $decodeSent($transport, 27)->payload['message']['attaches'][0]); + + $chatFileAnswer = $chat->answer('chat file', null, [File::fromRaw('file-bytes', 'chat.pdf')]); + $assertSame(94, $chatFileAnswer->id); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 28)->payload); + $assertSame('https://upload.test/file-helper', $uploader->streams[1]['url']); + $assertSame('attachment; filename=chat.pdf', $uploader->streams[1]['headers']['Content-Disposition']); + $assertSame('0-9/10', $uploader->streams[1]['headers']['Content-Range']); + $assertSame(['file-bytes'], $uploader->streams[1]['chunks']); + $assertSame([ + '_type' => 'FILE', + 'fileId' => 902, + ], $decodeSent($transport, 29)->payload['message']['attaches'][0]); + + $dialog = Chat::fromArray(['id' => 1, 'type' => 'DIALOG', 'status' => 'ACTIVE', 'owner' => 1]) + ->bind($app->api()->messages, $app->api()->chats); + $assertThrows(RuntimeException::class, static function () use ($dialog): void { + $dialog->leave(); + }); + + $deleteEvent = MessageDeleteEvent::fromArray([ + 'chatId' => 500, + 'message' => ['id' => 92, 'chatId' => 500, 'time' => 1092, 'type' => 'USER', 'text' => 'removed'], + ]); + Binding::bindApiModel($app, $deleteEvent); + $eventReflection = new ReflectionClass($deleteEvent); + $eventActions = $eventReflection->getProperty('actions'); + $eventActions->setAccessible(true); + $assert($eventActions->getValue($deleteEvent) === $app->api()->messages, 'MessageDeleteEvent must bind to MessageService like PyMax'); + $assertSame(92, $deleteEvent->message->id); +}; diff --git a/tests-php/Files/BaseFileUrlTest.php b/tests-php/Files/BaseFileUrlTest.php new file mode 100644 index 0000000..9df1a6d --- /dev/null +++ b/tests-php/Files/BaseFileUrlTest.php @@ -0,0 +1,171 @@ + $headers + */ + private static function serveOnce($server, string $expectedMethod, string $path, int $status, string $body, array $headers): void + { + $conn = @stream_socket_accept($server, 5.0); + fclose($server); + if (!is_resource($conn)) { + exit(2); + } + + stream_set_timeout($conn, 5); + $request = ''; + while (strpos($request, "\r\n\r\n") === false && !feof($conn)) { + $chunk = fread($conn, 1); + if ($chunk === false) { + fclose($conn); + exit(3); + } + $request .= $chunk; + if (strlen($request) > 16384) { + fclose($conn); + exit(4); + } + } + + $requestLine = strtok($request, "\r\n"); + if ($requestLine !== $expectedMethod . ' ' . $path . ' HTTP/1.1') { + fclose($conn); + exit(5); + } + + $reason = $status >= 200 && $status < 300 ? 'OK' : 'Not Found'; + $responseHeaders = [ + 'Content-Length' => (string) strlen($body), + 'Connection' => 'close', + ]; + foreach ($headers as $name => $value) { + $responseHeaders[$name] = $value; + } + + $response = 'HTTP/1.1 ' . $status . ' ' . $reason . "\r\n"; + foreach ($responseHeaders as $name => $value) { + $response .= $name . ': ' . $value . "\r\n"; + } + $response .= "\r\n"; + if ($expectedMethod !== 'HEAD') { + $response .= $body; + } + + fwrite($conn, $response); + fclose($conn); + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $assertSame('report.pdf', File::fromUrl('https://cdn.example.test/files/report.pdf')->name()); + $assertSame('clip.mp4', Video::fromUrl('http://cdn.example.test/video/clip.mp4')->name()); + $assertSame(['jpg', 'image/jpeg'], Photo::fromUrl('https://cdn.example.test/img/avatar.jpg?token=1')->validatePhoto()); + + foreach ([ + 'file:///tmp/secret.pdf', + 'php://filter/resource=/etc/passwd', + 'ftp://cdn.example.test/file.pdf', + '//cdn.example.test/file.pdf', + '/relative/file.pdf', + 'https:///missing-host.pdf', + 'http://cdn.example.test:0/file.pdf', + ] as $unsafeUrl) { + $assertThrows(UploadException::class, static function () use ($unsafeUrl): void { + File::fromUrl($unsafeUrl); + }, 'URL sources must reject non-HTTP(S), relative or hostless URL: ' . $unsafeUrl); + } + + if (!function_exists('pcntl_fork') || !function_exists('stream_socket_server')) { + $assert(true, 'BaseFile URL tests require pcntl and sockets'); + return; + } + + $withServer = static function (string $method, string $path, int $status, string $body, callable $callback, array $headers = []) use ($assertSame): void { + list($url, $pid) = BaseFileUrlLoopbackServer::start($method, $path, $status, $body, $headers); + try { + $callback($url); + } finally { + pcntl_waitpid($pid, $serverStatus); + } + $assertSame(0, pcntl_wexitstatus($serverStatus), 'URL source loopback server must receive expected request'); + }; + + $withServer('GET', '/read.txt', 200, 'url-bytes', static function (string $url) use ($assertSame): void { + $assertSame('url-bytes', File::fromUrl($url)->read()); + }); + + $withServer('HEAD', '/size.bin', 200, '', static function (string $url) use ($assertSame): void { + $assertSame(12, File::fromUrl($url)->size()); + }, ['Content-Length' => '12']); + + $withServer('GET', '/chunks.bin', 200, 'abcdef', static function (string $url) use ($assertSame): void { + $assertSame(['ab', 'cd', 'ef'], iterator_to_array(File::fromUrl($url)->iterChunks(2))); + }); + + $withServer('GET', '/missing-read.txt', 404, 'missing', static function (string $url) use ($assertThrows): void { + $assertThrows(UploadException::class, static function () use ($url): void { + File::fromUrl($url)->read(); + }, 'URL read must fail on HTTP 404 like PyMax raise_for_status'); + }); + + $withServer('GET', '/secret-read.txt?token=secret-query', 404, 'missing', static function (string $url) use ($assert): void { + $secretUrl = str_replace('http://', 'http://user:secret-pass@', $url); + try { + File::fromUrl($secretUrl)->read(); + throw new RuntimeException('Expected URL read to fail'); + } catch (UploadException $e) { + $message = $e->getMessage(); + $assert(strpos($message, 'HTTP 404') !== false, 'URL read error must keep HTTP status'); + $assert(strpos($message, 'secret-query') === false, 'URL read error must redact query secrets'); + $assert(strpos($message, 'secret-pass') === false, 'URL read error must redact userinfo secrets'); + $assert(strpos($message, 'user:') === false, 'URL read error must not include URL userinfo'); + } + }); + + $withServer('HEAD', '/missing-size.bin', 404, '', static function (string $url) use ($assertThrows): void { + $assertThrows(UploadException::class, static function () use ($url): void { + File::fromUrl($url)->size(); + }, 'URL size must fail on HTTP 404 even when Content-Length exists'); + }, ['Content-Length' => '7']); + + $withServer('GET', '/missing-chunks.bin', 404, 'missing', static function (string $url) use ($assertThrows): void { + $assertThrows(UploadException::class, static function () use ($url): void { + iterator_to_array(File::fromUrl($url)->iterChunks(2)); + }, 'URL chunk iteration must fail on HTTP 404 like PyMax raise_for_status'); + }); +}; diff --git a/tests-php/Formatting/FormatterParityTest.php b/tests-php/Formatting/FormatterParityTest.php new file mode 100644 index 0000000..18dbba8 --- /dev/null +++ b/tests-php/Formatting/FormatterParityTest.php @@ -0,0 +1,74 @@ +type, + $entity->from, + $entity->length, + $entity->attributes !== null ? $entity->attributes->url : null, + ]; + } + + return $result; + }; + + $cases = [ + 'all inline markers' => [ + '*em* _em2_ **strong** __under__ ~~strike~~ `mono`', + 'em em2 strong under strike mono', + [ + ['EMPHASIZED', 0, 2, null], + ['EMPHASIZED', 3, 3, null], + ['STRONG', 7, 6, null], + ['UNDERLINE', 14, 5, null], + ['STRIKETHROUGH', 20, 6, null], + ['MONOSPACED', 27, 4, null], + ], + ], + 'multiline code skips language line' => [ + "A ```php\necho \"x\";\n``` Z", + "A echo \"x\";\n Z", + [ + ['CODE', 2, 10, null], + ], + ], + 'invalid and multiline inline markers stay mostly literal' => [ + "bad ** ** and **line\nbreak** and [x]()", + "bad and **line\nbreak** and [x]()", + [ + ['STRONG', 4, 1, null], + ], + ], + 'nested markers preserve PyMax close order' => [ + '**bold _still bold_**', + 'bold still bold', + [ + ['EMPHASIZED', 5, 10, null], + ['STRONG', 0, 15, null], + ], + ], + 'emoji offsets use UTF-16 code units' => [ + '😀 *ok* [😀x](https://e.test)', + '😀 ok 😀x', + [ + ['EMPHASIZED', 3, 2, null], + ['LINK', 6, 3, 'https://e.test'], + ], + ], + ]; + + foreach ($cases as $label => $case) { + [$input, $expectedText, $expectedEntities] = $case; + [$cleanText, $entities] = Formatter::formatMarkdown($input); + + $assertSame($expectedText, $cleanText, $label . ' clean text must match PyMax'); + $assertSame($expectedEntities, $normalize($entities), $label . ' entities must match PyMax'); + } +}; diff --git a/tests-php/Messages/MessageServiceTest.php b/tests-php/Messages/MessageServiceTest.php new file mode 100644 index 0000000..49dc14d --- /dev/null +++ b/tests-php/Messages/MessageServiceTest.php @@ -0,0 +1,314 @@ + */ + private $chunks; + /** @var list */ + public $sent = []; + /** @var bool */ + private $connected = false; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake message chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + [$clean, $entities] = Formatter::formatMarkdown('# Title' . "\n" . '> Quote' . "\n" . 'Hi 😀 **ok** and [site](https://example.com)'); + $assertSame("Title\nQuote\nHi 😀 ok and site", $clean); + $assertSame('HEADING', $entities[0]->type); + $assertSame('QUOTE', $entities[1]->type); + $assertSame('STRONG', $entities[2]->type); + $assertSame(18, $entities[2]->from, 'UTF-16 offset must count emoji as two code units'); + $assertSame('LINK', $entities[3]->type); + $assertSame('https://example.com', $entities[3]->attributes->url); + + $protocol = new TcpProtocol(); + $frameChunks = static function (array $payload, int $opcode, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, Command::RESPONSE)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (MessageTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $chunks = array_merge( + $frameChunks(['id' => 55, 'chatId' => 100, 'time' => 123, 'type' => 'USER', 'text' => 'Hello bold'], Opcode::MSG_SEND, 0), + $frameChunks(['id' => 56, 'chatId' => 200, 'time' => 124, 'type' => 'USER', 'text' => 'forward'], Opcode::MSG_SEND, 1), + $frameChunks(['messages' => [ + ['id' => 57, 'time' => 125, 'type' => 'USER', 'text' => 'one'], + ['id' => 58, 'chatId' => 100, 'time' => 126, 'type' => 'USER', 'text' => 'two'], + ]], Opcode::MSG_GET, 2), + $frameChunks(['message' => ['id' => 57, 'time' => 127, 'type' => 'USER', 'text' => 'edited', 'status' => 'EDITED']], Opcode::MSG_EDIT, 3), + $frameChunks(['messages' => [ + ['id' => 59, 'chatId' => 100, 'time' => 128, 'type' => 'USER', 'text' => 'history'], + ]], Opcode::CHAT_HISTORY, 4), + $frameChunks([], Opcode::MSG_DELETE, 5), + $frameChunks(['reactionInfo' => ['totalCount' => 1, 'counters' => [['count' => 1, 'reaction' => '👍']], 'yourReaction' => '👍']], Opcode::MSG_REACTION, 6), + $frameChunks(['messagesReactions' => ['57' => ['totalCount' => 1, 'counters' => []]]], Opcode::MSG_GET_REACTIONS, 7), + $frameChunks(['reactionInfo' => ['totalCount' => 0, 'counters' => []]], Opcode::MSG_CANCEL_REACTION, 8), + $frameChunks(['unread' => 0, 'mark' => 999], Opcode::CHAT_MARK, 9), + $frameChunks(['EXTERNAL' => false, 'cache' => true, 'dynamicVideoUrl' => 'https://cdn.test/video.mp4'], Opcode::VIDEO_PLAY, 10), + $frameChunks(['unsafe' => false, 'url' => 'https://cdn.test/file.pdf'], Opcode::FILE_DOWNLOAD, 11) + ); + $transport = new MessageTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + $service = $app->api()->messages; + + $sentMessage = $service->sendMessage(100, 'Hello **bold**', 44, [['photoToken' => 'photo-token']], false); + $assertSame(55, $sentMessage->id); + $sendPayload = $decodeSent($transport, 0)->payload; + $assertSame(Opcode::MSG_SEND, $decodeSent($transport, 0)->opcode); + $assertSame(100, $sendPayload['chatId']); + $assertSame(false, $sendPayload['notify']); + $assertSame('Hello bold', $sendPayload['message']['text']); + $assertSame(['type' => 'REPLY', 'messageId' => 44], $sendPayload['message']['link']); + $assertSame('STRONG', $sendPayload['message']['elements'][0]['type']); + $assertSame('photo-token', $sendPayload['message']['attaches'][0]['photoToken']); + + $forwarded = $service->forwardMessage(200, 116742887450236083, 100, false); + $assertSame(56, $forwarded->id); + $forwardPayload = $decodeSent($transport, 1)->payload; + $assertSame(-abs($forwardPayload['message']['cid']), $forwardPayload['message']['cid']); + $assertSame('116742887450236083', $forwardPayload['message']['link']['messageId']); + $assertSame(100, $forwardPayload['message']['link']['chatId']); + $assertSame(false, $forwardPayload['notify']); + + $messages = $service->getMessages(100, [57, 58]); + $assertSame([57, 58], [$messages[0]->id, $messages[1]->id]); + $assertSame(100, $messages[0]->chatId, 'Missing chatId must be restored from request'); + $assertSame(['chatId' => 100, 'messageIds' => [57, 58]], $decodeSent($transport, 2)->payload); + + $edited = $service->editMessage(100, 57, 'edited **text**'); + $assertSame('EDITED', $edited->status); + $assertSame(100, $edited->chatId); + $editPayload = $decodeSent($transport, 3)->payload; + $assertSame('edited text', $editPayload['text']); + $assertSame('STRONG', $editPayload['elements'][0]['type']); + + $history = $service->fetchHistory(100, 0, 2, 0, 0, 123, ItemType::DELAYED, true, true, true); + $assertSame(59, $history[0]->id); + $historyPayload = $decodeSent($transport, 4)->payload; + $assertSame(123, $historyPayload['from']); + $assertSame(ItemType::DELAYED, $historyPayload['itemType']); + $assertSame(true, $historyPayload['getChat']); + + $assertSame(true, $service->deleteMessage(100, [57], false)); + $assertSame(['chatId' => 100, 'messageIds' => [57], 'forMe' => false], $decodeSent($transport, 5)->payload); + + $reaction = $service->addReaction(100, '57', '👍'); + $assertSame(1, $reaction->totalCount); + $assertSame('👍', $decodeSent($transport, 6)->payload['reaction']['id']); + + $reactions = $service->getReactions(100, ['57']); + $assertSame(1, $reactions['57']->totalCount); + + $removed = $service->removeReaction(100, '57'); + $assertSame(0, $removed->totalCount); + + $read = $service->readMessage(57, 100); + $assertSame(0, $read->unread); + $assertSame('READ_MESSAGE', $decodeSent($transport, 9)->payload['type']); + + $video = $service->getVideoById(100, '57', 700); + $assertSame('https://cdn.test/video.mp4', $video->url); + $assertSame(false, $video->external); + $assertSame(true, $video->cache); + $videoPayload = $decodeSent($transport, 10)->payload; + $assertSame(['chatId' => 100, 'messageId' => '57', 'videoId' => 700], $videoPayload); + + $file = $service->getFileById(100, 57, 800); + $assertSame('https://cdn.test/file.pdf', $file->url); + $assertSame(false, $file->unsafe); + $filePayload = $decodeSent($transport, 11)->payload; + $assertSame(['chatId' => 100, 'messageId' => 57, 'fileId' => 800], $filePayload); + + $makeService = static function (array $chunks) use ($protocol): array { + $transport = new MessageTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + + return [$transport, $app->api()->messages]; + }; + + [, $emptyResponseService] = $makeService(array_merge( + $frameChunks([], Opcode::CHAT_MARK, 0), + $frameChunks([], Opcode::VIDEO_PLAY, 1), + $frameChunks([], Opcode::FILE_DOWNLOAD, 2) + )); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($emptyResponseService): void { + $emptyResponseService->readMessage(57, 100); + }, 'readMessage must require a payload like PyMax require_payload_model'); + $assertSame(null, $emptyResponseService->getVideoById(100, '57', 700), 'getVideoById must mirror PyMax parse_payload_model null path'); + $assertSame(null, $emptyResponseService->getFileById(100, '57', 800), 'getFileById must mirror PyMax parse_payload_model null path'); + + [, $strictResponseService] = $makeService(array_merge( + $frameChunks([], Opcode::MSG_SEND, 0), + $frameChunks([], Opcode::MSG_SEND, 1), + $frameChunks([], Opcode::MSG_EDIT, 2), + $frameChunks(['unexpected' => []], Opcode::MSG_EDIT, 3) + )); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($strictResponseService): void { + $strictResponseService->sendMessage(100, 'missing response'); + }, 'sendMessage must require a payload like PyMax require_payload_model'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($strictResponseService): void { + $strictResponseService->forwardMessage(100, 57); + }, 'forwardMessage must require a payload like PyMax require_payload_model'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($strictResponseService): void { + $strictResponseService->editMessage(100, 57, 'missing edit response'); + }, 'editMessage must require a message payload item like PyMax require_payload_item_model'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($strictResponseService): void { + $strictResponseService->editMessage(100, 57, 'missing message item'); + }, 'editMessage must reject responses without message item'); + + [, $badMessageListService] = $makeService(array_merge( + $frameChunks(['messages' => [123]], Opcode::MSG_GET, 0), + $frameChunks(['messages' => [123]], Opcode::CHAT_HISTORY, 1) + )); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badMessageListService): void { + $badMessageListService->getMessages(100, [57]); + }, 'Malformed message list items must fail fast like PyMax parse_payload_list'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badMessageListService): void { + $badMessageListService->fetchHistory(100); + }, 'Malformed history message list items must fail fast like PyMax parse_payload_list'); + + [, $reactionEdgeService] = $makeService(array_merge( + $frameChunks(['reactionInfo' => []], Opcode::MSG_REACTION, 0), + $frameChunks(['reactionInfo' => 123], Opcode::MSG_CANCEL_REACTION, 1), + $frameChunks(['messagesReactions' => ['57' => 123]], Opcode::MSG_GET_REACTIONS, 2), + $frameChunks(['messagesReactions' => [['totalCount' => 1, 'counters' => []]]], Opcode::MSG_GET_REACTIONS, 3) + )); + $assertSame(null, $reactionEdgeService->addReaction(100, '57', '👍'), 'Empty reactionInfo must mirror PyMax optional falsey path'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($reactionEdgeService): void { + $reactionEdgeService->removeReaction(100, '57'); + }, 'Malformed reactionInfo must fail fast like PyMax model_validate'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($reactionEdgeService): void { + $reactionEdgeService->getReactions(100, ['57']); + }, 'Malformed messagesReactions values must fail fast'); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($reactionEdgeService): void { + $reactionEdgeService->getReactions(100, ['57']); + }, 'messagesReactions must be a map keyed by message id'); + + $clientChunks = array_merge( + $frameChunks(['id' => 70, 'chatId' => 300, 'time' => 130, 'type' => 'USER', 'text' => 'forwarded'], Opcode::MSG_SEND, 0), + $frameChunks(['message' => ['id' => 71, 'chatId' => 300, 'time' => 131, 'type' => 'USER', 'text' => 'edited']], Opcode::MSG_EDIT, 1), + $frameChunks(['messages' => [ + ['id' => 72, 'chatId' => 300, 'time' => 132, 'type' => 'USER', 'text' => 'history'], + ]], Opcode::CHAT_HISTORY, 2), + $frameChunks([], Opcode::MSG_DELETE, 3), + $frameChunks([], Opcode::CHAT_UPDATE, 4), + $frameChunks(['reactionInfo' => ['totalCount' => 1, 'counters' => []]], Opcode::MSG_REACTION, 5), + $frameChunks(['messagesReactions' => ['70' => ['totalCount' => 2, 'counters' => []]]], Opcode::MSG_GET_REACTIONS, 6), + $frameChunks(['reactionInfo' => ['totalCount' => 0, 'counters' => []]], Opcode::MSG_CANCEL_REACTION, 7), + $frameChunks(['unread' => 0, 'mark' => 1000], Opcode::CHAT_MARK, 8), + $frameChunks(['EXTERNAL' => false, 'cache' => true, 'dynamicVideoUrl' => 'https://cdn.test/client-video.mp4'], Opcode::VIDEO_PLAY, 9), + $frameChunks(['unsafe' => false, 'url' => 'https://cdn.test/client-file.pdf'], Opcode::FILE_DOWNLOAD, 10) + ); + $clientTransport = new MessageTestTransport($clientChunks); + $clientManager = new ConnectionManager($clientTransport, $protocol); + $clientManager->open(); + $client = new Client(new ClientOptions(['requestTimeout' => 1.0]), $clientManager); + + $clientForwarded = $client->forwardMessage(300, 'source-message', 100, false); + $assertSame(70, $clientForwarded->id); + $assertSame(Opcode::MSG_SEND, $decodeSent($clientTransport, 0)->opcode); + $assertSame('source-message', $decodeSent($clientTransport, 0)->payload['message']['link']['messageId']); + + $clientEdited = $client->editMessage(300, 71, 'client **edit**'); + $assertSame(71, $clientEdited->id); + $assertSame(Opcode::MSG_EDIT, $decodeSent($clientTransport, 1)->opcode); + $assertSame('client edit', $decodeSent($clientTransport, 1)->payload['text']); + + $clientHistory = $client->fetchHistory(300, 0, 1, 0, 0, 555, ItemType::REGULAR, false, true, false); + $assertSame(72, $clientHistory[0]->id); + $assertSame(Opcode::CHAT_HISTORY, $decodeSent($clientTransport, 2)->opcode); + $assertSame(555, $decodeSent($clientTransport, 2)->payload['from']); + + $assertSame(true, $client->deleteMessage(300, [70], true)); + $assertSame(Opcode::MSG_DELETE, $decodeSent($clientTransport, 3)->opcode); + $assertSame(['chatId' => 300, 'messageIds' => [70], 'forMe' => true], $decodeSent($clientTransport, 3)->payload); + + $assertSame(true, $client->pinMessage(300, 70, false)); + $assertSame(Opcode::CHAT_UPDATE, $decodeSent($clientTransport, 4)->opcode); + $assertSame(false, $decodeSent($clientTransport, 4)->payload['notifyPin']); + + $clientReaction = $client->addReaction(300, '70', '🔥'); + $assertSame(1, $clientReaction->totalCount); + $assertSame(Opcode::MSG_REACTION, $decodeSent($clientTransport, 5)->opcode); + $assertSame('🔥', $decodeSent($clientTransport, 5)->payload['reaction']['id']); + + $clientReactions = $client->getReactions(300, ['70']); + $assertSame(2, $clientReactions['70']->totalCount); + $assertSame(Opcode::MSG_GET_REACTIONS, $decodeSent($clientTransport, 6)->opcode); + + $clientRemoved = $client->removeReaction(300, '70'); + $assertSame(0, $clientRemoved->totalCount); + $assertSame(Opcode::MSG_CANCEL_REACTION, $decodeSent($clientTransport, 7)->opcode); + + $clientRead = $client->readMessage('70', 300); + $assertSame(0, $clientRead->unread); + $assertSame(Opcode::CHAT_MARK, $decodeSent($clientTransport, 8)->opcode); + $assertSame('70', $decodeSent($clientTransport, 8)->payload['messageId']); + + $clientVideo = $client->getVideoById(300, '70', 900); + $assertSame('https://cdn.test/client-video.mp4', $clientVideo->url); + $assertSame(Opcode::VIDEO_PLAY, $decodeSent($clientTransport, 9)->opcode); + + $clientFile = $client->getFileById(300, '70', 901); + $assertSame('https://cdn.test/client-file.pdf', $clientFile->url); + $assertSame(Opcode::FILE_DOWNLOAD, $decodeSent($clientTransport, 10)->opcode); +}; diff --git a/tests-php/Protocol/BinaryPayloadTest.php b/tests-php/Protocol/BinaryPayloadTest.php new file mode 100644 index 0000000..51f453f --- /dev/null +++ b/tests-php/Protocol/BinaryPayloadTest.php @@ -0,0 +1,22 @@ +toArray(); + + $assert( + $payload['exp']['chatsCountGroups'] instanceof \PHPMax\Protocol\Tcp\BinaryString, + 'SyncPayload exp.chatsCountGroups must stay a MessagePack binary value.' + ); + + $encoded = (new \PHPMax\Protocol\Tcp\MsgpackPayloadCodec())->encode($payload); + $assert( + strpos($encoded, "\xC4\x02\x0A\x32") !== false, + 'MessagePack LOGIN payload must encode exp.chatsCountGroups as bin8 bytes.' + ); +}; diff --git a/tests-php/Protocol/TcpProtocolTest.php b/tests-php/Protocol/TcpProtocolTest.php new file mode 100644 index 0000000..62c7bd6 --- /dev/null +++ b/tests-php/Protocol/TcpProtocolTest.php @@ -0,0 +1,78 @@ +pack(10, Command::RESPONSE, 0x0100, Opcode::PING, 2, 'abc'); + + $assertSame(10, TcpPacketFramer::HEADER_SIZE, 'TCP header size must match PyMax'); + $assertSame( + hex2bin('0a010100000102000003'), + substr($packet, 0, TcpPacketFramer::HEADER_SIZE), + 'TCP header layout must match PyMax' + ); + $assertSame(3, $framer->unpackHeader(substr($packet, 0, TcpPacketFramer::HEADER_SIZE))); + $unpacked = $framer->unpack($packet); + $assert($unpacked !== null, 'Framer must unpack complete packet'); + $assertSame(Command::RESPONSE, $unpacked->header->cmd); + $assertSame(2, $unpacked->header->flags); + $assertSame('abc', $unpacked->payloadBytes); + + $protocol = new TcpProtocol(); + $frame = new OutboundFrame( + TcpProtocol::VERSION, + Opcode::CHAT_HISTORY, + 3, + ['chatId' => 100, 'itemType' => 'REGULAR'], + Command::REQUEST + ); + $decoded = $protocol->decode($protocol->encode($frame)); + $assert($decoded instanceof InboundFrame); + $assertSame(Opcode::CHAT_HISTORY, $decoded->opcode); + $assertSame(Command::REQUEST, $decoded->cmd); + $assertSame(3, $decoded->seq); + $assertSame(['chatId' => 100, 'itemType' => 'REGULAR'], $decoded->payload); + + $codec = new MsgpackPayloadCodec(); + $payload = [1 => ['name' => 'DELAYED'], 'list' => ['REGULAR']]; + $normalized = (new TcpPayloadDecoder($codec))->decode($codec->encode($payload)); + $assertSame(['1' => ['name' => 'DELAYED'], 'list' => ['REGULAR']], $normalized); + + $largePositive = 1783069696612; + $largeNegative = -5000000000; + $largeDecoded = $codec->decode($codec->encode([ + 'expiresAt' => $largePositive, + 'offset' => $largeNegative, + ])); + $assertSame($largePositive, $largeDecoded['expiresAt'], 'MessagePack fallback must preserve uint64 timestamps'); + $assertSame($largeNegative, $largeDecoded['offset'], 'MessagePack fallback must preserve int64 values'); + + $compressed = hex2bin( + 'f40a84a6707265666978a27878a464617461b0664a73436c4b437508008f' . + 'a47461696cd92a79010016dfa6726570656174d9684142434404004c5044' . + '41424344' + ); + $decodedLz4 = (new TcpPayloadDecoder($codec, new Lz4BlockCompression()))->decode($compressed, 4); + $assertSame('xx', $decodedLz4['prefix']); + $assertSame(str_repeat('ABCD', 26), $decodedLz4['repeat']); + + $assertThrows(\PHPMax\Exception\ProtocolException::class, static function (): void { + (new Lz4BlockCompression())->decompress(chr(0x01) . chr(0x00) . chr(0x00)); + }, 'Invalid LZ4 zero offset must be rejected'); + + $attachmentPayload = ['_type' => AttachmentType::PHOTO, 'photoId' => 'p1', 'token' => 't1']; + $encodedAttachment = $codec->encode(['attaches' => [$attachmentPayload]]); + $assertSame(['attaches' => [$attachmentPayload]], (new TcpPayloadDecoder($codec))->decode($encodedAttachment)); +}; diff --git a/tests-php/Protocol/WsProtocolTest.php b/tests-php/Protocol/WsProtocolTest.php new file mode 100644 index 0000000..c97b4e7 --- /dev/null +++ b/tests-php/Protocol/WsProtocolTest.php @@ -0,0 +1,209 @@ + */ + private $messages; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $messages + */ + public function __construct(array $messages) + { + $this->messages = $messages; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + return $this->recvMessage($timeout); + } + + public function recvMessage(float $timeout): string + { + $message = array_shift($this->messages); + if ($message === null) { + throw new RuntimeException('No fake WebSocket messages left'); + } + + return $message; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class WsProtocolQrHandler implements QrHandlerInterface +{ + /** @var list */ + public $urls = []; + + public function showQr(string $qrUrl): void + { + $this->urls[] = $qrUrl; + } +} + +final class WsProtocolCustomAuthFlow implements AuthFlowInterface +{ + public function authenticate(AuthService $authService, ClientOptions $options): AuthResult + { + return new AuthResult('custom-web-token'); + } +} + +final class WsProtocolByteOnlyTransport implements TransportInterface +{ + public function connect(): void + { + } + + public function close(): void + { + } + + public function send(string $data): void + { + } + + public function recv(int $length, float $timeout): string + { + return ''; + } + + public function connected(): bool + { + return true; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new WsProtocol(); + $encoded = $protocol->encode(new OutboundFrame( + WsProtocol::VERSION, + Opcode::PING, + 7, + ['interactive' => true], + Command::REQUEST + )); + $decodedJson = json_decode($encoded, true); + $assertSame(11, $decodedJson['ver']); + $assertSame(Opcode::PING, $decodedJson['opcode']); + $assertSame(Command::REQUEST, $decodedJson['cmd']); + $assertSame(7, $decodedJson['seq']); + $assertSame(['interactive' => true], $decodedJson['payload']); + + $decoded = $protocol->decode('{"opcode":1,"cmd":1,"seq":7,"payload":{"ok":true}}'); + $assert($decoded instanceof InboundFrame); + $assertSame(Opcode::PING, $decoded->opcode); + $assertSame(Command::RESPONSE, $decoded->cmd); + $assertSame(7, $decoded->seq); + $assertSame(['ok' => true], $decoded->payload); + $assertSame(null, $protocol->decode('not-json')->seq); + $assertSame(0, $protocol->decode('not-json')->opcode); + + $transport = new WsProtocolTestTransport([ + '{"opcode":129,"cmd":0,"seq":22,"payload":{"chatId":10,"userId":20}}', + '{"opcode":1,"cmd":1,"seq":0,"payload":{"ok":true}}', + ]); + $manager = new ConnectionManager($transport, $protocol, new WebSocketFrameReader()); + $events = []; + $manager->setEventHandler(static function (InboundFrame $frame) use (&$events): void { + $events[] = $frame; + }); + $manager->open(); + $response = $manager->request(new OutboundFrame( + $manager->protocolVersion(), + Opcode::PING, + $manager->nextSeq(), + ['interactive' => true], + Command::REQUEST + ), 1.0); + $assertSame(['ok' => true], $response->payload); + $assertSame(1, count($events)); + $assertSame(Opcode::NOTIF_TYPING, $events[0]->opcode); + $sentFrame = json_decode($transport->sent[0], true); + $assertSame(11, $sentFrame['ver']); + $assertSame(Opcode::PING, $sentFrame['opcode']); + + $appTransport = new WsProtocolTestTransport([ + '{"opcode":1,"cmd":1,"seq":0,"payload":{"ok":true}}', + ]); + $appManager = new ConnectionManager($appTransport, new WsProtocol(), new WebSocketFrameReader()); + $appManager->open(); + $app = new App($appManager, new ClientOptions(['requestTimeout' => 1.0])); + $appResponse = $app->invoke(Opcode::PING, ['interactive' => true]); + $assertSame(['ok' => true], $appResponse->payload); + $appSent = json_decode($appTransport->sent[0], true); + $assertSame(11, $appSent['ver'], 'App::invoke must use active protocol version'); + + $webClientTransport = new WsProtocolTestTransport([]); + $webClientManager = new ConnectionManager($webClientTransport, new WsProtocol(), new WebSocketFrameReader()); + $webClient = new WebClient(new ClientOptions(['requestTimeout' => 1.0]), new WsProtocolQrHandler(), $webClientManager); + $assertSame(DeviceType::WEB, $webClient->app()->options()->userAgent->deviceType); + $assert($webClient->app()->options()->authFlow instanceof QrAuthFlow, 'WebClient must default to QR auth flow'); + + $customFlow = new WsProtocolCustomAuthFlow(); + $customWebClient = new WebClient( + new ClientOptions(['authFlow' => $customFlow]), + new WsProtocolQrHandler(), + new ConnectionManager(new WsProtocolTestTransport([]), new WsProtocol(), new WebSocketFrameReader()) + ); + $assert($customWebClient->app()->options()->authFlow === $customFlow, 'WebClient must preserve an explicitly configured auth flow'); + $assertSame(DeviceType::WEB, $customWebClient->app()->options()->userAgent->deviceType, 'WebClient must still force web user-agent for custom auth flow'); + + $webOptions = new ClientOptions(); + $webOptions->userAgent = \PHPMax\Api\Session\MobileUserAgentPayload::defaultWeb(); + $existingWebAgent = $webOptions->userAgent; + $existingWebClient = new WebClient( + $webOptions, + new WsProtocolQrHandler(), + new ConnectionManager(new WsProtocolTestTransport([]), new WsProtocol(), new WebSocketFrameReader()) + ); + $assert($existingWebClient->app()->options()->userAgent === $existingWebAgent, 'WebClient must preserve explicitly configured web user-agent object'); + + $assertThrows(\PHPMax\Exception\ProtocolException::class, static function (): void { + (new WebSocketFrameReader())->read(new WsProtocolByteOnlyTransport(), 1.0); + }, 'Empty WebSocket message transport must surface read errors'); +}; diff --git a/tests-php/Runtime/ConnectionManagerTest.php b/tests-php/Runtime/ConnectionManagerTest.php new file mode 100644 index 0000000..e90eab3 --- /dev/null +++ b/tests-php/Runtime/ConnectionManagerTest.php @@ -0,0 +1,196 @@ + */ + public $chunks; + /** @var list */ + public $sent = []; + /** @var bool */ + private $connected = false; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + if ($this->chunks === []) { + throw new RuntimeException('No fake chunks left'); + } + $chunk = array_shift($this->chunks); + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected recv length ' . $length . ', got chunk length ' . strlen($chunk)); + } + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $eventRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::NOTIF_TYPING, + 55, + ['chatId' => 100], + Command::REQUEST + )); + $responseRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + 7, + ['ok' => true], + Command::RESPONSE + )); + + $transport = new FakeRuntimeTransport([ + substr($eventRaw, 0, 10), + substr($eventRaw, 10), + substr($responseRaw, 0, 10), + substr($responseRaw, 10), + ]); + $manager = new ConnectionManager($transport, $protocol); + $events = []; + $manager->setEventHandler(static function (InboundFrame $frame) use (&$events): void { + $events[] = $frame; + }); + $manager->open(); + + $response = $manager->request(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + 7, + ['interactive' => true], + Command::REQUEST + ), 1.0); + + $assertSame(Opcode::PING, $response->opcode); + $assertSame(['ok' => true], $response->payload); + $assertSame(1, count($events)); + $assertSame(Opcode::NOTIF_TYPING, $events[0]->opcode); + $assertSame(1, count($transport->sent)); + + $managerWrap = new ConnectionManager(new FakeRuntimeTransport([]), $protocol); + $reflection = new ReflectionClass($managerWrap); + $seq = $reflection->getProperty('seq'); + $seq->setAccessible(true); + $seq->setValue($managerWrap, 0xFFFE); + $assertSame(0xFFFF, $managerWrap->nextSeq()); + $assertSame(0, $managerWrap->nextSeq()); + + $assertThrows(\PHPMax\Exception\ProtocolException::class, static function () use ($managerWrap): void { + $managerWrap->send(new OutboundFrame(TcpProtocol::VERSION, Opcode::PING, 1, [])); + }, 'Closed connection must reject send'); + + $appResponseRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + 0, + ['ok' => true], + Command::RESPONSE + )); + $transportApp = new FakeRuntimeTransport([ + substr($appResponseRaw, 0, 10), + substr($appResponseRaw, 10), + ]); + $managerApp = new ConnectionManager($transportApp, $protocol); + $managerApp->open(); + $app = new App($managerApp, 1.0); + $appResponse = $app->invoke(Opcode::PING, ['interactive' => true]); + $assertSame(['ok' => true], $appResponse->payload); + + $errorRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::MSG_SEND, + 0, + [ + 'error' => 'message_denied', + 'title' => 'Denied', + 'message' => 'Message rejected', + 'localizedMessage' => 'Сообщение отклонено', + 'details' => ['reason' => 'policy'], + ], + Command::ERROR + )); + $errorTransport = new FakeRuntimeTransport([ + substr($errorRaw, 0, 10), + substr($errorRaw, 10), + ]); + $errorManager = new ConnectionManager($errorTransport, $protocol); + $errorManager->open(); + $errorApp = new App($errorManager, 1.0); + try { + $errorApp->invoke(Opcode::MSG_SEND, ['chatId' => 1]); + throw new RuntimeException('ApiException was not thrown'); + } catch (ApiException $e) { + $assertSame(Opcode::MSG_SEND, $e->opcode()); + $assertSame('message_denied', $e->error()); + $assertSame('Denied', $e->title()); + $assertSame('Message rejected', $e->apiMessage()); + $assertSame('Сообщение отклонено', $e->localizedMessage()); + $assertSame('policy', $e->payload()['details']['reason']); + $assert(strpos($e->getMessage(), 'Сообщение отклонено') !== false); + $assert(strpos($e->getMessage(), '[message_denied]') !== false); + } + + $malformedErrorRaw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + Opcode::PING, + 0, + ['message' => 'Missing required error code'], + Command::ERROR + )); + $malformedTransport = new FakeRuntimeTransport([ + substr($malformedErrorRaw, 0, 10), + substr($malformedErrorRaw, 10), + ]); + $malformedManager = new ConnectionManager($malformedTransport, $protocol); + $malformedManager->open(); + $malformedApp = new App($malformedManager, 1.0); + try { + $malformedApp->invoke(Opcode::PING, []); + throw new RuntimeException('Fallback ApiException was not thrown'); + } catch (ApiException $e) { + $assertSame(Opcode::PING, $e->opcode()); + $assertSame('unknown_error', $e->error()); + $assertSame('Unknown error', $e->title()); + $assertSame('Missing required field `error` for PHPMax\\Domain\\MaxApiError', $e->apiMessage()); + $assertSame('Missing required error code', $e->payload()['message']); + } +}; diff --git a/tests-php/Support/ContractManifestTest.php b/tests-php/Support/ContractManifestTest.php new file mode 100644 index 0000000..bafbc93 --- /dev/null +++ b/tests-php/Support/ContractManifestTest.php @@ -0,0 +1,121 @@ + AccessType::class, + 'AttachmentType' => AttachmentType::class, + 'ChatType' => ChatType::class, + 'MessageStatus' => MessageStatus::class, + 'TranscriptionStatus' => TranscriptionStatus::class, + ]; + + foreach ($domainClasses as $name => $className) { + $assert(isset($manifest['domain_enums'][$name]) && is_array($manifest['domain_enums'][$name]), 'Missing domain enum manifest for ' . $name); + $constants = (new ReflectionClass($className))->getConstants(); + ksort($constants); + $assertSame($manifest['domain_enums'][$name], $constants, 'Domain enum constants must match manifest for ' . $name); + } + + $assertSame([ + 'EDITED' => 'EDITED', + 'REMOVED' => 'REMOVED', + ], $manifest['domain_enums']['MessageStatus'], 'MessageStatus must mirror PyMax exactly'); + + $apiClasses = [ + 'auth.AuthType' => AuthType::class, + 'auth.ProfileOptions' => ProfileOptions::class, + 'auth.TwoFactorAction' => TwoFactorAction::class, + 'chats.ChatLinkPrefix' => ChatLinkPrefix::class, + 'chats.ChatMemberOperation' => ChatMemberOperation::class, + 'chats.ChatOption' => ChatOption::class, + 'chats.ChatPayloadKey' => ChatPayloadKey::class, + 'chats.ControlEvent' => ControlEvent::class, + 'messages.ItemType' => ItemType::class, + 'messages.MessagePayloadKey' => MessagePayloadKey::class, + 'messages.ReadAction' => ReadAction::class, + 'self.AvatarType' => AvatarType::class, + 'self.SelfPayloadKey' => SelfPayloadKey::class, + 'session.DeviceType' => DeviceType::class, + 'users.ContactAction' => ContactAction::class, + 'users.UserPayloadKey' => UserPayloadKey::class, + ]; + + foreach ($apiClasses as $name => $className) { + $assert(isset($manifest['api_enums'][$name]) && is_array($manifest['api_enums'][$name]), 'Missing API enum manifest for ' . $name); + $constants = (new ReflectionClass($className))->getConstants(); + ksort($constants); + $assertSame($manifest['api_enums'][$name], $constants, 'API enum constants must match manifest for ' . $name); + } + + $assertSame('reactionInfo', $manifest['api_enums']['messages.MessagePayloadKey']['REACTION_INFO']); + $assertSame(5, $manifest['api_enums']['auth.TwoFactorAction']['REMOVE_2FA']); + $assertSame('sessions', $manifest['api_enums']['users.UserPayloadKey']['SESSIONS']); + $assertSame('chatId', $manifest['payload_models']['messages.SendMessagePayload']['payload_keys']['chat_id']); + $assertSame('_type', $manifest['payload_models']['chats.CreateGroupAttach']['payload_keys']['type']); + $assertSame('ONLY_ADMIN_CAN_ADD_MEMBER', $manifest['payload_models']['chats.ChangeGroupSettingsOptions']['payload_keys']['only_admin_can_add_member']); + $assertSame('firstName', $manifest['payload_models']['users._ContactPayload']['payload_keys']['first_name']); + $assertSame('checkTwoFactor', $manifest['service_methods']['auth']['check_2fa']); + $assertSame('getInitData', $manifest['service_methods']['bots']['get_init_data']); + $assertSame('deleteChat', $manifest['service_methods']['chats']['delete_chat']); + $assertSame('checkTwoFactor', $manifest['client_methods']['check_2fa']); + $assertSame('getBotInitData', $manifest['client_methods']['get_bot_init_data']); + $assertSame('fetchHistory', $manifest['client_methods']['fetch_history']); + $assertSame(['passwordOld', 'passwordNew'], $manifest['service_method_params']['auth']['change_password']['php_params']); + $assertSame(['passwordOld', 'passwordNew'], $manifest['client_method_params']['change_password']['php_params']); + $assertSame('fromTime', $manifest['client_method_params']['fetch_history']['php_params'][5]); + $assertSame('firstName', $manifest['domain_models']['Name']['payload_keys']['first_name']); + $assertSame('reactionInfo', $manifest['domain_models']['Message']['payload_keys']['reaction_info']); + $assertSame('profileOptions', $manifest['domain_models']['Profile']['payload_keys']['profile_options']); + $assertSame('LOGIN', $manifest['domain_models']['TokenAttrs']['payload_keys']['login']); + $assertSame('chats_sync', $manifest['domain_models']['SyncState']['payload_keys']['chats_sync']); + $assertSame('_type', $manifest['domain_models']['PhotoAttachment']['payload_keys']['type']); + $assertSame('videoType', $manifest['domain_models']['VideoAttachment']['payload_keys']['video_type']); + $assertSame('queryId', $manifest['domain_models']['InitData']['payload_keys']['query_id']); + $assertSame('from', $manifest['domain_models']['Element']['payload_keys']['from_']); + $assertSame('messageIds', $manifest['event_models']['MessageDeleteEvent']['payload_keys']['message_ids']); + $assertSame('totalCount', $manifest['event_models']['ReactionUpdateEvent']['payload_keys']['total_count']); + $assertSame('videoId', $manifest['event_models']['VideoUploadSignal']['payload_keys']['video_id']); + + $checkOutput = []; + $checkExit = 1; + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($repoRoot . '/tools/contract-manifest.php') . ' check', $checkOutput, $checkExit); + $assertSame(0, $checkExit, 'contract manifest check must pass'); +}; diff --git a/tests-php/Support/IntegrationCheckTest.php b/tests-php/Support/IntegrationCheckTest.php new file mode 100644 index 0000000..b407c84 --- /dev/null +++ b/tests-php/Support/IntegrationCheckTest.php @@ -0,0 +1,149 @@ +&1'; + $output = []; + $exitCode = 1; + exec($command, $output, $exitCode); + + $assertSame(0, $exitCode, 'Integration check must be safe to run without credentials'); + $assert(strpos(implode("\n", $output), 'SKIP integration checks') !== false, 'Integration check must explain disabled state'); + + $missingTokenCommand = 'env -u PHPMAX_TOKEN PHPMAX_INTEGRATION=1 ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') . ' 2>&1'; + $missingTokenOutput = []; + $missingTokenExitCode = 0; + exec($missingTokenCommand, $missingTokenOutput, $missingTokenExitCode); + + $assertSame(2, $missingTokenExitCode, 'Integration check must fail fast when explicitly enabled without token'); + $assert(strpos(implode("\n", $missingTokenOutput), 'PHPMAX_TOKEN is required') !== false, 'Integration check must explain missing token'); + + $smsPhoneCommand = 'env -u PHPMAX_TOKEN PHPMAX_INTEGRATION=1' + . ' PHPMAX_AUTH_SMS=1' + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $smsPhoneOutput = []; + $smsPhoneExitCode = 0; + exec($smsPhoneCommand, $smsPhoneOutput, $smsPhoneExitCode); + $smsPhoneText = implode("\n", $smsPhoneOutput); + + $assertSame(2, $smsPhoneExitCode, 'Interactive SMS integration mode must fail fast without phone before network checks'); + $assert(strpos($smsPhoneText, 'PHPMAX_PHONE is required when PHPMAX_AUTH_SMS=1') !== false, 'SMS auth preflight must explain missing phone'); + + $planCommand = 'PHPMAX_INTEGRATION_PLAN=1 PHPMAX_TOKEN=secret-token PHPMAX_PROXY=http://user:secret-pass@127.0.0.1:8080 PHPMAX_UPLOAD_PHOTO=1 ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') . ' 2>&1'; + $planOutput = []; + $planExitCode = 1; + exec($planCommand, $planOutput, $planExitCode); + $planText = implode("\n", $planOutput); + + $assertSame(0, $planExitCode, 'Integration plan must be safe without real network checks'); + $assert(strpos($planText, 'PHPMax integration plan') !== false, 'Integration plan must explain what it prints'); + $assert(strpos($planText, 'PHPMAX_AUTH_SMS=1') !== false, 'Integration plan must document interactive SMS auth mode'); + $assert(strpos($planText, 'saved-session-login') !== false, 'Integration plan must document saved-session reuse check'); + $assert(strpos($planText, 'photo-upload:') !== false && strpos($planText, 'ready') !== false, 'Integration plan must mark configured optional checks'); + $assert(strpos($planText, 'secret-token') === false, 'Integration plan must not print token values'); + $assert(strpos($planText, 'secret-pass') === false, 'Integration plan must not print proxy credentials'); + + $preflightCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_PROXY=' . escapeshellarg('http://user:secret-pass@127.0.0.1:8080') + . ' PHPMAX_SESSION_NAME=' . escapeshellarg('../session.json') + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $preflightOutput = []; + $preflightExitCode = 0; + exec($preflightCommand, $preflightOutput, $preflightExitCode); + $preflightText = implode("\n", $preflightOutput); + + $assertSame(2, $preflightExitCode, 'Integration check must fail fast on invalid enabled-run configuration before network checks'); + $assert(strpos($preflightText, 'FAIL integration preflight') !== false, 'Integration preflight failure must be explicit'); + $assert(strpos($preflightText, 'Session file name must be a plain file name') !== false, 'Integration preflight must surface invalid session name'); + $assert(strpos($preflightText, 'secret-token') === false, 'Integration preflight must not print token values'); + $assert(strpos($preflightText, 'secret-pass') === false, 'Integration preflight must not print proxy credentials'); + + $numericCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_UPLOAD_CHUNK_SIZE=not-a-number' + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $numericOutput = []; + $numericExitCode = 0; + exec($numericCommand, $numericOutput, $numericExitCode); + $numericText = implode("\n", $numericOutput); + + $assertSame(2, $numericExitCode, 'Integration check must fail fast on malformed numeric env before network checks'); + $assert(strpos($numericText, 'PHPMAX_UPLOAD_CHUNK_SIZE must be an integer') !== false, 'Integration preflight must explain malformed numeric env'); + $assert(strpos($numericText, 'secret-token') === false, 'Integration numeric preflight must not print token values'); + + $boundsCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_UPLOAD_CHUNK_SIZE=0' + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $boundsOutput = []; + $boundsExitCode = 0; + exec($boundsCommand, $boundsOutput, $boundsExitCode); + $boundsText = implode("\n", $boundsOutput); + + $assertSame(2, $boundsExitCode, 'Integration check must reject non-positive upload chunk size before network checks'); + $assert(strpos($boundsText, 'PHPMAX_UPLOAD_CHUNK_SIZE must be a positive integer') !== false, 'Integration preflight must explain non-positive upload chunk size'); + $assert(strpos($boundsText, 'secret-token') === false, 'Integration bounds preflight must not print token values'); + + $timeoutCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_REQUEST_TIMEOUT=0' + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $timeoutOutput = []; + $timeoutExitCode = 0; + exec($timeoutCommand, $timeoutOutput, $timeoutExitCode); + $timeoutText = implode("\n", $timeoutOutput); + + $assertSame(2, $timeoutExitCode, 'Integration check must reject zero request timeout before network checks'); + $assert(strpos($timeoutText, 'PHPMAX_REQUEST_TIMEOUT must be greater than 0') !== false, 'Integration preflight must explain zero request timeout'); + $assert(strpos($timeoutText, 'secret-token') === false, 'Integration timeout preflight must not print token values'); + + $pingCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_PING_INTERVAL=-1' + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $pingOutput = []; + $pingExitCode = 0; + exec($pingCommand, $pingOutput, $pingExitCode); + $pingText = implode("\n", $pingOutput); + + $assertSame(2, $pingExitCode, 'Integration check must reject negative ping interval before network checks'); + $assert(strpos($pingText, 'PHPMAX_PING_INTERVAL must be greater than or equal to 0') !== false, 'Integration preflight must explain negative ping interval'); + $assert(strpos($pingText, 'secret-token') === false, 'Integration ping preflight must not print token values'); + + $workDirFile = tempnam(sys_get_temp_dir(), 'phpmax-integration-workdir-test-'); + if ($workDirFile === false) { + throw new RuntimeException('Unable to create temporary workdir fixture'); + } + try { + $workDirCommand = 'PHPMAX_INTEGRATION=1' + . ' PHPMAX_TOKEN=secret-token' + . ' PHPMAX_WORKDIR=' . escapeshellarg($workDirFile) + . ' ' . escapeshellarg(PHP_BINARY) + . ' ' . escapeshellarg(__DIR__ . '/../../tools/integration-check.php') + . ' 2>&1'; + $workDirOutput = []; + $workDirExitCode = 0; + exec($workDirCommand, $workDirOutput, $workDirExitCode); + $workDirText = implode("\n", $workDirOutput); + + $assertSame(2, $workDirExitCode, 'Integration check must reject file workdir before network checks'); + $assert(strpos($workDirText, 'Integration workdir is not a directory') !== false, 'Integration preflight must explain invalid workdir'); + $assert(strpos($workDirText, 'secret-token') === false, 'Integration workdir preflight must not print token values'); + } finally { + @unlink($workDirFile); + } +}; diff --git a/tests-php/Support/Php74CompatCheckTest.php b/tests-php/Support/Php74CompatCheckTest.php new file mode 100644 index 0000000..cd91293 --- /dev/null +++ b/tests-php/Support/Php74CompatCheckTest.php @@ -0,0 +1,88 @@ +&1', $output, $exitCode); + + return [$exitCode, implode("\n", $output)]; + } + + public static function removeTree(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($path); + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + if (!function_exists('exec')) { + $assert(true, 'php74 compatibility checker test requires exec'); + return; + } + + $root = dirname(__DIR__, 2); + $tool = $root . '/tools/php74-compat-check.php'; + $dir = sys_get_temp_dir() . '/phpmax-php74-compat-' . getmypid() . '-' . mt_rand(); + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Unable to create PHP 7.4 compatibility fixture dir'); + } + + try { + $good = $dir . '/good.php'; + file_put_contents($good, " null }; }\n" + . "\$object?->method();\n" + . "str_contains('abc', 'a');\n"; + file_put_contents($bad, $badSource); + list($badExit, $badOutput) = Php74CompatCheckTestHelper::run($tool, $bad); + $assertSame(1, $badExit, 'PHP 8+ fixture must fail compatibility check'); + foreach ([ + 'PHP attributes require PHP 8.0+', + 'native enum declarations require PHP 8.1+', + 'constructor property promotion requires PHP 8.0+', + 'union parameter types require PHP 8.0+', + 'this return type is not available in PHP 7.4', + 'match expressions require PHP 8.0+', + 'nullsafe operator requires PHP 8.0+', + 'called function is not available in PHP 7.4', + ] as $expected) { + $assert(strpos($badOutput, $expected) !== false, 'Compatibility checker output must include: ' . $expected); + } + } finally { + Php74CompatCheckTestHelper::removeTree($dir); + } +}; diff --git a/tests-php/Support/ReleaseBuildTest.php b/tests-php/Support/ReleaseBuildTest.php new file mode 100644 index 0000000..057264b --- /dev/null +++ b/tests-php/Support/ReleaseBuildTest.php @@ -0,0 +1,139 @@ +isDir() && !$file->isLink()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + @rmdir($path); + }; + + $dryRunOutput = []; + $dryRunExit = 1; + exec($php . ' ' . $tool . ' --dry-run', $dryRunOutput, $dryRunExit); + $assertSame(0, $dryRunExit, 'release dry-run must succeed'); + $spec = json_decode(implode("\n", $dryRunOutput), true); + $assert(is_array($spec), 'release dry-run must return JSON'); + $assertSame(false, $spec['vendor_included']); + $assertSame([], $spec['runtime_packages']); + $assert(in_array('autoload.php', $spec['files'], true)); + $assert(in_array('composer.json', $spec['files'], true)); + $assert(in_array('src/PHPMax/Client.php', $spec['files'], true)); + $assert(in_array('src/PHPMax/Runtime/App.php', $spec['files'], true)); + $assert(in_array('docs/phpmax/README.md', $spec['files'], true)); + $assert(!in_array('src/pymax/app.py', $spec['files'], true)); + $assert(!in_array('tests-php/bootstrap.php', $spec['files'], true)); + + $readme = (string) file_get_contents($repoRoot . '/README.md'); + $assert(strpos($readme, '# PHPMax') !== false, 'root README must document PHPMax'); + $assert(strpos($readme, 'composer require varyagnord/phpmax') !== false, 'root README must include PHP installation path'); + $assert(strpos($readme, 'pip install') === false, 'root README must not ship Python install instructions'); + + $checkOutput = []; + $checkExit = 1; + exec($php . ' ' . $tool . ' --check', $checkOutput, $checkExit); + $assertSame(0, $checkExit, 'release check must validate manifest without building ZIP'); + $assertSame('Release spec is valid.', implode("\n", $checkOutput)); + + $conflictOutput = []; + $conflictExit = 0; + exec($php . ' ' . $tool . ' --check --dry-run 2>&1', $conflictOutput, $conflictExit); + $assertSame(2, $conflictExit, 'release builder must reject conflicting check/dry-run modes'); + $assert(strpos(implode("\n", $conflictOutput), 'cannot be used together') !== false, 'release builder conflict error must be diagnostic'); + + if (!class_exists('ZipArchive') && trim((string) shell_exec('command -v zip 2>/dev/null')) === '') { + $assert(true, 'No ZIP backend available; archive build check skipped'); + return; + } + if (trim((string) shell_exec('command -v unzip 2>/dev/null')) === '') { + $assert(true, 'No unzip command available; archive content check skipped'); + return; + } + + $dir = sys_get_temp_dir() . '/phpmax-release-test-' . getmypid() . '-' . mt_rand(); + $zipPath = $dir . '/phpmax-test.zip'; + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Unable to create release test directory'); + } + + try { + $buildOutput = []; + $buildExit = 1; + exec($php . ' ' . $tool . ' --output=' . escapeshellarg($zipPath), $buildOutput, $buildExit); + $assertSame(0, $buildExit, 'release archive build must succeed'); + $assert(is_file($zipPath), 'release archive must exist'); + + $listOutput = []; + $listExit = 1; + exec('unzip -Z1 ' . escapeshellarg($zipPath), $listOutput, $listExit); + $assertSame(0, $listExit, 'release archive listing must succeed'); + $assert(in_array('autoload.php', $listOutput, true)); + $assert(in_array('composer.json', $listOutput, true)); + $assert(in_array('src/PHPMax/Client.php', $listOutput, true)); + $assert(in_array('docs/phpmax/README.md', $listOutput, true)); + $assert(!in_array('src/pymax/app.py', $listOutput, true)); + $assert(!in_array('tests-php/bootstrap.php', $listOutput, true)); + + $extractDir = $dir . '/extract'; + $extractOutput = []; + $extractExit = 1; + exec('unzip -q ' . escapeshellarg($zipPath) . ' -d ' . escapeshellarg($extractDir), $extractOutput, $extractExit); + $assertSame(0, $extractExit, 'release archive extraction must succeed'); + $archiveReadme = (string) file_get_contents($extractDir . '/README.md'); + $assert(strpos($archiveReadme, '# PHPMax') !== false, 'release README must document PHPMax'); + $assert(strpos($archiveReadme, 'pip install') === false, 'release README must not document Python installation'); + $smokeCode = 'require ' . var_export($extractDir . '/autoload.php', true) . ';' + . '$dir = sys_get_temp_dir() . "/phpmax-release-smoke-" . getmypid();' + . 'if (!is_dir($dir) && !mkdir($dir, 0700, true)) { exit(2); }' + . 'register_shutdown_function(static function () use ($dir): void { foreach (glob($dir . "/*") ?: [] as $path) { @unlink($path); } @rmdir($dir); });' + . '$store = new PHPMax\\Session\\JsonFileSessionStore($dir, "session.json");' + . '$store->saveSession(new PHPMax\\Session\\SessionInfo(["token" => "token", "deviceId" => "device", "phone" => "+10000000000"]));' + . 'if (!$store->loadSession() instanceof PHPMax\\Session\\SessionInfo) { exit(3); }' + . '$store->deleteAllSessions();' + . '$store->close();' + . '$options = new PHPMax\\Config\\ClientOptions(["token" => "token", "store" => $store]);' + . 'if ($options->token !== "token") { exit(4); }' + . '$client = new PHPMax\\Client($options);' + . 'if (!$client instanceof PHPMax\\Client) { exit(5); }' + . '$web = new PHPMax\\WebClient(new PHPMax\\Config\\ClientOptions(["token" => "token", "workDir" => $dir, "sessionName" => "web-session.json"]));' + . 'if (!$web instanceof PHPMax\\WebClient) { exit(6); }' + . '$tcp = new PHPMax\\Transport\\TcpTransport("api.oneme.ru", 443, true, 0.001);' + . '$ws = new PHPMax\\Transport\\WebSocketTransport("wss://ws-api.oneme.ru/websocket", 0.001);' + . 'if (!$tcp instanceof PHPMax\\Transport\\TcpTransport || !$ws instanceof PHPMax\\Transport\\WebSocketTransport) { exit(7); }' + . '$file = PHPMax\\Files\\File::fromRaw("abc", "a.txt");' + . 'if ($file->size() !== 3 || $file->name() !== "a.txt") { exit(8); }' + . '$photo = PHPMax\\Files\\Photo::fromRaw("abc", "avatar.png");' + . 'if ($photo->validatePhoto()[0] !== "png") { exit(9); }' + . 'echo "release autoload ok\n";'; + $smokeOutput = []; + $smokeExit = 1; + exec($php . ' -r ' . escapeshellarg($smokeCode), $smokeOutput, $smokeExit); + $assertSame(0, $smokeExit, 'release fallback autoload must load runtime classes from extracted archive'); + $assertSame('release autoload ok', implode("\n", $smokeOutput)); + } finally { + if (isset($extractDir)) { + $removeTree($extractDir); + } + @unlink($zipPath); + @rmdir($dir); + } +}; diff --git a/tests-php/Telemetry/TelemetryServiceTest.php b/tests-php/Telemetry/TelemetryServiceTest.php new file mode 100644 index 0000000..a90a5b1 --- /dev/null +++ b/tests-php/Telemetry/TelemetryServiceTest.php @@ -0,0 +1,292 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake telemetry chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class TelemetryTestStore implements SessionStoreInterface +{ + /** @var SessionInfo|null */ + public $session; + + public function __construct(?SessionInfo $session = null) + { + $this->session = $session; + } + + public function saveSession(SessionInfo $sessionInfo): void + { + $this->session = $sessionInfo; + } + + public function updateToken(string $oldToken, string $newToken): void + { + if ($this->session !== null && $this->session->token === $oldToken) { + $this->session = new SessionInfo([ + 'token' => $newToken, + 'deviceId' => $this->session->deviceId, + 'phone' => $this->session->phone, + 'mtInstanceId' => $this->session->mtInstanceId, + 'sync' => $this->session->sync, + ]); + } + } + + public function loadSession(): ?SessionInfo + { + return $this->session; + } + + public function loadSessionByDeviceId(string $deviceId): ?SessionInfo + { + return $this->session !== null && $this->session->deviceId === $deviceId ? $this->session : null; + } + + public function loadSessionByPhone(string $phone): ?SessionInfo + { + return $this->session !== null && $this->session->phone === $phone ? $this->session : null; + } + + public function deleteSession(string $token): void + { + if ($this->session !== null && $this->session->token === $token) { + $this->session = null; + } + } + + public function deleteAllSessions(): void + { + $this->session = null; + } + + public function close(): void + { + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $frameChunks = static function (int $opcode, int $seq, array $payload = []) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame( + TcpProtocol::VERSION, + $opcode, + $seq, + $payload, + Command::RESPONSE + )); + + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (TelemetryTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $builder = new TelemetryPayloadBuilder(); + $login = $builder->login(100, 44)->toArray(); + $assertSame('PERF', $login['type']); + $assertSame('login', $login['event']); + $assertSame(100, $login['userId']); + $assertSame(44, $login['sessionId']); + $assertSame(2, $login['params']['properties']['connection_type']); + $assertSame(100, $login['params']['errorType']); + + $navigation = $builder->navigation(100, 44, 1, 2, 300, 400, [ + 'source_type' => 9, + 'screen_to' => 99, + ])->toArray(); + $assertSame('NAV', $navigation['type']); + $assertSame('GO', $navigation['event']); + $assertSame(300, $navigation['params']['prev_time']); + $assertSame(99, $navigation['params']['screen_to']); + $assertSame(400, $navigation['params']['action_id']); + $assertSame(1, $navigation['params']['screen_from']); + $assertSame(9, $navigation['params']['source_type']); + + $openChat = $builder->openChat(100, 44)->toArray(); + $assertSame('open_chat_to_render', $openChat['event']); + $assertSame('open_chat_to_render', $openChat['params']['spans'][0]['name']); + $assertSame('messages_list_created', $openChat['params']['spans'][1]['name']); + $assertSame('messages_render', $openChat['params']['spans'][2]['name']); + + $openChats = $builder->openChats(100, 44)->toArray(); + $assertSame('open_chats_to_render', $openChats['event']); + $assertSame('chats_tab_created', $openChats['params']['spans'][1]['name']); + $assertSame('chat_list_render', $openChats['params']['spans'][2]['name']); + + $payload = $builder->toPayload([$builder->login(101, 45)]); + $assertSame(101, $payload['events'][0]['userId']); + $assertSame(45, $payload['events'][0]['sessionId']); + + $serviceTransport = new TelemetryTestTransport($frameChunks(Opcode::LOG, 0, ['ok' => true])); + $serviceManager = new ConnectionManager($serviceTransport, $protocol); + $serviceManager->open(); + $app = new App($serviceManager, new ClientOptions(['clientSessionId' => 44, 'requestTimeout' => 1.0])); + $assert($app->api()->telemetry->sendEvents([$builder->login(100, 44)]), 'Telemetry event must be sent'); + $sentLog = $decodeSent($serviceTransport, 0); + $assertSame(Opcode::LOG, $sentLog->opcode); + $assertSame('login', $sentLog->payload['events'][0]['event']); + $assertSame(100, $sentLog->payload['events'][0]['userId']); + $assert($app->api()->telemetry->sendEvents([]), 'Empty telemetry batch must be a successful no-op'); + $assertSame(1, count($serviceTransport->sent)); + + $failingTransport = new TelemetryTestTransport([]); + $failingManager = new ConnectionManager($failingTransport, $protocol); + $failingManager->open(); + $failingApp = new App($failingManager, new ClientOptions(['requestTimeout' => 1.0])); + $assert(!$failingApp->api()->telemetry->login(100, 44), 'Telemetry failures must not escape service boundary'); + + $autoTransport = new TelemetryTestTransport(array_merge( + $frameChunks(Opcode::SESSION_INIT, 0), + $frameChunks(Opcode::LOGIN, 1, [ + 'profile' => ['contact' => ['id' => 700, 'names' => []]], + 'chats' => [], + 'messages' => [], + 'contacts' => [], + 'token' => 'server-token', + 'time' => 999, + 'config' => ['hash' => 'cfg'], + ]), + $frameChunks(Opcode::LOG, 2, ['ok' => true]) + )); + $autoClient = new Client(new ClientOptions([ + 'token' => 'local-token', + 'phone' => '+79990000000', + 'deviceId' => 'device-token', + 'mtInstanceId' => 'mt-token', + 'clientSessionId' => 55, + 'telemetry' => true, + 'store' => new TelemetryTestStore(), + 'requestTimeout' => 1.0, + ]), new ConnectionManager($autoTransport, $protocol)); + $autoClient->open(); + $assertSame(3, count($autoTransport->sent)); + $assertSame(Opcode::SESSION_INIT, $decodeSent($autoTransport, 0)->opcode); + $assertSame(Opcode::LOGIN, $decodeSent($autoTransport, 1)->opcode); + $autoLog = $decodeSent($autoTransport, 2); + $assertSame(Opcode::LOG, $autoLog->opcode); + $assertSame('login', $autoLog->payload['events'][0]['event']); + $assertSame(700, $autoLog->payload['events'][0]['userId']); + $assertSame(55, $autoLog->payload['events'][0]['sessionId']); + + $profile = new RouteProfile(2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + $rules = new NavigationRules( + ['test' => $profile], + [ + Screen::BACKGROUND => [new ScreenTransition(Screen::CHAT, 1)], + Screen::CHAT => [new ScreenTransition(Screen::CHATS, 1)], + Screen::CHATS => [new ScreenTransition(Screen::BACKGROUND, 1)], + ] + ); + $planner = new NavigationPlanner($rules); + $assertSame(Screen::BACKGROUND, $planner->currentScreen()); + $assertSame($profile, $planner->newProfile()); + $assertSame(Screen::CHAT, $planner->nextScreen($profile)); + $assertSame([Screen::BACKGROUND], $planner->history()); + $planner->resetToBackground(); + $assertSame([], $planner->history()); + + $plannedTransport = new TelemetryTestTransport($frameChunks(Opcode::LOG, 0, ['ok' => true])); + $plannedManager = new ConnectionManager($plannedTransport, $protocol); + $plannedManager->open(); + $plannedApp = new App($plannedManager, new ClientOptions(['clientSessionId' => 88, 'requestTimeout' => 1.0])); + $plannedService = new TelemetryService($plannedApp, new TelemetryPayloadBuilder(), new NavigationPlanner($rules)); + $dialog = Chat::fromArray([ + 'id' => 777, + 'type' => ChatType::DIALOG, + 'status' => 'ACTIVE', + 'owner' => 100, + ]); + $events = $plannedService->plannedNavigationEvents(100, null, $profile, [$dialog]); + $assertSame('GO', $events[0]->event); + $assertSame(Screen::BACKGROUND, $events[0]->params['screen_from']); + $assertSame(Screen::CHAT, $events[0]->params['screen_to']); + $assertSame(1, $events[0]->params['action_id']); + $assertSame(1, $events[0]->params['source_type']); + $assertSame(777, $events[0]->params['source_id']); + $assertSame('open_chat_to_render', $events[1]->event); + $assertSame('GO', $events[2]->event); + $assertSame(Screen::CHAT, $events[2]->params['screen_from']); + $assertSame(Screen::CHATS, $events[2]->params['screen_to']); + $assertSame(2, $events[2]->params['action_id']); + $assertSame(5, $events[2]->params['source_type']); + $assertSame(1, $events[2]->params['source_id']); + $assertSame(2, $events[2]->params['tab_config']); + + $plannedService->resetNavigation(); + $assert($plannedService->sendPlannedNavigation(100, 88, $profile, [$dialog])); + $plannedLog = $decodeSent($plannedTransport, 0); + $assertSame(Opcode::LOG, $plannedLog->opcode); + $assertSame('GO', $plannedLog->payload['events'][0]['event']); + $assertSame('open_chat_to_render', $plannedLog->payload['events'][1]['event']); + $assertSame(88, $plannedLog->payload['events'][0]['sessionId']); +}; diff --git a/tests-php/Transport/ProxyConfigTest.php b/tests-php/Transport/ProxyConfigTest.php new file mode 100644 index 0000000..310b5b1 --- /dev/null +++ b/tests-php/Transport/ProxyConfigTest.php @@ -0,0 +1,119 @@ +setAccessible(true); + + return $reflection->getValue($object); + }; + + $assertSame(null, ProxyConfig::fromUrl(null)); + $assertSame(null, ProxyConfig::fromUrl('')); + + $http = ProxyConfig::fromUrl('http://user:pass@proxy.local:3128'); + $assertSame('http', $http->scheme()); + $assertSame('proxy.local', $http->host()); + $assertSame(3128, $http->port()); + $assertSame('proxy.local:3128', $http->authority()); + $assertSame('tcp://proxy.local:3128', $http->streamTarget()); + $assertSame('Proxy-Authorization: Basic ' . base64_encode('user:pass'), $http->basicAuthorizationHeader()); + $assertSame('http://proxy.local:3128', $http->curlProxyUrl()); + $assertSame('user:pass', $http->curlUserPassword()); + $assert($http->isHttpConnect(), 'HTTP proxy must use CONNECT for TCP/WebSocket'); + + $https = ProxyConfig::fromUrl('https://proxy.local'); + $assertSame(443, $https->port()); + $assertSame('tls://proxy.local:443', $https->streamTarget()); + + $socks = ProxyConfig::fromUrl('socks5://u:p@127.0.0.1'); + $assertSame('socks5', $socks->scheme()); + $assertSame(1080, $socks->port()); + $assertSame('u', $socks->username()); + $assertSame('p', $socks->password()); + $assert($socks->isSocks5(), 'SOCKS5 proxy must be recognized'); + + $options = new ClientOptions(['proxy' => 'http://proxy.local:8080']); + $assertSame('http://proxy.local:8080', $options->proxy); + + $assertThrows(PHPMaxException::class, static function (): void { + new ClientOptions(['host' => '']); + }, 'Empty API host must fail fast'); + + $assertThrows(PHPMaxException::class, static function (): void { + new ClientOptions(['port' => 0]); + }, 'Zero API port must fail fast'); + + $assertThrows(PHPMaxException::class, static function (): void { + new ClientOptions(['port' => 70000]); + }, 'Out-of-range API port must fail fast'); + + $assertThrows(ProtocolException::class, static function (): void { + ProxyConfig::fromUrl('ftp://proxy.local:21'); + }, 'Unsupported proxy scheme must fail fast'); + + $assertThrows(ProtocolException::class, static function (): void { + new TcpTransport('', 443); + }, 'TCP transport must reject empty host'); + + $assertThrows(ProtocolException::class, static function (): void { + new TcpTransport('api.oneme.ru', 0); + }, 'TCP transport must reject invalid port'); + + $assertThrows(ProtocolException::class, static function (): void { + new TcpTransport('api.oneme.ru', 443, true, 1.0, 'ftp://proxy.local:21'); + }, 'TCP transport must validate proxy URL on construction'); + + $assertThrows(ProtocolException::class, static function (): void { + (new WebSocketTransport('ws://example.test:0/websocket'))->connect(); + }, 'WebSocket transport must reject invalid URL port before connecting'); + + $assertThrows(ProtocolException::class, static function (): void { + new WebSocketTransport('wss://ws-api.oneme.ru/websocket', 1.0, 'https://web.max.ru', 'ftp://proxy.local:21'); + }, 'WebSocket transport must validate proxy URL on construction'); + + $assertThrows(ProtocolException::class, static function () use ($http): void { + (new ProxyConnector($http))->connect('', 443, false, 1.0); + }, 'Proxy connector must reject empty target host before connecting'); + + $assertThrows(ProtocolException::class, static function () use ($http): void { + (new ProxyConnector($http))->connect('api.oneme.ru', 0, false, 1.0); + }, 'Proxy connector must reject invalid target port before connecting'); + + $assertThrows(ProtocolException::class, static function (): void { + new NativeHttpUploader(1.0, 'ftp://proxy.local:21'); + }, 'HTTP uploader must validate proxy URL on construction'); + + $assertSame(0.001, $privateFloat(new TcpTransport('api.oneme.ru', 443, true, -5.0), 'connectTimeout')); + $assertSame(0.001, $privateFloat(new WebSocketTransport('wss://ws-api.oneme.ru/websocket', -5.0), 'connectTimeout')); + $assertSame(0.001, $privateFloat(new NativeHttpUploader(-5.0), 'timeout')); + + if (function_exists('stream_socket_pair')) { + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + if ($pair !== false) { + fwrite($pair[1], 'ok'); + $transport = new TcpTransport('api.oneme.ru', 443); + $streamProperty = new ReflectionProperty(TcpTransport::class, 'stream'); + $streamProperty->setAccessible(true); + $streamProperty->setValue($transport, $pair[0]); + $assertSame('ok', $transport->recv(2, -5.0), 'TCP recv must normalize direct negative read timeouts'); + fclose($pair[1]); + $transport->close(); + } else { + $assert(true, 'stream_socket_pair unavailable for direct TCP recv timeout fixture'); + } + } else { + $assert(true, 'stream_socket_pair unavailable for direct TCP recv timeout fixture'); + } +}; diff --git a/tests-php/Transport/ProxyConnectorTest.php b/tests-php/Transport/ProxyConnectorTest.php new file mode 100644 index 0000000..891a571 --- /dev/null +++ b/tests-php/Transport/ProxyConnectorTest.php @@ -0,0 +1,204 @@ + 0) { + usleep($responseDelayMicros); + } + self::writeAll($conn, "HTTP/1.1 200 Connection Established\r\nProxy-Agent: PHPMaxTest\r\n\r\n"); + + return self::echoTunnel($conn); + }); + } + + /** + * @return array{0: string, 1: int} + */ + public static function startSocks5(string $expectedHost, int $expectedPort, ?string $expectedUser, ?string $expectedPassword): array + { + return self::start(static function ($conn) use ($expectedHost, $expectedPort, $expectedUser, $expectedPassword): int { + $greeting = self::readExact($conn, 2); + if ($greeting[0] !== "\x05") { + return 20; + } + $methodCount = ord($greeting[1]); + $methods = self::readExact($conn, $methodCount); + $wantAuth = $expectedUser !== null; + if ($wantAuth) { + if (strpos($methods, "\x02") === false) { + return 21; + } + self::writeAll($conn, "\x05\x02"); + $authHead = self::readExact($conn, 2); + if ($authHead[0] !== "\x01") { + return 22; + } + $user = self::readExact($conn, ord($authHead[1])); + $passLen = ord(self::readExact($conn, 1)); + $password = self::readExact($conn, $passLen); + if ($user !== (string) $expectedUser || $password !== (string) $expectedPassword) { + return 23; + } + self::writeAll($conn, "\x01\x00"); + } else { + if (strpos($methods, "\x00") === false) { + return 24; + } + self::writeAll($conn, "\x05\x00"); + } + + $requestHead = self::readExact($conn, 5); + if ($requestHead[0] !== "\x05" || $requestHead[1] !== "\x01" || $requestHead[2] !== "\x00" || $requestHead[3] !== "\x03") { + return 25; + } + $hostLength = ord($requestHead[4]); + $host = self::readExact($conn, $hostLength); + $port = unpack('nport', self::readExact($conn, 2)); + if ($host !== $expectedHost || (int) $port['port'] !== $expectedPort) { + return 26; + } + + self::writeAll($conn, "\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00"); + + return self::echoTunnel($conn); + }); + } + + /** + * @return array{0: string, 1: int} + */ + private static function start(callable $handler): array + { + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + if (!is_resource($server)) { + throw new RuntimeException('Failed to create proxy loopback server: ' . $errstr); + } + + $address = (string) stream_socket_get_name($server, false); + $pid = pcntl_fork(); + if ($pid === -1) { + fclose($server); + throw new RuntimeException('Failed to fork proxy loopback server'); + } + + if ($pid === 0) { + $conn = @stream_socket_accept($server, 5.0); + fclose($server); + if (!is_resource($conn)) { + exit(2); + } + stream_set_timeout($conn, 5); + $status = (int) call_user_func($handler, $conn); + fclose($conn); + exit($status); + } + + fclose($server); + + return [$address, $pid]; + } + + private static function echoTunnel($conn): int + { + $payload = self::readExact($conn, 4); + if ($payload !== 'ping') { + return 30; + } + self::writeAll($conn, 'pong'); + + return 0; + } + + private static function readHeaders($conn): string + { + $headers = ''; + while (strpos($headers, "\r\n\r\n") === false) { + $headers .= self::readExact($conn, 1); + if (strlen($headers) > 16384) { + return ''; + } + } + + return $headers; + } + + private static function readExact($conn, int $length): string + { + $data = ''; + while (strlen($data) < $length) { + $chunk = fread($conn, $length - strlen($data)); + if ($chunk === false || $chunk === '') { + return $data; + } + $data .= $chunk; + } + + return $data; + } + + private static function writeAll($conn, string $data): void + { + $written = 0; + $length = strlen($data); + while ($written < $length) { + $chunk = fwrite($conn, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + return; + } + $written += $chunk; + } + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + if (!function_exists('pcntl_fork') || !function_exists('stream_socket_server')) { + $assert(true, 'ProxyConnector loopback tests require pcntl and sockets'); + return; + } + + $exerciseTunnel = static function (string $proxyUrl, string $targetHost, int $targetPort, int $pid, float $timeout = 2.0) use ($assertSame): void { + $stream = (new ProxyConnector(ProxyConfig::fromUrl($proxyUrl)))->connect($targetHost, $targetPort, false, $timeout); + fwrite($stream, 'ping'); + $reply = fread($stream, 4); + fclose($stream); + pcntl_waitpid($pid, $status); + $assertSame('pong', $reply); + $assertSame(0, pcntl_wexitstatus($status), 'Proxy loopback server must receive expected handshake and tunnel bytes'); + }; + + list($httpAddress, $httpPid) = ProxyConnectorLoopbackServer::startHttpConnect( + 'max.example:443', + 'Basic ' . base64_encode('user:pass'), + 50000 + ); + $exerciseTunnel('http://user:pass@' . $httpAddress, 'max.example', 443, $httpPid, -5.0); + + list($socksAddress, $socksPid) = ProxyConnectorLoopbackServer::startSocks5('max.example', 5228, null, null); + $exerciseTunnel('socks5://' . $socksAddress, 'max.example', 5228, $socksPid); + + list($socksAuthAddress, $socksAuthPid) = ProxyConnectorLoopbackServer::startSocks5('max.example', 443, 'u', 'p'); + $exerciseTunnel('socks5://u:p@' . $socksAuthAddress, 'max.example', 443, $socksAuthPid); +}; diff --git a/tests-php/Transport/WebSocketTransportTest.php b/tests-php/Transport/WebSocketTransportTest.php new file mode 100644 index 0000000..427547e --- /dev/null +++ b/tests-php/Transport/WebSocketTransportTest.php @@ -0,0 +1,222 @@ +setAccessible(true); + $method->invoke($transport, $headers, $key); + } + + public static function acceptKey(string $key): string + { + return base64_encode(sha1($key . self::GUID, true)); + } + + /** + * @return array{0: WebSocketTransport, 1: resource} + */ + public static function transportWithInput(string $input): array + { + if (!function_exists('stream_socket_pair')) { + throw new RuntimeException('stream_socket_pair is required for WebSocketTransport tests'); + } + + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + if ($pair === false) { + throw new RuntimeException('Failed to create socket pair for WebSocketTransport tests'); + } + + stream_set_blocking($pair[0], true); + stream_set_blocking($pair[1], true); + self::writeAll($pair[1], $input); + + $transport = new WebSocketTransport('ws://example.test/websocket', 1.0); + $property = new ReflectionProperty(WebSocketTransport::class, 'stream'); + $property->setAccessible(true); + $property->setValue($transport, $pair[0]); + + return [$transport, $pair[1]]; + } + + public static function serverFrame(int $opcode, string $payload, bool $fin = true, int $rsv = 0): string + { + $first = ($fin ? 0x80 : 0x00) | ($rsv & 0x70) | ($opcode & 0x0F); + $length = strlen($payload); + if ($length <= 125) { + return chr($first) . chr($length) . $payload; + } + if ($length <= 0xFFFF) { + return chr($first) . chr(126) . pack('n', $length) . $payload; + } + + return chr($first) . chr(127) . pack('N2', intdiv($length, 4294967296), $length % 4294967296) . $payload; + } + + public static function maskedServerFrame(int $opcode, string $payload): string + { + $mask = "\x01\x02\x03\x04"; + $masked = ''; + $length = strlen($payload); + for ($i = 0; $i < $length; $i++) { + $masked .= $payload[$i] ^ $mask[$i % 4]; + } + + return chr(0x80 | ($opcode & 0x0F)) . chr(0x80 | $length) . $mask . $masked; + } + + public static function extendedLengthFrame(int $opcode, int $length, string $payload = '', bool $fin = true): string + { + $first = ($fin ? 0x80 : 0x00) | ($opcode & 0x0F); + if ($length <= 0xFFFF) { + return chr($first) . chr(126) . pack('n', $length) . $payload; + } + + return chr($first) . chr(127) . pack('N2', intdiv($length, 4294967296), $length % 4294967296) . $payload; + } + + private static function writeAll($stream, string $data): void + { + $written = 0; + $length = strlen($data); + while ($written < $length) { + $chunk = fwrite($stream, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + throw new RuntimeException('Failed to write test WebSocket bytes'); + } + $written += $chunk; + } + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $key = base64_encode(str_repeat('a', 16)); + $accept = WsTransportHardeningHarness::acceptKey($key); + WsTransportHardeningHarness::validateHandshake( + "HTTP/1.1 101 Switching Protocols\r\n" . + "Upgrade: WebSocket\r\n" . + "Connection: keep-alive, Upgrade\r\n" . + 'Sec-WebSocket-Accept: ' . $accept . "\r\n\r\n", + $key + ); + $assert(true, 'Valid WebSocket handshake response must be accepted'); + + $assertThrows(ProtocolException::class, static function () use ($key, $accept): void { + WsTransportHardeningHarness::validateHandshake( + "HTTP/1.1 200 101 Not Switching\r\n" . + "Upgrade: websocket\r\n" . + "Connection: Upgrade\r\n" . + 'Sec-WebSocket-Accept: ' . $accept . "\r\n\r\n", + $key + ); + }, 'Handshake status must be an actual HTTP 101 response'); + + $assertThrows(ProtocolException::class, static function () use ($key, $accept): void { + WsTransportHardeningHarness::validateHandshake( + "HTTP/1.1 101 Switching Protocols\r\n" . + "Connection: Upgrade\r\n" . + 'Sec-WebSocket-Accept: ' . $accept . "\r\n\r\n", + $key + ); + }, 'Handshake must require Upgrade: websocket'); + + $assertThrows(ProtocolException::class, static function () use ($key, $accept): void { + WsTransportHardeningHarness::validateHandshake( + "HTTP/1.1 101 Switching Protocols\r\n" . + "Upgrade: websocket\r\n" . + "Connection: keep-alive\r\n" . + 'Sec-WebSocket-Accept: ' . $accept . "\r\n\r\n", + $key + ); + }, 'Handshake must require Connection token Upgrade'); + + list($fragmented) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, 'hel', false) . + WsTransportHardeningHarness::serverFrame(0x0, 'lo', true) + ); + $assertSame('hello', $fragmented->recvMessage(1.0), 'Valid fragmented text message must be reassembled'); + + list($negativeTimeout) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, 'ok') + ); + $assertSame('ok', $negativeTimeout->recvMessage(-5.0), 'WebSocket recvMessage must normalize direct negative read timeouts'); + + list($splitUtf8) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, "\xD0", false) . + WsTransportHardeningHarness::serverFrame(0x0, "\x9F", true) + ); + $assertSame("\xD0\x9F", $splitUtf8->recvMessage(1.0), 'Valid UTF-8 split across fragments must be accepted after message reassembly'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, "\xC3\x28") + ); + $transport->recvMessage(1.0); + }, 'Invalid UTF-8 WebSocket text messages must fail before JSON protocol decode'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::maskedServerFrame(0x1, 'no') + ); + $transport->recvMessage(1.0); + }, 'Server WebSocket frames must not be masked'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x9, 'ping', false) + ); + $transport->recvMessage(1.0); + }, 'Control frames must not be fragmented'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::extendedLengthFrame(0x9, 126, str_repeat('x', 126)) + ); + $transport->recvMessage(1.0); + }, 'Control frames must stay within 125 bytes'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x0, 'dangling') + ); + $transport->recvMessage(1.0); + }, 'Continuation frame without an active fragmented message must fail'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, 'half', false) . + WsTransportHardeningHarness::serverFrame(0x1, 'new', true) + ); + $transport->recvMessage(1.0); + }, 'New data frame before fragmented message completion must fail'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x2, '{"opcode":1}') + ); + $transport->recvMessage(1.0); + }, 'Binary WebSocket messages must fail because Max WebSocket protocol is JSON text'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::serverFrame(0x1, 'rsv', true, 0x40) + ); + $transport->recvMessage(1.0); + }, 'Reserved bits must fail when no WebSocket extensions are negotiated'); + + $assertThrows(ProtocolException::class, static function (): void { + list($transport) = WsTransportHardeningHarness::transportWithInput( + WsTransportHardeningHarness::extendedLengthFrame(0x1, 16777217) + ); + $transport->recvMessage(1.0); + }, 'Oversized WebSocket frames must fail before reading payload bytes'); +}; diff --git a/tests-php/Unit/ModelHydrationTest.php b/tests-php/Unit/ModelHydrationTest.php new file mode 100644 index 0000000..03bd6f5 --- /dev/null +++ b/tests-php/Unit/ModelHydrationTest.php @@ -0,0 +1,419 @@ + 100, + 'message' => [ + 'id' => '42', + 'time' => 123456, + 'type' => 'USER', + 'text' => 'hello', + 'attaches' => [ + ['_type' => AttachmentType::PHOTO, 'photoId' => 'photo-1', 'token' => 'token-1'], + ['_type' => AttachmentType::FILE, 'fileId' => 77, 'name' => 'report.pdf', 'size' => 1024, 'token' => 'file-token'], + ['_type' => AttachmentType::VIDEO, 'videoId' => 88, 'height' => 720, 'width' => 1280, 'duration' => 5, 'previewData' => 'preview', 'thumbnail' => 'thumb', 'token' => 'video-token', 'videoType' => 1], + ['_type' => AttachmentType::AUDIO, 'audio_id' => 12, 'duration' => 3, 'wave' => 'wave', 'transcription_status' => TranscriptionStatus::SUCCESS, 'url' => 'audio-url', 'token' => 'audio-token'], + ['_type' => AttachmentType::CONTACT, 'contact_id' => 700, 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'name' => 'Ada L.', 'photo_url' => 'photo-url'], + ['_type' => AttachmentType::STICKER, 'sticker_id' => 45, 'set_id' => 4, 'sticker_type' => 'STATIC', 'lottie_url' => 'lottie-url', 'author_type' => 'USER', 'url' => 'sticker-url', 'tags' => ['math'], 'width' => 64, 'height' => 48, 'time' => 9, 'audio' => false], + ['_type' => AttachmentType::CONTROL, 'event' => 'chat_created'], + ['_type' => AttachmentType::INLINE_KEYBOARD, 'keyboard' => ['buttons' => [['text' => 'Open']]]], + ['_type' => AttachmentType::SHARE, 'url' => 'https://example.test', 'title' => 'Example', 'description' => 'Preview', 'image' => ['url' => 'image-url']], + ['_type' => AttachmentType::CALL, 'duration' => 30, 'conversation_id' => 'call-1', 'contact_ids' => [1, 2]], + ['type' => AttachmentType::CONTROL, 'event' => 'type_key_control'], + ['_type' => 'FUTURE_KIND', 'raw' => true], + ], + ], + 'unread' => 2, + 'mark' => 777, + ]); + + $assertSame(42, $message->id); + $assertSame(100, $message->chatId); + $assertSame('hello', $message->text); + $assertSame(2, $message->unread); + $assert($message->attaches[0] instanceof PhotoAttachment, 'Known photo attachment must hydrate to PhotoAttachment'); + $assertSame('token-1', $message->attaches[0]->photoToken); + $malformedPhoto = PhotoAttachment::fromArray([ + '_type' => AttachmentType::PHOTO, + 'baseUrl' => ['bad' => 'shape'], + 'photoId' => 12345, + 'token' => ['bad' => 'token'], + 'previewData' => true, + ]); + $assertSame('', $malformedPhoto->baseUrl); + $assertSame('12345', $malformedPhoto->photoId); + $assertSame('', $malformedPhoto->photoToken); + $assertSame('1', $malformedPhoto->previewData); + $assert($message->attaches[1] instanceof FileAttachment, 'Known file attachment must hydrate to FileAttachment'); + $assertSame('file-token', $message->attaches[1]->token); + $assert($message->attaches[2] instanceof VideoAttachment, 'Known video attachment must hydrate to VideoAttachment'); + $assertSame(1280, $message->attaches[2]->width); + $assertSame('video-token', $message->attaches[2]->token); + $assert($message->attaches[3] instanceof AudioAttachment, 'Known audio attachment must hydrate to AudioAttachment'); + $assertSame(12, $message->attaches[3]->audioId); + $assertSame(TranscriptionStatus::SUCCESS, $message->attaches[3]->transcriptionStatus); + $assertSame('audio-token', $message->attaches[3]->token); + $assertSame(12, $message->attaches[3]->toArray()['audioId']); + $assertSame(TranscriptionStatus::SUCCESS, $message->attaches[3]->toArray()['transcriptionStatus']); + $assert($message->attaches[4] instanceof ContactAttachment, 'Known contact attachment must hydrate to ContactAttachment'); + $assertSame(700, $message->attaches[4]->contactId); + $assertSame('Ada', $message->attaches[4]->firstName); + $assertSame('photo-url', $message->attaches[4]->photoUrl); + $assert($message->attaches[5] instanceof StickerAttachment, 'Known sticker attachment must hydrate to StickerAttachment'); + $assertSame(45, $message->attaches[5]->stickerId); + $assertSame(4, $message->attaches[5]->setId); + $assertSame('STATIC', $message->attaches[5]->stickerType); + $assertSame(false, $message->attaches[5]->audio); + $assert($message->attaches[6] instanceof ControlAttachment, 'Known control attachment must hydrate to ControlAttachment'); + $assertSame('chat_created', $message->attaches[6]->event); + $assert($message->attaches[7] instanceof InlineKeyboardAttachment, 'Known inline keyboard attachment must hydrate to InlineKeyboardAttachment'); + $assertSame('Open', $message->attaches[7]->keyboard['buttons'][0]['text']); + $assert($message->attaches[8] instanceof ShareAttachment, 'Known share attachment must hydrate to ShareAttachment'); + $assertSame('Example', $message->attaches[8]->title); + $assertSame('image-url', $message->attaches[8]->image['url']); + $assert($message->attaches[9] instanceof CallAttachment, 'Known call attachment must hydrate to CallAttachment'); + $assertSame('call-1', $message->attaches[9]->conversationId); + $assertSame([1, 2], $message->attaches[9]->contactIds); + $assertSame('call-1', $message->attaches[9]->toArray()['conversationId']); + $assert($message->attaches[10] instanceof ControlAttachment, 'Known attachment type key must hydrate through AttachmentFactory'); + $assertSame('type_key_control', $message->attaches[10]->event); + $assert($message->attaches[11] instanceof UnknownAttachment, 'Unknown attachment must stay available'); + $assertSame('FUTURE_KIND', $message->attaches[11]->type); + $assertSame(true, $message->attaches[11]->extra()['raw']); + $assertThrows(ValidationException::class, static function (): void { + UnknownAttachment::fromArray(['_type' => AttachmentType::PHOTO]); + }, 'Known attachment type must not hydrate as UnknownAttachment'); + + $chat = Chat::fromArray([ + 'id' => 5, + 'type' => 'CHAT', + 'status' => 'ACTIVE', + 'owner' => 1, + 'access' => AccessType::PRIVATE, + 'new_messages' => 4, + 'participants_count' => 8, + 'base_raw_icon_url' => 'raw-icon', + 'pinnedMessage' => [ + 'id' => 9, + 'chatId' => 5, + 'time' => 123, + 'type' => 'USER', + 'text' => 'pin', + ], + ]); + $assert($chat->pinnedMessage instanceof Message); + $assertSame(9, $chat->pinnedMessage->id); + $assertSame(AccessType::PRIVATE, $chat->access); + $assertSame(4, $chat->newMessages); + $assertSame(8, $chat->participantsCount); + $assertSame('raw-icon', $chat->baseRawIconUrl); + $assertSame(AccessType::PRIVATE, $chat->toArray()['access']); + + $profile = Profile::fromArray([ + 'contact' => [ + 'id' => 9, + 'names' => [['name' => 'Test User', 'type' => 'NICK']], + ], + 'unknownProfileField' => 'kept', + ]); + $assert($profile->contact instanceof User); + $assertSame(null, $profile->profileOptions); + $assert(!array_key_exists('profileOptions', $profile->toArray()), 'Absent profileOptions must stay null and be omitted from payload'); + $assertSame('Test User', $profile->contact->names[0]->name); + $assertSame('kept', $profile->extra()['unknownProfileField']); + $assertSame('kept', $profile->toArray()['unknownProfileField']); + + $apiPayload = SendMessagePayload::fromArray([ + 'chatId' => 77, + 'notify' => true, + 'message' => [ + 'text' => 'hello', + 'cid' => 123, + 'futureNested' => 'must-not-leak', + ], + 'futureTopLevel' => 'must-not-leak', + ]); + $assert($apiPayload->message instanceof SendMessagePayloadMessage, 'Nested API payload model must hydrate'); + $assertSame([], $apiPayload->extra(), 'API payload models must ignore unknown top-level fields like PyMax API BaseModel'); + $assertSame([], $apiPayload->message->extra(), 'Nested API payload models must ignore unknown fields'); + $assertSame([ + 'chatId' => 77, + 'message' => [ + 'text' => 'hello', + 'cid' => 123, + 'elements' => [], + 'attaches' => [], + ], + 'notify' => true, + ], $apiPayload->toArray()); + + $sessionInfo = SessionInfo::fromArray([ + 'token' => 'token', + 'deviceId' => 'device', + 'phone' => '+10000000000', + 'debugSecret' => 'must-not-persist', + ]); + $assertSame([], $sessionInfo->extra(), 'SessionInfo must ignore unknown storage keys like PyMax session BaseModel'); + $assert(!array_key_exists('debugSecret', $sessionInfo->toArray()), 'SessionInfo must not persist unknown top-level keys'); + + $sync = (new SyncOverrides(['chatsSync' => 10]))->resolve(new SyncState([ + 'chatsSync' => 1, + 'contactsSync' => 2, + 'draftsSync' => 3, + 'presenceSync' => 4, + 'configHash' => 'hash', + ])); + $assertSame(10, $sync->chatsSync); + $assertSame(2, $sync->contactsSync); + $assertSame('hash', $sync->configHash); + $assertSame([ + 'chats_sync' => 10, + 'contacts_sync' => 2, + 'drafts_sync' => 3, + 'presence_sync' => 4, + 'config_hash' => 'hash', + ], $sync->toArray()); + + $snakeSync = SyncState::fromArray([ + 'chats_sync' => 21, + 'contacts_sync' => 22, + 'drafts_sync' => 23, + 'presence_sync' => 24, + 'config_hash' => 'snake-hash', + ]); + $assertSame(21, $snakeSync->chatsSync); + $assertSame('snake-hash', $snakeSync->configHash); + $assertSame(['config_hash' => 'override-hash'], (new SyncOverrides(['config_hash' => 'override-hash']))->toArray()); + + $preservedSync = LoginResponse::fromArray([ + 'profile' => ['contact' => ['id' => 77, 'names' => []]], + ])->updateSyncState(new SyncState([ + 'chatsSync' => 31, + 'contactsSync' => 32, + 'draftsSync' => 33, + 'presenceSync' => 34, + 'configHash' => 'current-hash', + ])); + $assertSame(31, $preservedSync->chatsSync); + $assertSame(32, $preservedSync->contactsSync); + $assertSame(33, $preservedSync->draftsSync); + $assertSame(34, $preservedSync->presenceSync); + $assertSame('current-hash', $preservedSync->configHash); + + $updatedSync = LoginResponse::fromArray([ + 'profile' => ['contact' => ['id' => 78, 'names' => []]], + 'time' => 99, + 'config' => ['hash' => 'next-hash'], + ])->updateSyncState(new SyncState([ + 'chatsSync' => 41, + 'contactsSync' => 42, + 'draftsSync' => 43, + 'presenceSync' => 44, + 'configHash' => 'old-hash', + ])); + $assertSame(99, $updatedSync->chatsSync); + $assertSame(99, $updatedSync->contactsSync); + $assertSame(99, $updatedSync->draftsSync); + $assertSame(99, $updatedSync->presenceSync); + $assertSame('next-hash', $updatedSync->configHash); + + $snakeMessage = Message::fromArray([ + 'chat_id' => 501, + 'message' => [ + 'id' => 502, + 'chat_id' => 501, + 'time' => 1234567, + 'type' => 'USER', + 'text' => 'snake', + 'prev_message_id' => '501', + 'attaches' => [ + ['_type' => AttachmentType::PHOTO, 'photo_id' => 'photo-snake', 'photo_token' => 'token-snake'], + ], + ], + ]); + $assertSame(501, $snakeMessage->chatId); + $assertSame('501', $snakeMessage->prevMessageId); + $assert($snakeMessage->attaches[0] instanceof PhotoAttachment, 'snake_case attachment keys must hydrate'); + $assertSame('photo-snake', $snakeMessage->attaches[0]->photoId); + $assertSame('token-snake', $snakeMessage->attaches[0]->photoToken); + $assertSame('token-snake', $snakeMessage->attaches[0]->toArray()['photoToken']); + + $snakeProfile = Profile::fromArray([ + 'contact' => [ + 'id' => 903, + 'base_raw_url' => 'raw-url', + 'base_url' => 'base-url', + 'names' => [[ + 'name' => 'Snake User', + 'first_name' => 'Snake', + 'last_name' => 'User', + 'type' => 'NICK', + ]], + ], + ]); + $assertSame('raw-url', $snakeProfile->contact->baseRawUrl); + $assertSame('base-url', $snakeProfile->contact->baseUrl); + $assertSame('Snake', $snakeProfile->contact->names[0]->firstName); + $assertSame('User', $snakeProfile->contact->names[0]->lastName); + $assertSame('Snake', $snakeProfile->contact->names[0]->toArray()['firstName']); + $assertSame('User', $snakeProfile->contact->names[0]->toArray()['lastName']); + + $payload = GetMessagesPayload::fromArray([ + 'chat_id' => 600, + 'message_ids' => [1, 2], + ]); + $assertSame(['chatId' => 600, 'messageIds' => [1, 2]], $payload->toArray()); + $coercedListPayload = GetMessagesPayload::fromArray([ + 'chat_id' => 600, + 'message_ids' => ['1', '2.0', true], + ]); + $assertSame(['chatId' => 600, 'messageIds' => [1, 2, 1]], $coercedListPayload->toArray()); + $folderPayload = CreateFolderPayload::fromArray([ + 'id' => 'folder', + 'title' => 'Folder', + 'include' => ['10'], + 'filters' => [['type' => 'unread']], + ]); + $assertSame(['id' => 'folder', 'title' => 'Folder', 'include' => [10], 'filters' => [['type' => 'unread']]], $folderPayload->toArray()); + $deleteFolderPayload = DeleteFolderPayload::fromArray(['folderIds' => ['a', 'b']]); + $assertSame(['folderIds' => ['a', 'b']], $deleteFolderPayload->toArray()); + + $coercedScalars = Message::fromArray([ + 'id' => '42.0', + 'time' => true, + 'type' => 'USER', + 'ttl' => 'false', + ]); + $assertSame(42, $coercedScalars->id); + $assertSame(1, $coercedScalars->time); + $assertSame(false, $coercedScalars->ttl); + + $assertThrows(ValidationException::class, static function (): void { + Chat::fromArray([]); + }, 'Empty explicit payload for required model must fail like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => 'abc', 'time' => 2, 'type' => 'USER']); + }, 'Non-numeric int payload must fail instead of casting to zero'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => '42.1', 'time' => 2, 'type' => 'USER']); + }, 'Fractional int payload must fail like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => [], 'time' => 2, 'type' => 'USER']); + }, 'Array int payload must fail instead of casting to one'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => 1, 'time' => 2, 'type' => 3]); + }, 'Non-string string payload must fail like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => 1, 'time' => 2, 'type' => 'USER', 'ttl' => 2]); + }, 'Boolean payload must reject non-0/1 integers like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => 1, 'time' => 2, 'type' => 'USER', 'ttl' => 'maybe']); + }, 'Boolean payload must reject unknown strings like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + User::fromArray(['id' => 1, 'names' => 'bad']); + }, 'Explicit malformed list field must fail instead of becoming empty list'); + $assertThrows(ValidationException::class, static function (): void { + User::fromArray(['id' => 1, 'names' => ['primary' => ['name' => 'Bad']]]); + }, 'Associative array must not be accepted for list fields'); + $assertThrows(ValidationException::class, static function (): void { + GetMessagesPayload::fromArray(['chatId' => 1, 'messageIds' => ['first' => 2]]); + }, 'Primitive list payloads must reject associative maps'); + $assertThrows(ValidationException::class, static function (): void { + GetMessagesPayload::fromArray(['chatId' => 1, 'messageIds' => ['abc']]); + }, 'list payload items must reject non-numeric values'); + $assertThrows(ValidationException::class, static function (): void { + GetReactionsPayload::fromArray(['chatId' => 1, 'messageIds' => [1]]); + }, 'list payload items must reject non-string values like Pydantic'); + $assertThrows(ValidationException::class, static function (): void { + InviteUsersPayload::fromArray(['chatId' => 1, 'userIds' => ['first' => 2], 'showHistory' => true]); + }, 'Chat user id list payloads must reject associative maps'); + $assertThrows(ValidationException::class, static function (): void { + CreateFolderPayload::fromArray(['id' => 'folder', 'title' => 'Folder', 'include' => ['primary' => 10], 'filters' => []]); + }, 'Account include list payloads must reject associative maps'); + $assertThrows(ValidationException::class, static function (): void { + CreateFolderPayload::fromArray(['id' => 'folder', 'title' => 'Folder', 'include' => [10], 'filters' => ['type' => 'unread']]); + }, 'Account filter list payloads must reject associative maps'); + $assertThrows(ValidationException::class, static function (): void { + Profile::fromArray(['contact' => ['id' => 1, 'names' => []], 'profileOptions' => 'bad']); + }, 'Explicit malformed array field must fail instead of becoming empty array'); + $assertThrows(ValidationException::class, static function (): void { + LoginResponse::fromArray(['profile' => ['contact' => ['id' => 1, 'names' => []]], 'messages' => 'bad']); + }, 'Explicit malformed map-list field must fail instead of becoming empty map'); + $assertThrows(ValidationException::class, static function (): void { + LoginResponse::fromArray([ + 'profile' => ['contact' => ['id' => 1, 'names' => []]], + 'messages' => [ + 1 => [ + 'first' => ['id' => 2, 'time' => 3, 'type' => 'USER'], + ], + ], + ]); + }, 'Map-list values must be list-like arrays, not associative maps'); + $assertThrows(ValidationException::class, static function (): void { + LoginResponse::fromArray(['profile' => ['contact' => ['id' => 1, 'names' => []]], 'contacts' => ['bad']]); + }, 'LoginResponse contacts allow nulls but not scalar contact items'); + $assertThrows(ValidationException::class, static function (): void { + LoginResponse::fromArray([ + 'profile' => ['contact' => ['id' => 1, 'names' => []]], + 'contacts' => [ + 'primary' => ['id' => 2, 'names' => []], + ], + ]); + }, 'LoginResponse contacts must be a list-like payload'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray(['id' => 1, 'time' => 2, 'type' => 'USER', 'attaches' => ['bad']]); + }, 'Explicit malformed attachment item must fail instead of being preserved as raw scalar'); + $assertThrows(ValidationException::class, static function (): void { + Message::fromArray([ + 'id' => 1, + 'time' => 2, + 'type' => 'USER', + 'attaches' => [ + 'control' => ['_type' => AttachmentType::CONTROL, 'event' => 'bad'], + ], + ]); + }, 'Message attaches must be a list-like payload'); + $assertThrows(ValidationException::class, static function (): void { + MessageDeleteEvent::fromArray(['chatId' => 1, 'messageIds' => 'bad']); + }, 'MessageDeleteEvent messageIds must be a list-like payload'); + $assertThrows(ValidationException::class, static function (): void { + MessageDeleteEvent::fromArray(['chatId' => 1, 'messageIds' => ['first' => 2]]); + }, 'MessageDeleteEvent messageIds must not accept associative maps'); + $assertThrows(ValidationException::class, static function (): void { + MessageDeleteEvent::fromArray(['chatId' => 1, 'messageIds' => [['bad']]]); + }, 'MessageDeleteEvent messageIds must contain scalar ids'); +}; diff --git a/tests-php/Unit/SessionStoreTest.php b/tests-php/Unit/SessionStoreTest.php new file mode 100644 index 0000000..3ff2e9a --- /dev/null +++ b/tests-php/Unit/SessionStoreTest.php @@ -0,0 +1,122 @@ + 'token-1', + 'deviceId' => 'device-1', + 'phone' => '+79990000000', + 'mtInstanceId' => 'mt-1', + 'sync' => new SyncState([ + 'chatsSync' => 10, + 'contactsSync' => 20, + 'draftsSync' => 30, + 'presenceSync' => 40, + 'configHash' => 'config-1', + ]), + ]); + + $store->saveSession($session); + $loaded = $store->loadSession(); + $assert($loaded instanceof SessionInfo); + $assertSame('token-1', $loaded->token); + $assertSame('device-1', $store->loadSessionByDeviceId('device-1')->deviceId); + $assertSame('+79990000000', $store->loadSessionByPhone('+79990000000')->phone); + $assertSame(10, $loaded->sync->chatsSync); + $assertSame(20, $loaded->sync->contactsSync); + $assertSame(30, $loaded->sync->draftsSync); + $assertSame(40, $loaded->sync->presenceSync); + $assertSame('config-1', $loaded->sync->configHash); + + $otherSession = new SessionInfo([ + 'token' => 'token-other', + 'deviceId' => 'device-other', + 'phone' => '+79990000001', + ]); + $store->saveSession($otherSession); + $assertSame('token-other', $store->loadSessionByDeviceId('device-other')->token); + + $store->updateToken('token-1', 'token-2'); + $updated = $store->loadSessionByDeviceId('device-1'); + $assert($updated instanceof SessionInfo); + $assertSame('token-2', $updated->token); + $assertSame(10, $updated->sync->chatsSync); + + $store->close(); + $assertSame('token-2', $store->loadSessionByPhone('+79990000000')->token); + + $store->deleteSession('token-2'); + $assertSame(null, $store->loadSessionByDeviceId('device-1')); + $assertSame('token-other', $store->loadSessionByDeviceId('device-other')->token); + $store->deleteAllSessions(); + $assertSame(null, $store->loadSessionByDeviceId('device-other')); + $assertSame(null, $store->loadSessionByPhone('+79990000001')); + $assertSame(null, $store->loadSession()); + $store->close(); + }; + + $assertRejectsUnsafeFileNames = static function (callable $factory, string $prefix) use ($assertThrows, $cleanupDir): void { + $unsafeNames = [ + '', + '.', + '..', + '../session.json', + 'nested/session.json', + 'nested\\session.json', + "session\0.json", + ]; + + foreach ($unsafeNames as $unsafeName) { + $dir = sys_get_temp_dir() . '/phpmax-session-unsafe-test-' . $prefix . '-' . getmypid() . '-' . mt_rand(); + try { + $assertThrows(PHPMaxException::class, static function () use ($factory, $dir, $unsafeName): void { + $factory($dir, $unsafeName); + }, 'Unsafe session file name must be rejected'); + } finally { + $cleanupDir($dir); + } + } + }; + + $jsonDir = sys_get_temp_dir() . '/phpmax-session-json-test-' . getmypid() . '-' . mt_rand(); + try { + $exerciseStore(new JsonFileSessionStore($jsonDir)); + } finally { + $cleanupDir($jsonDir); + } + $assertRejectsUnsafeFileNames(static function (string $dir, string $fileName): void { + new JsonFileSessionStore($dir, $fileName); + }, 'json'); + + $sqliteDir = sys_get_temp_dir() . '/phpmax-session-sqlite-test-' . getmypid() . '-' . mt_rand(); + if (class_exists(PDO::class) && in_array('sqlite', PDO::getAvailableDrivers(), true)) { + try { + $exerciseStore(new SQLiteSessionStore($sqliteDir)); + } finally { + $cleanupDir($sqliteDir); + } + } else { + $assertThrows(PHPMaxException::class, static function () use ($sqliteDir): void { + new SQLiteSessionStore($sqliteDir); + }); + } + $assertRejectsUnsafeFileNames(static function (string $dir, string $fileName): void { + new SQLiteSessionStore($dir, $fileName); + }, 'sqlite'); +}; diff --git a/tests-php/Unit/UserAgentPayloadTest.php b/tests-php/Unit/UserAgentPayloadTest.php new file mode 100644 index 0000000..33196af --- /dev/null +++ b/tests-php/Unit/UserAgentPayloadTest.php @@ -0,0 +1,81 @@ + 6686, + '26.14.0' => 6685, + '26.13.0' => 6683, + '26.12.2' => 6681, + '26.12.1' => 6679, + '26.12.0' => 6678, + '26.11.3' => 6680, + '26.11.2' => 6669, + '26.11.1' => 6665, + '26.11.0' => 6661, + ]; + $timezones = [ + 'Europe/Moscow', + 'Europe/Kaliningrad', + 'Europe/Samara', + 'Asia/Yekaterinburg', + 'Asia/Omsk', + 'Asia/Novosibirsk', + 'Asia/Krasnoyarsk', + 'Asia/Irkutsk', + 'Asia/Yakutsk', + 'Asia/Vladivostok', + ]; + + for ($i = 0; $i < 8; $i++) { + $android = MobileUserAgentPayload::randomAndroid(); + $assertSame(DeviceType::ANDROID, $android->deviceType); + $assert(array_key_exists((string) $android->appVersion, $appVersions), 'Android app version must come from PyMax anchors'); + $assertSame($appVersions[(string) $android->appVersion], $android->buildNumber); + $assert(in_array($android->timezone, $timezones, true), 'Android timezone must come from PyMax anchors'); + $assert(in_array($android->osVersion, ['Android 12', 'Android 13', 'Android 14'], true), 'Android OS version must come from PyMax device anchors'); + $assertSame('GCM', $android->pushDeviceType); + $assertSame('arm64-v8a', $android->arch); + $assertSame('ru', $android->locale); + $assertSame('ru', $android->deviceLocale); + $assert(strpos((string) $android->screen, 'dpi') !== false, 'Android screen must include PyMax dpi descriptor'); + $assert($android->deviceName !== null && $android->deviceName !== '', 'Android device name must be present'); + } + + $options = new ClientOptions(); + $assertSame(DeviceType::ANDROID, $options->userAgent->deviceType); + $assert(array_key_exists((string) $options->userAgent->appVersion, $appVersions), 'ClientOptions default user-agent must use PyMax app anchors'); + + $web = MobileUserAgentPayload::randomWeb(); + $assertSame(DeviceType::WEB, $web->deviceType); + $assertSame('26.5.5', $web->appVersion); + $assertSame('Linux', $web->osVersion); + $assertSame('1080x1920 1.0x', $web->screen); + $assertSame('Chrome', $web->deviceName); + $assertSame('ru', $web->locale); + $assert(in_array($web->timezone, $timezones, true), 'Web timezone must come from PyMax anchors'); + $assertSame(MobileUserAgentPayload::DEFAULT_WEB_HEADER_USER_AGENT, $web->headerUserAgent); + + $webPayload = $web->toWebPayload(); + $assertSame(DeviceType::WEB, $webPayload['deviceType']); + $assertSame(MobileUserAgentPayload::DEFAULT_WEB_HEADER_USER_AGENT, $webPayload['headerUserAgent']); + $assert(!array_key_exists('pushDeviceType', $webPayload), 'Web payload must keep PyMax web aliases only'); + $assert(!array_key_exists('arch', $webPayload), 'Web payload must keep PyMax web aliases only'); + + $webWithoutHeader = new MobileUserAgentPayload([ + 'deviceType' => DeviceType::WEB, + 'appVersion' => '26.5.5', + 'osVersion' => 'Linux', + 'timezone' => 'Europe/Moscow', + 'screen' => '1080x1920 1.0x', + 'locale' => 'ru', + 'deviceName' => 'Chrome', + 'deviceLocale' => 'ru', + ]); + $assertSame(MobileUserAgentPayload::DEFAULT_WEB_HEADER_USER_AGENT, $webWithoutHeader->toWebPayload()['headerUserAgent']); +}; diff --git a/tests-php/Uploads/NativeHttpUploaderTest.php b/tests-php/Uploads/NativeHttpUploaderTest.php new file mode 100644 index 0000000..844c873 --- /dev/null +++ b/tests-php/Uploads/NativeHttpUploaderTest.php @@ -0,0 +1,173 @@ + 16384) { + fclose($conn); + exit(4); + } + } + + $length = 0; + if (preg_match('/\r\nContent-Length:\s*(\d+)\r\n/i', $headers, $matches)) { + $length = (int) $matches[1]; + } + + $body = ''; + while (strlen($body) < $length && !feof($conn)) { + $chunk = fread($conn, $length - strlen($body)); + if ($chunk === false) { + fclose($conn); + exit(5); + } + $body .= $chunk; + } + + fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + fclose($conn); + + $requestLine = strtok($headers, "\r\n"); + $expectedRequestLine = $expectedMethod . ' ' . $expectedPath . ' HTTP/1.1'; + if ($requestLine !== $expectedRequestLine) { + exit(6); + } + if ($expectedBody !== null && $body !== $expectedBody) { + exit(7); + } + if ($expectedBody !== null && $length !== strlen($expectedBody)) { + exit(8); + } + if ($inspector !== null && !call_user_func($inspector, $headers, $body)) { + exit(9); + } + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + foreach ([ + 'file:///tmp/phpmax-upload-target', + 'ftp://upload.example.test/file', + '//upload.example.test/file', + '/relative/upload', + 'http://upload.example.test:0/file', + ] as $invalidUrl) { + $assertThrows(UploadException::class, static function () use ($invalidUrl): void { + (new NativeHttpUploader(5.0))->uploadMultipart($invalidUrl, 'file', 'abc', 'a.txt', 'text/plain'); + }, 'Multipart upload must reject non-HTTP(S) URL before HTTP client starts'); + + $assertThrows(UploadException::class, static function () use ($invalidUrl): void { + (new NativeHttpUploader(5.0))->uploadStream($invalidUrl, [], ['abc'], 3); + }, 'Streaming upload must reject non-HTTP(S) URL before HTTP client starts'); + } + + if (!function_exists('curl_init') || !function_exists('pcntl_fork') || !function_exists('stream_socket_server')) { + $assert(true, 'NativeHttpUploader loopback test requires ext-curl, pcntl and sockets'); + return; + } + + list($address, $pid) = NativeHttpUploaderLoopbackServer::start('POST', '/upload', 'abcdef'); + + try { + $uploader = new NativeHttpUploader(5.0); + $response = $uploader->uploadStream( + 'http://' . $address . '/upload', + ['Content-Type' => 'application/octet-stream'], + ['abc', '', 'def'], + 6 + ); + $assertSame(200, $response->status()); + $assertSame('ok', $response->body()); + } finally { + pcntl_waitpid($pid, $status); + } + + $assertSame(0, pcntl_wexitstatus($status), 'Loopback server must receive streaming POST body and Content-Length'); + + $multipartInspector = static function (string $headers, string $body): bool { + if (!preg_match('/\r\nContent-Type:\s*multipart\/form-data;\s*boundary=([^\r\n;]+)/i', $headers, $matches)) { + return false; + } + + $boundary = $matches[1]; + if (!preg_match('/\r\nContent-Length:\s*(\d+)\r\n/i', $headers, $lengthMatches)) { + return false; + } + if ((int) $lengthMatches[1] !== strlen($body)) { + return false; + } + + return strpos($body, '--' . $boundary . "\r\n") === 0 + && strpos($body, 'Content-Disposition: form-data; name="file\"field"; filename="avatar\"X-Bad: 1.png"') !== false + && strpos($body, "\r\nX-Bad: 1.png") === false + && strpos($body, "Content-Type: image/png\r\n\r\nphoto-bytes\r\n") !== false + && substr($body, -strlen('--' . $boundary . "--\r\n")) === '--' . $boundary . "--\r\n"; + }; + + list($multipartAddress, $multipartPid) = NativeHttpUploaderLoopbackServer::start('POST', '/photo', null, $multipartInspector); + + try { + $uploader = new NativeHttpUploader(5.0); + $response = $uploader->uploadMultipart( + 'http://' . $multipartAddress . '/photo', + 'file"field', + 'photo-bytes', + "avatar\"\r\nX-Bad: 1.png", + 'image/png' + ); + $assertSame(200, $response->status()); + $assertSame('ok', $response->body()); + } finally { + pcntl_waitpid($multipartPid, $multipartStatus); + } + + $assertSame(0, pcntl_wexitstatus($multipartStatus), 'Loopback server must receive sanitized multipart upload body'); +}; diff --git a/tests-php/Uploads/UploadServiceTest.php b/tests-php/Uploads/UploadServiceTest.php new file mode 100644 index 0000000..3022153 --- /dev/null +++ b/tests-php/Uploads/UploadServiceTest.php @@ -0,0 +1,517 @@ + */ + private $chunks; + /** @var list */ + public $sent = []; + /** @var bool */ + private $connected = false; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake upload chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +final class UploadServiceFakeUploader implements HttpUploaderInterface +{ + /** @var list> */ + public $multipart = []; + /** @var list> */ + public $streams = []; + /** @var callable|null */ + public $onStreamUpload; + /** @var HttpUploadResponse|null */ + public $multipartResponse; + /** @var HttpUploadResponse|null */ + public $streamResponse; + + public function uploadMultipart( + string $url, + string $fieldName, + string $contents, + string $filename, + string $contentType + ): HttpUploadResponse { + $this->multipart[] = [ + 'url' => $url, + 'fieldName' => $fieldName, + 'contents' => $contents, + 'filename' => $filename, + 'contentType' => $contentType, + ]; + + if ($this->multipartResponse !== null) { + return $this->multipartResponse; + } + + return new HttpUploadResponse(200, json_encode([ + 'photos' => [ + 'photo-1' => ['token' => 'photo-token'], + 'profile-1' => ['token' => 'profile-token'], + ], + ])); + } + + public function uploadStream( + string $url, + array $headers, + iterable $chunks, + int $contentLength + ): HttpUploadResponse { + $this->streams[] = [ + 'url' => $url, + 'headers' => $headers, + 'chunks' => is_array($chunks) ? $chunks : iterator_to_array($chunks), + 'contentLength' => $contentLength, + ]; + if ($this->onStreamUpload !== null) { + call_user_func($this->onStreamUpload, $url, $headers, $contentLength); + } + if ($this->streamResponse !== null) { + return $this->streamResponse; + } + + return new HttpUploadResponse(200, ''); + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $body = new StreamBody(['abc', '', 'defg'], 7); + $assertSame('ab', $body->read(2)); + $assertSame('cde', $body->read(3)); + $assertSame('fg', $body->read(99)); + $assertSame('', $body->read(1)); + $assertSame(7, $body->bytesRead()); + $body->assertComplete(); + + $shortBody = new StreamBody(['abc'], 4); + $assertSame('abc', $shortBody->read(10)); + $assertThrows(UploadException::class, static function () use ($shortBody): void { + $shortBody->assertComplete(); + }); + $assertThrows(UploadException::class, static function (): void { + (new StreamBody(['abcd'], 3))->read(4); + }); + $assertThrows(UploadException::class, static function (): void { + (new StreamBody([123], 3))->read(3); + }); + + $assertThrows(UploadException::class, static function (): void { + Photo::fromRaw('bytes', 'image.txt')->validatePhoto(); + }); + $assertThrows(UploadException::class, static function (): void { + new File('raw-a', null, 'https://example.test/a.txt', 'a.txt'); + }); + + $rawFile = File::fromRaw('abcdef', 'report.pdf'); + $assertSame(6, $rawFile->size()); + $assertSame(['ab', 'cd', 'ef'], iterator_to_array($rawFile->iterChunks(2))); + $emptyRawFile = File::fromRaw('', 'empty.pdf'); + $assertThrows(UploadException::class, static function () use ($emptyRawFile): void { + $emptyRawFile->read(); + }, 'Empty raw file read must fail like PyMax falsey raw handling'); + $assertThrows(UploadException::class, static function () use ($emptyRawFile): void { + $emptyRawFile->size(); + }, 'Empty raw file size must fail like PyMax falsey raw handling'); + $assertSame([], iterator_to_array($emptyRawFile->iterChunks(2)), 'Empty raw chunk iterator stays empty'); + + $tmpPath = sys_get_temp_dir() . '/phpmax-upload-fixture-' . getmypid() . '-' . mt_rand() . '.pdf'; + file_put_contents($tmpPath, 'path-bytes'); + try { + $pathFile = File::fromPath($tmpPath); + $assertSame(basename($tmpPath), $pathFile->name()); + $assertSame(10, $pathFile->size()); + $assertSame('path-bytes', $pathFile->read()); + $assertSame(['path', '-byt', 'es'], iterator_to_array($pathFile->iterChunks(4))); + } finally { + if (is_file($tmpPath)) { + unlink($tmpPath); + } + } + + $pathVideo = Video::fromPath('/tmp/phpmax-clip.mp4'); + $assertSame('phpmax-clip.mp4', $pathVideo->name()); + + $urlFile = File::fromUrl('https://cdn.example.test/files/report.pdf'); + $assertSame('report.pdf', $urlFile->name()); + + $assertSame(['jpg', 'image/jpg'], Photo::fromRaw('photo-bytes', 'avatar.jpg')->validatePhoto()); + $assertSame(['jpg', 'image/jpg'], Photo::fromPath('/tmp/phpmax-avatar.jpg')->validatePhoto()); + $assertSame(['jpg', 'image/jpeg'], Photo::fromUrl('https://cdn.example.test/img/avatar.jpg?token=1')->validatePhoto()); + $assertSame(['webp', 'image/webp'], Photo::fromUrl('https://cdn.example.test/img/avatar.webp?token=1')->validatePhoto()); + $assertThrows(UploadException::class, static function (): void { + Photo::fromUrl('https://cdn.example.test/img/avatar.txt')->validatePhoto(); + }); + + $protocol = new TcpProtocol(); + $frameChunks = static function (array $payload, int $opcode, int $seq, int $cmd = Command::RESPONSE) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, $cmd)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (UploadServiceTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + $privateArray = static function ($object, string $property): array { + $reflection = new ReflectionProperty(get_class($object), $property); + $reflection->setAccessible(true); + + return $reflection->getValue($object); + }; + $assertUploadStateEmpty = static function ($uploadService) use ($assertSame, $privateArray): void { + $assertSame([], $privateArray($uploadService, 'readyVideos')); + $assertSame([], $privateArray($uploadService, 'readyFiles')); + $assertSame([], $privateArray($uploadService, 'expectedVideos')); + $assertSame([], $privateArray($uploadService, 'expectedFiles')); + }; + + $chunks = array_merge( + $frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), + $frameChunks(['info' => [['url' => 'https://upload.test/video', 'videoId' => 700, 'token' => 'video-token']]], Opcode::VIDEO_UPLOAD, 1), + $frameChunks(['videoId' => 999], Opcode::NOTIF_ATTACH, 89, Command::REQUEST), + $frameChunks(['videoId' => 700], Opcode::NOTIF_ATTACH, 90, Command::REQUEST), + $frameChunks(['info' => [['url' => 'https://upload.test/file', 'fileId' => 800, 'token' => 'file-token']]], Opcode::FILE_UPLOAD, 2), + $frameChunks(['fileId' => 999], Opcode::NOTIF_ATTACH, 91, Command::REQUEST), + $frameChunks(['fileId' => 800], Opcode::NOTIF_ATTACH, 92, Command::REQUEST) + ); + + $uploader = new UploadServiceFakeUploader(); + $transport = new UploadServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $events = []; + $manager->setEventHandler(static function (InboundFrame $frame) use (&$events): void { + $events[] = $frame; + }); + $manager->open(); + $app = new App($manager, new ClientOptions([ + 'httpUploader' => $uploader, + 'requestTimeout' => 1.0, + 'uploadProcessingTimeout' => 1.0, + 'uploadChunkSize' => 3, + ])); + + $photoPayload = $app->api()->uploads->uploadPhoto(Photo::fromRaw('photo-bytes', 'image.png')); + $assertSame(['_type' => 'PHOTO', 'photoToken' => 'photo-token'], $photoPayload->toArray()); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 0)->payload); + $assertSame('file', $uploader->multipart[0]['fieldName']); + $assertSame('image.png', $uploader->multipart[0]['filename']); + $assertSame('image/png', $uploader->multipart[0]['contentType']); + + $makeUploadApp = static function (array $chunks, UploadServiceFakeUploader $uploader) use ($protocol): App { + $transport = new UploadServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + + return new App($manager, new ClientOptions([ + 'httpUploader' => $uploader, + 'requestTimeout' => 1.0, + 'uploadProcessingTimeout' => 0.1, + ])); + }; + + $invalidJsonUploader = new UploadServiceFakeUploader(); + $invalidJsonUploader->multipartResponse = new HttpUploadResponse(200, 'not-json'); + $invalidJsonApp = $makeUploadApp($frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), $invalidJsonUploader); + $assertThrows(UploadException::class, static function () use ($invalidJsonApp): void { + $invalidJsonApp->api()->uploads->uploadPhoto(Photo::fromRaw('photo-bytes', 'invalid-json.png')); + }, 'uploadPhoto must fail on invalid HTTP JSON'); + + $missingPhotosUploader = new UploadServiceFakeUploader(); + $missingPhotosUploader->multipartResponse = new HttpUploadResponse(200, json_encode(['unexpected' => []])); + $missingPhotosApp = $makeUploadApp($frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), $missingPhotosUploader); + $assertThrows(UploadException::class, static function () use ($missingPhotosApp): void { + $missingPhotosApp->api()->uploads->uploadPhoto(Photo::fromRaw('photo-bytes', 'missing-photos.png')); + }, 'uploadPhoto must fail when HTTP JSON lacks required photos map'); + + $missingTokenUploader = new UploadServiceFakeUploader(); + $missingTokenUploader->multipartResponse = new HttpUploadResponse(200, json_encode([ + 'photos' => [ + 'other-photo' => ['token' => 'other-token'], + ], + ])); + $missingTokenApp = $makeUploadApp($frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), $missingTokenUploader); + $assertThrows(UploadException::class, static function () use ($missingTokenApp): void { + $missingTokenApp->api()->uploads->uploadPhoto(Photo::fromRaw('photo-bytes', 'missing-token.png')); + }, 'uploadPhoto must fail when HTTP JSON lacks token for requested photo_id'); + + $emptyRawPhotoUploader = new UploadServiceFakeUploader(); + $emptyRawPhotoApp = $makeUploadApp($frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), $emptyRawPhotoUploader); + $assertThrows(UploadException::class, static function () use ($emptyRawPhotoApp): void { + $emptyRawPhotoApp->api()->uploads->uploadPhoto(Photo::fromRaw('', 'empty.png')); + }, 'uploadPhoto must reject empty raw photo before HTTP multipart upload'); + $assertSame(0, count($emptyRawPhotoUploader->multipart)); + + $videoPayload = $app->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'clip.mp4')); + $assertSame(['_type' => 'VIDEO', 'videoId' => 700, 'token' => 'video-token'], $videoPayload->toArray()); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 1)->payload); + $assertSame('https://upload.test/video', $uploader->streams[0]['url']); + $assertSame('attachment; filename=clip.mp4', $uploader->streams[0]['headers']['Content-Disposition']); + $assertSame('0-10/11', $uploader->streams[0]['headers']['Content-Range']); + $assertSame(['vid', 'eo-', 'byt', 'es'], $uploader->streams[0]['chunks']); + + $filePayload = $app->api()->uploads->uploadFile(File::fromRaw('file-bytes', 'report.pdf')); + $assertSame(['_type' => 'FILE', 'fileId' => 800], $filePayload->toArray()); + $assertSame(['count' => 1, 'profile' => false], $decodeSent($transport, 2)->payload); + $assertSame('https://upload.test/file', $uploader->streams[1]['url']); + $assertSame('attachment; filename=report.pdf', $uploader->streams[1]['headers']['Content-Disposition']); + $assertSame('0-9/10', $uploader->streams[1]['headers']['Content-Range']); + $assertSame([Opcode::NOTIF_ATTACH, Opcode::NOTIF_ATTACH, Opcode::NOTIF_ATTACH, Opcode::NOTIF_ATTACH], [ + $events[0]->opcode, + $events[1]->opcode, + $events[2]->opcode, + $events[3]->opcode, + ]); + $assertSame([999, 700], [$events[0]->payload['videoId'], $events[1]->payload['videoId']]); + $assertSame([999, 800], [$events[2]->payload['fileId'], $events[3]->payload['fileId']]); + $assertUploadStateEmpty($app->api()->uploads); + + $preReadyChunks = $frameChunks( + ['info' => [['url' => 'https://upload.test/pre-ready-video', 'videoId' => 701, 'token' => 'ready-token']]], + Opcode::VIDEO_UPLOAD, + 0 + ); + $preReadyUploader = new UploadServiceFakeUploader(); + $preReadyTransport = new UploadServiceTestTransport($preReadyChunks); + $preReadyManager = new ConnectionManager($preReadyTransport, $protocol); + $preReadyManager->open(); + $preReadyApp = new App($preReadyManager, new ClientOptions([ + 'httpUploader' => $preReadyUploader, + 'requestTimeout' => 1.0, + 'uploadProcessingTimeout' => 0.1, + ])); + $preReadyUploader->onStreamUpload = static function () use ($preReadyManager): void { + $preReadyManager->dispatchEvent(new InboundFrame(Opcode::NOTIF_ATTACH, Command::REQUEST, 900, ['videoId' => 701])); + }; + $preReadyVideo = $preReadyApp->api()->uploads->uploadVideo(Video::fromRaw('ready-video', 'ready.mp4')); + $assertSame(['_type' => 'VIDEO', 'videoId' => 701, 'token' => 'ready-token'], $preReadyVideo->toArray()); + $assertSame(1, count($preReadyTransport->sent), 'Pre-ready attach must avoid an extra blocking read'); + $assertUploadStateEmpty($preReadyApp->api()->uploads); + + $failedVideoUploader = new UploadServiceFakeUploader(); + $failedVideoUploader->streamResponse = new HttpUploadResponse(500, ''); + $failedVideoApp = $makeUploadApp( + $frameChunks( + ['info' => [['url' => 'https://upload.test/failed-video', 'videoId' => 703, 'token' => 'failed-token']]], + Opcode::VIDEO_UPLOAD, + 0 + ), + $failedVideoUploader + ); + $assertThrows(UploadException::class, static function () use ($failedVideoApp): void { + $failedVideoApp->api()->uploads->uploadVideo(Video::fromRaw('failed-video', 'failed.mp4')); + }, 'uploadVideo must clear expected waiter state when HTTP upload fails'); + $assertUploadStateEmpty($failedVideoApp->api()->uploads); + + $emptyVideoUploader = new UploadServiceFakeUploader(); + $emptyVideoTransport = new UploadServiceTestTransport($frameChunks([], Opcode::VIDEO_UPLOAD, 0)); + $emptyVideoManager = new ConnectionManager($emptyVideoTransport, $protocol); + $emptyVideoManager->open(); + $emptyVideoApp = new App($emptyVideoManager, new ClientOptions([ + 'httpUploader' => $emptyVideoUploader, + 'requestTimeout' => 1.0, + ])); + $assertThrows(UploadException::class, static function () use ($emptyVideoApp): void { + $emptyVideoApp->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'empty-video.mp4')); + }, 'uploadVideo must reject an empty init response before HTTP upload'); + $assertSame(0, count($emptyVideoUploader->streams)); + + $emptyFileUploader = new UploadServiceFakeUploader(); + $emptyFileTransport = new UploadServiceTestTransport($frameChunks([], Opcode::FILE_UPLOAD, 0)); + $emptyFileManager = new ConnectionManager($emptyFileTransport, $protocol); + $emptyFileManager->open(); + $emptyFileApp = new App($emptyFileManager, new ClientOptions([ + 'httpUploader' => $emptyFileUploader, + 'requestTimeout' => 1.0, + ])); + $assertThrows(UploadException::class, static function () use ($emptyFileApp): void { + $emptyFileApp->api()->uploads->uploadFile(File::fromRaw('file-bytes', 'empty-file.pdf')); + }, 'uploadFile must reject an empty init response before HTTP upload'); + $assertSame(0, count($emptyFileUploader->streams)); + + $missingVideoInfoUploader = new UploadServiceFakeUploader(); + $missingVideoInfoApp = $makeUploadApp($frameChunks(['unexpected' => true], Opcode::VIDEO_UPLOAD, 0), $missingVideoInfoUploader); + $assertThrows(UploadException::class, static function () use ($missingVideoInfoApp): void { + $missingVideoInfoApp->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'missing-info.mp4')); + }, 'uploadVideo must reject an init response without required info'); + $assertSame(0, count($missingVideoInfoUploader->streams)); + + $associativeVideoInfoUploader = new UploadServiceFakeUploader(); + $associativeVideoInfoApp = $makeUploadApp( + $frameChunks(['info' => ['first' => ['url' => 'https://upload.test/video', 'videoId' => 703, 'token' => 'token']]], Opcode::VIDEO_UPLOAD, 0), + $associativeVideoInfoUploader + ); + $assertThrows(UploadException::class, static function () use ($associativeVideoInfoApp): void { + $associativeVideoInfoApp->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'associative-info.mp4')); + }, 'uploadVideo must reject associative init info maps before HTTP upload'); + $assertSame(0, count($associativeVideoInfoUploader->streams)); + + $invalidVideoIdUploader = new UploadServiceFakeUploader(); + $invalidVideoIdApp = $makeUploadApp( + $frameChunks(['info' => [['url' => 'https://upload.test/video', 'videoId' => 0, 'token' => 'token']]], Opcode::VIDEO_UPLOAD, 0), + $invalidVideoIdUploader + ); + $assertThrows(UploadException::class, static function () use ($invalidVideoIdApp): void { + $invalidVideoIdApp->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'invalid-video-id.mp4')); + }, 'uploadVideo must reject non-positive videoId before HTTP upload'); + $assertSame(0, count($invalidVideoIdUploader->streams)); + + $emptyVideoTokenUploader = new UploadServiceFakeUploader(); + $emptyVideoTokenApp = $makeUploadApp( + $frameChunks(['info' => [['url' => 'https://upload.test/video', 'videoId' => 704, 'token' => '']]], Opcode::VIDEO_UPLOAD, 0), + $emptyVideoTokenUploader + ); + $assertThrows(UploadException::class, static function () use ($emptyVideoTokenApp): void { + $emptyVideoTokenApp->api()->uploads->uploadVideo(Video::fromRaw('video-bytes', 'empty-video-token.mp4')); + }, 'uploadVideo must reject empty upload token before HTTP upload'); + $assertSame(0, count($emptyVideoTokenUploader->streams)); + + $malformedFileInfoUploader = new UploadServiceFakeUploader(); + $malformedFileInfoApp = $makeUploadApp( + $frameChunks(['info' => [['url' => 'https://upload.test/file', 'fileId' => 803]]], Opcode::FILE_UPLOAD, 0), + $malformedFileInfoUploader + ); + $assertThrows(UploadException::class, static function () use ($malformedFileInfoApp): void { + $malformedFileInfoApp->api()->uploads->uploadFile(File::fromRaw('file-bytes', 'malformed-info.pdf')); + }, 'uploadFile must reject malformed init info before HTTP upload'); + $assertSame(0, count($malformedFileInfoUploader->streams)); + + $emptyFileUrlUploader = new UploadServiceFakeUploader(); + $emptyFileUrlApp = $makeUploadApp( + $frameChunks(['info' => [['url' => '', 'fileId' => 804, 'token' => 'file-token']]], Opcode::FILE_UPLOAD, 0), + $emptyFileUrlUploader + ); + $assertThrows(UploadException::class, static function () use ($emptyFileUrlApp): void { + $emptyFileUrlApp->api()->uploads->uploadFile(File::fromRaw('file-bytes', 'empty-file-url.pdf')); + }, 'uploadFile must reject empty upload URL before HTTP upload'); + $assertSame(0, count($emptyFileUrlUploader->streams)); + + $invalidFileIdUploader = new UploadServiceFakeUploader(); + $invalidFileIdApp = $makeUploadApp( + $frameChunks(['info' => [['url' => 'https://upload.test/file', 'fileId' => 0, 'token' => 'file-token']]], Opcode::FILE_UPLOAD, 0), + $invalidFileIdUploader + ); + $assertThrows(UploadException::class, static function () use ($invalidFileIdApp): void { + $invalidFileIdApp->api()->uploads->uploadFile(File::fromRaw('file-bytes', 'invalid-file-id.pdf')); + }, 'uploadFile must reject non-positive fileId before HTTP upload'); + $assertSame(0, count($invalidFileIdUploader->streams)); + + $clientChunks = array_merge( + $frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), + $frameChunks(['info' => [['url' => 'https://upload.test/client-video', 'videoId' => 702, 'token' => 'client-video-token']]], Opcode::VIDEO_UPLOAD, 1), + $frameChunks(['videoId' => 702], Opcode::NOTIF_ATTACH, 92, Command::REQUEST), + $frameChunks(['info' => [['url' => 'https://upload.test/client-file', 'fileId' => 802, 'token' => 'client-file-token']]], Opcode::FILE_UPLOAD, 2), + $frameChunks(['fileId' => 802], Opcode::NOTIF_ATTACH, 93, Command::REQUEST) + ); + $clientUploader = new UploadServiceFakeUploader(); + $clientTransport = new UploadServiceTestTransport($clientChunks); + $clientManager = new ConnectionManager($clientTransport, $protocol); + $client = new Client(new ClientOptions([ + 'httpUploader' => $clientUploader, + 'requestTimeout' => 1.0, + 'uploadProcessingTimeout' => 1.0, + ]), $clientManager); + $clientManager->open(); + + $clientPhoto = $client->uploadPhoto(Photo::fromRaw('photo-bytes', 'client.png')); + $assert($clientPhoto instanceof AttachPhotoPayload, 'Client::uploadPhoto must preserve typed payload'); + $assertSame(['_type' => 'PHOTO', 'photoToken' => 'photo-token'], $clientPhoto->toArray()); + + $clientVideo = $client->uploadVideo(Video::fromRaw('video-bytes', 'client.mp4')); + $assert($clientVideo instanceof VideoAttachPayload, 'Client::uploadVideo must preserve typed payload'); + $assertSame(['_type' => 'VIDEO', 'videoId' => 702, 'token' => 'client-video-token'], $clientVideo->toArray()); + + $clientFile = $client->uploadFile(File::fromRaw('file-bytes', 'client.pdf')); + $assert($clientFile instanceof AttachFilePayload, 'Client::uploadFile must preserve typed payload'); + $assertSame(['_type' => 'FILE', 'fileId' => 802], $clientFile->toArray()); + + $messageChunks = array_merge( + $frameChunks(['url' => 'https://upload.test/photo?photoIds=photo-1'], Opcode::PHOTO_UPLOAD, 0), + $frameChunks(['id' => 42, 'chatId' => 100, 'time' => 1, 'type' => 'USER', 'text' => 'photo'], Opcode::MSG_SEND, 1) + ); + $messageUploader = new UploadServiceFakeUploader(); + $messageTransport = new UploadServiceTestTransport($messageChunks); + $messageManager = new ConnectionManager($messageTransport, $protocol); + $messageManager->open(); + $messageApp = new App($messageManager, new ClientOptions(['httpUploader' => $messageUploader, 'requestTimeout' => 1.0])); + $message = $messageApp->api()->messages->sendMessage(100, 'photo', null, [Photo::fromRaw('photo-bytes', 'image.png')]); + $assertSame(42, $message->id); + $sendPayload = $decodeSent($messageTransport, 1)->payload; + $assertSame(['_type' => 'PHOTO', 'photoToken' => 'photo-token'], $sendPayload['message']['attaches'][0]); + + $accountChunks = array_merge( + $frameChunks(['url' => 'https://upload.test/photo?photoIds=profile-1'], Opcode::PHOTO_UPLOAD, 0), + $frameChunks(['profile' => [ + 'contact' => ['id' => 77, 'names' => [['name' => 'Me', 'type' => 'NICK']]], + 'profileOptions' => ['showPhone' => false], + ]], Opcode::PROFILE, 1) + ); + $accountUploader = new UploadServiceFakeUploader(); + $accountTransport = new UploadServiceTestTransport($accountChunks); + $accountManager = new ConnectionManager($accountTransport, $protocol); + $accountManager->open(); + $accountApp = new App($accountManager, new ClientOptions(['httpUploader' => $accountUploader, 'requestTimeout' => 1.0])); + $assertSame(true, $accountApp->api()->account->changeProfile('First', null, null, Photo::fromRaw('photo-bytes', 'avatar.png'))); + $assertSame(['count' => 1, 'profile' => true], $decodeSent($accountTransport, 0)->payload); + $assertSame('profile-token', $decodeSent($accountTransport, 1)->payload['photoToken']); +}; diff --git a/tests-php/Users/UserServiceTest.php b/tests-php/Users/UserServiceTest.php new file mode 100644 index 0000000..fb537d0 --- /dev/null +++ b/tests-php/Users/UserServiceTest.php @@ -0,0 +1,212 @@ + */ + private $chunks; + /** @var bool */ + private $connected = false; + /** @var list */ + public $sent = []; + + /** + * @param list $chunks + */ + public function __construct(array $chunks) + { + $this->chunks = $chunks; + } + + public function connect(): void + { + $this->connected = true; + } + + public function close(): void + { + $this->connected = false; + } + + public function send(string $data): void + { + $this->sent[] = $data; + } + + public function recv(int $length, float $timeout): string + { + $chunk = array_shift($this->chunks); + if ($chunk === null) { + throw new RuntimeException('No fake user chunks left'); + } + if (strlen($chunk) !== $length) { + throw new RuntimeException('Expected chunk length ' . $length . ', got ' . strlen($chunk)); + } + + return $chunk; + } + + public function connected(): bool + { + return $this->connected; + } +} + +return static function (callable $assert, callable $assertSame, callable $assertThrows): void { + $protocol = new TcpProtocol(); + $user = static function (int $id, string $name = 'User'): array { + return [ + 'id' => $id, + 'names' => [['name' => $name . ' ' . $id, 'type' => 'NICK']], + 'accountStatus' => 1, + 'registrationTime' => 100 + $id, + 'country' => 'RU', + 'baseRawUrl' => 'raw-' . $id, + 'baseUrl' => 'base-' . $id, + 'photoId' => $id + 1000, + 'updateTime' => $id + 2000, + 'phone' => '7999000' . $id, + 'status' => 'CONTACT', + 'description' => 'desc', + 'gender' => 1, + 'link' => 'https://max.test/u/' . $id, + 'webApp' => ['url' => 'https://app.test'], + 'menuButton' => ['type' => 'web_app'], + ]; + }; + $frameChunks = static function (array $payload, int $opcode, int $seq) use ($protocol): array { + $raw = $protocol->encode(new OutboundFrame(TcpProtocol::VERSION, $opcode, $seq, $payload, Command::RESPONSE)); + return [substr($raw, 0, 10), substr($raw, 10)]; + }; + $decodeSent = static function (UserServiceTestTransport $transport, int $index) use ($protocol) { + return $protocol->decode($transport->sent[$index]); + }; + + $chunks = array_merge( + $frameChunks(['contacts' => [$user(10), $user(20)]], Opcode::CONTACT_INFO, 0), + $frameChunks(['contacts' => [$user(30)]], Opcode::CONTACT_INFO, 1), + $frameChunks(['contact' => $user(40, 'Phone')], Opcode::CONTACT_INFO_BY_PHONE, 2), + $frameChunks(['contact' => $user(50, 'Added')], Opcode::CONTACT_UPDATE, 3), + $frameChunks([], Opcode::CONTACT_UPDATE, 4), + $frameChunks(['sessions' => [[ + 'id' => 'session-1', + 'deviceId' => 'device-1', + 'current' => true, + 'userAgent' => 'ua', + 'appVersion' => '1.0', + 'deviceName' => 'Desktop', + 'deviceType' => 'WEB', + 'platform' => 'Linux', + 'ip' => '127.0.0.1', + 'location' => 'Local', + 'created' => 1, + 'updated' => 2, + 'lastActivity' => 3, + 'options' => ['x' => true], + ]]], Opcode::SESSIONS_INFO, 5), + $frameChunks(['contacts' => [$user(60, 'Imported')]], Opcode::SYNC, 6), + $frameChunks(['contact' => $user(10, 'HelperAdd')], Opcode::CONTACT_UPDATE, 7), + $frameChunks([], Opcode::CONTACT_UPDATE, 8) + ); + + $transport = new UserServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + $service = $app->api()->users; + + $fetched = $service->fetchUsers([10, 20]); + $assertSame([10, 20], [$fetched[0]->id, $fetched[1]->id]); + $assertSame('User 10', $fetched[0]->names[0]->name); + $assertSame('RU', $fetched[0]->country); + $assertSame(10, $app->cachedUser(10)->id); + $assertSame(['contactIds' => [10, 20]], $decodeSent($transport, 0)->payload); + + $users = $service->getUsers([10, 20, 30]); + $assertSame([10, 20, 30], [$users[0]->id, $users[1]->id, $users[2]->id]); + $assertSame(['contactIds' => [30]], $decodeSent($transport, 1)->payload, 'getUsers must request only cache misses'); + $assertSame(10, $service->getCachedUser(10)->id); + $assertSame(10 ^ 20, $service->getChatId(10, 20)); + + $byPhone = $service->searchByPhone('+79990000040'); + $assertSame(40, $byPhone->id); + $assertSame(['phone' => '+79990000040'], $decodeSent($transport, 2)->payload); + + $added = $service->addContact(50); + $assertSame(50, $added->id); + $assertSame(['contactId' => 50, 'action' => ContactAction::ADD], $decodeSent($transport, 3)->payload); + $assertSame(true, $service->removeContact(50)); + $assertSame(['contactId' => 50, 'action' => ContactAction::REMOVE], $decodeSent($transport, 4)->payload); + $assertSame(null, $service->getCachedUser(50)); + $assertSame(null, $app->cachedUser(50)); + + $sessions = $service->getSessions(); + $assertSame('session-1', $sessions[0]->id); + $assertSame(true, $sessions[0]->current); + $assertSame('Linux', $sessions[0]->platform); + $assertSame([], $decodeSent($transport, 5)->payload); + + $imported = $service->importContacts([ + new ContactInfo(['phone' => '+79990000060', 'firstName' => 'Imported']), + ]); + $assertSame(60, $imported[0]->id); + $assertSame([ + 'contactList' => [ + '+79990000060' => ['firstName' => 'Imported'], + ], + ], $decodeSent($transport, 6)->payload); + + $helperAdded = $fetched[0]->addContact(); + $assertSame(10, $helperAdded->id); + $assertSame(['contactId' => 10, 'action' => ContactAction::ADD], $decodeSent($transport, 7)->payload); + $assertSame(true, $fetched[0]->removeContact()); + $assertSame(['contactId' => 10, 'action' => ContactAction::REMOVE], $decodeSent($transport, 8)->payload); + $assertSame(null, $app->cachedUser(10)); + $assertSame(10 ^ 99, $fetched[0]->getChatId(99)); + + $badRemoveTransport = new UserServiceTestTransport($frameChunks([1], Opcode::CONTACT_UPDATE, 0)); + $badRemoveManager = new ConnectionManager($badRemoveTransport, $protocol); + $badRemoveManager->open(); + $badRemoveApp = new App($badRemoveManager, 1.0); + $badRemoveService = $badRemoveApp->api()->users; + $badRemoveService->cacheExternalUser(\PHPMax\Domain\User::fromArray($user(70, 'Cached'))); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badRemoveService): void { + $badRemoveService->removeContact(70); + }, 'removeContact must require dict CONTACT_UPDATE response like PyMax require_payload_dict'); + $assertSame(70, $badRemoveApp->cachedUser(70)->id, 'Invalid CONTACT_UPDATE response must not clear user cache'); + + $makeUserService = static function (array $chunks) use ($protocol): array { + $transport = new UserServiceTestTransport($chunks); + $manager = new ConnectionManager($transport, $protocol); + $manager->open(); + $app = new App($manager, 1.0); + + return [$transport, $app->api()->users]; + }; + + [, $badFetchService] = $makeUserService($frameChunks(['contacts' => [123]], Opcode::CONTACT_INFO, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badFetchService): void { + $badFetchService->fetchUsers([80]); + }, 'Malformed contacts list items must fail fast like PyMax parse_payload_list'); + + [, $badSessionsService] = $makeUserService($frameChunks(['sessions' => [123]], Opcode::SESSIONS_INFO, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badSessionsService): void { + $badSessionsService->getSessions(); + }, 'Malformed sessions list items must fail fast like PyMax parse_payload_list'); + + [, $badImportService] = $makeUserService($frameChunks(['contacts' => [123]], Opcode::SYNC, 0)); + $assertThrows(\PHPMax\Exception\PHPMaxException::class, static function () use ($badImportService): void { + $badImportService->importContacts([new ContactInfo(['phone' => '+79990000080'])]); + }, 'Malformed imported contacts list items must fail fast like PyMax parse_payload_list'); +}; diff --git a/tests-php/bootstrap.php b/tests-php/bootstrap.php new file mode 100644 index 0000000..4e60e15 --- /dev/null +++ b/tests-php/bootstrap.php @@ -0,0 +1,17 @@ + $args + * @return array + */ +function parseArguments(array $args): array +{ + $options = []; + foreach ($args as $arg) { + if ($arg === '--dry-run') { + $options['dry-run'] = true; + continue; + } + if ($arg === '--check') { + $options['check'] = true; + continue; + } + if (strpos($arg, '--output=') === 0) { + $options['output'] = substr($arg, strlen('--output=')); + continue; + } + if ($arg === '--help' || $arg === '-h') { + fwrite(STDOUT, "Usage: php tools/build-release.php [--dry-run|--check] [--output=dist/phpmax-dev.zip]\n"); + exit(0); + } + + fwrite(STDERR, 'Unknown argument: ' . $arg . PHP_EOL); + exit(2); + } + + return $options; +} + +/** + * @return array + */ +function buildReleaseSpec(string $repoRoot): array +{ + $composer = json_decode((string) file_get_contents($repoRoot . '/composer.json'), true); + if (!is_array($composer)) { + fwrite(STDERR, "composer.json is not valid JSON.\n"); + exit(1); + } + + $runtimePackages = runtimeComposerPackages(isset($composer['require']) && is_array($composer['require']) ? $composer['require'] : []); + $vendorExists = is_dir($repoRoot . '/vendor'); + + $files = [ + 'autoload.php', + 'composer.json', + ]; + foreach (['LICENSE', 'README.md'] as $file) { + if (is_file($repoRoot . '/' . $file)) { + $files[] = $file; + } + } + + foreach (listFiles($repoRoot . '/src/PHPMax', 'src/PHPMax') as $file) { + $files[] = $file; + } + foreach (listFiles($repoRoot . '/docs/phpmax', 'docs/phpmax') as $file) { + $files[] = $file; + } + if ($vendorExists) { + foreach (listFiles($repoRoot . '/vendor', 'vendor') as $file) { + $files[] = $file; + } + } + + sort($files); + + return [ + 'name' => isset($composer['name']) ? (string) $composer['name'] : 'phpmax', + 'runtime_packages' => $runtimePackages, + 'vendor_included' => $vendorExists, + 'files' => array_values(array_unique($files)), + ]; +} + +/** + * @param array $requirements + * @return array + */ +function runtimeComposerPackages(array $requirements): array +{ + $packages = []; + foreach ($requirements as $name => $constraint) { + if ($name === 'php' || strpos($name, 'ext-') === 0 || strpos($name, 'lib-') === 0) { + continue; + } + $packages[$name] = (string) $constraint; + } + ksort($packages); + + return $packages; +} + +/** + * @param array $spec + */ +function validateReleaseSpec(array $spec): void +{ + $runtimePackages = isset($spec['runtime_packages']) && is_array($spec['runtime_packages']) ? $spec['runtime_packages'] : []; + $vendorIncluded = !empty($spec['vendor_included']); + + if ($runtimePackages !== [] && !$vendorIncluded) { + fwrite(STDERR, "Runtime Composer packages are required, but vendor/ is missing.\n"); + fwrite(STDERR, "Run composer install --no-dev before building the shared-hosting ZIP.\n"); + exit(1); + } +} + +/** + * @param array $spec + */ +function buildZip(string $repoRoot, string $output, array $spec): void +{ + $stage = sys_get_temp_dir() . '/phpmax-release-' . getmypid() . '-' . mt_rand(); + if (!mkdir($stage, 0700, true) && !is_dir($stage)) { + fwrite(STDERR, 'Unable to create release staging directory: ' . $stage . PHP_EOL); + exit(1); + } + + try { + foreach ($spec['files'] as $relativePath) { + if ($relativePath === 'autoload.php') { + writeFile($stage . '/autoload.php', releaseAutoload()); + continue; + } + + copyReleaseFile($repoRoot, $stage, (string) $relativePath); + } + + $outputDir = dirname($output); + if (!is_dir($outputDir) && !mkdir($outputDir, 0755, true) && !is_dir($outputDir)) { + fwrite(STDERR, 'Unable to create release output directory: ' . $outputDir . PHP_EOL); + exit(1); + } + if (is_file($output) && !unlink($output)) { + fwrite(STDERR, 'Unable to replace existing archive: ' . $output . PHP_EOL); + exit(1); + } + + if (class_exists('ZipArchive')) { + buildWithZipArchive($stage, $output); + } elseif (commandExists('zip')) { + buildWithZipCommand($stage, $output); + } else { + fwrite(STDERR, "Neither ext-zip nor zip command is available.\n"); + exit(1); + } + } finally { + removeTree($stage); + } +} + +function releaseAutoload(): string +{ + return <<<'PHP' + + */ +function listFiles(string $directory, string $prefix): array +{ + if (!is_dir($directory)) { + return []; + } + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile()) { + continue; + } + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($directory) + 1)); + $files[] = $prefix . '/' . $relative; + } + sort($files); + + return $files; +} + +function buildWithZipArchive(string $stage, string $output): void +{ + $zip = new ZipArchive(); + if ($zip->open($output, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, 'Unable to create archive: ' . $output . PHP_EOL); + exit(1); + } + + $files = listFiles($stage, ''); + foreach ($files as $relativePath) { + $path = $stage . '/' . ltrim($relativePath, '/'); + $zip->addFile($path, ltrim($relativePath, '/')); + } + $zip->close(); +} + +function buildWithZipCommand(string $stage, string $output): void +{ + $current = getcwd(); + if ($current === false || !chdir($stage)) { + fwrite(STDERR, 'Unable to enter release staging directory: ' . $stage . PHP_EOL); + exit(1); + } + + $command = 'zip -qr ' . escapeshellarg($output) . ' .'; + $lines = []; + $exitCode = 1; + exec($command, $lines, $exitCode); + chdir($current); + + if ($exitCode !== 0) { + fwrite(STDERR, 'zip command failed with exit code ' . $exitCode . PHP_EOL); + exit(1); + } +} + +function commandExists(string $command): bool +{ + $result = trim((string) shell_exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null')); + + return $result !== ''; +} + +function writeFile(string $path, string $contents): void +{ + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + fwrite(STDERR, 'Unable to create directory: ' . $dir . PHP_EOL); + exit(1); + } + if (file_put_contents($path, $contents) === false) { + fwrite(STDERR, 'Unable to write file: ' . $path . PHP_EOL); + exit(1); + } +} + +function removeTree(string $path): void +{ + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + if ($file->isDir() && !$file->isLink()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + @rmdir($path); +} + +function absolutePath(string $repoRoot, string $path): string +{ + if ($path === '') { + fwrite(STDERR, "Output path cannot be empty.\n"); + exit(2); + } + if ($path[0] === DIRECTORY_SEPARATOR) { + return $path; + } + + return $repoRoot . '/' . $path; +} diff --git a/tools/contract-manifest.php b/tools/contract-manifest.php new file mode 100644 index 0000000..b2d4fcc --- /dev/null +++ b/tools/contract-manifest.php @@ -0,0 +1,1292 @@ + phpConstants(AccessType::class), + 'AttachmentType' => phpConstants(AttachmentType::class), + 'ChatType' => phpConstants(ChatType::class), + 'MessageStatus' => phpConstants(MessageStatus::class), + 'TranscriptionStatus' => phpConstants(TranscriptionStatus::class), +]; +$phpApiEnums = [ + 'auth.AuthType' => phpConstants(AuthType::class), + 'auth.ProfileOptions' => phpConstants(ProfileOptions::class), + 'auth.TwoFactorAction' => phpConstants(TwoFactorAction::class), + 'chats.ChatLinkPrefix' => phpConstants(ChatLinkPrefix::class), + 'chats.ChatMemberOperation' => phpConstants(ChatMemberOperation::class), + 'chats.ChatOption' => phpConstants(ChatOption::class), + 'chats.ChatPayloadKey' => phpConstants(ChatPayloadKey::class), + 'chats.ControlEvent' => phpConstants(ControlEvent::class), + 'messages.ItemType' => phpConstants(ItemType::class), + 'messages.MessagePayloadKey' => phpConstants(MessagePayloadKey::class), + 'messages.ReadAction' => phpConstants(ReadAction::class), + 'self.AvatarType' => phpConstants(AvatarType::class), + 'self.SelfPayloadKey' => phpConstants(SelfPayloadKey::class), + 'session.DeviceType' => phpConstants(DeviceType::class), + 'users.ContactAction' => phpConstants(ContactAction::class), + 'users.UserPayloadKey' => phpConstants(UserPayloadKey::class), +]; + +compareMaps($manifest['commands'], $phpCommands, 'Command', $errors); +compareMaps($manifest['opcodes'], $phpOpcodes, 'Opcode', $errors); +compareMaps($manifest['event_types'], $phpEventTypes, 'EventType', $errors); +foreach ($manifest['domain_enums'] as $name => $values) { + compareMaps($values, $phpDomainEnums[$name] ?? [], 'Domain enum ' . $name, $errors); +} +foreach ($manifest['api_enums'] as $name => $values) { + compareMaps($values, $phpApiEnums[$name] ?? [], 'API enum ' . $name, $errors); +} +compareEventResolverCoverage($repoRoot, array_keys($manifest['event_map']), $errors); +comparePayloadModelCoverage($manifest['payload_models'], $errors); +compareServiceMethodCoverage($manifest['service_methods'], $errors); +compareClientMethodCoverage($manifest['client_methods'], $errors); +compareServiceMethodParameterCoverage($manifest['service_method_params'], $errors); +compareClientMethodParameterCoverage($manifest['client_method_params'], $errors); +compareDomainModelCoverage($manifest['domain_models'], $errors); +compareEventModelCoverage($manifest['event_models'], $errors); + +if ($errors !== []) { + foreach ($errors as $error) { + fwrite(STDERR, $error . "\n"); + } + fwrite(STDERR, "Run: php tools/contract-manifest.php write\n"); + exit(1); +} + +fwrite(STDOUT, "Contract manifest is in sync.\n"); + +/** + * @return array + */ +function buildManifest(string $repoRoot): array +{ + $pyproject = parsePyproject($repoRoot . '/pyproject.toml'); + $pymaxVersion = isset($pyproject['version']) ? $pyproject['version'] : null; + + $manifest = [ + 'generated_from' => [ + 'pymax_version' => $pymaxVersion, + 'pymax_commit' => currentPyMaxReferenceCommit($repoRoot), + 'reference_paths' => [ + 'src/pymax/protocol/enums.py', + 'src/pymax/dispatch/enums.py', + 'src/pymax/dispatch/mapping.py', + 'src/pymax/api/*/enums.py', + 'src/pymax/types/domain/enums.py', + 'src/pymax/types/domain/attachments/enums.py', + 'src/pymax/api/*/payloads.py', + 'src/pymax/api/*/service.py', + 'src/pymax/infra/*.py', + 'src/pymax/types/domain/*.py', + 'src/pymax/types/domain/attachments/**/*.py', + 'src/pymax/types/events/*.py', + ], + ], + 'commands' => parsePythonEnum($repoRoot . '/src/pymax/protocol/enums.py', 'Command'), + 'opcodes' => parsePythonEnum($repoRoot . '/src/pymax/protocol/enums.py', 'Opcode'), + 'event_types' => parsePythonEnum($repoRoot . '/src/pymax/dispatch/enums.py', 'EventType'), + 'domain_enums' => [ + 'AccessType' => parsePythonEnum($repoRoot . '/src/pymax/types/domain/enums.py', 'AccessType'), + 'AttachmentType' => parsePythonEnum($repoRoot . '/src/pymax/types/domain/attachments/enums.py', 'AttachmentType'), + 'ChatType' => parsePythonEnum($repoRoot . '/src/pymax/types/domain/enums.py', 'ChatType'), + 'MessageStatus' => parsePythonEnum($repoRoot . '/src/pymax/types/domain/enums.py', 'MessageStatus'), + 'TranscriptionStatus' => parsePythonEnum($repoRoot . '/src/pymax/types/domain/attachments/enums.py', 'TranscriptionStatus'), + ], + 'api_enums' => [ + 'auth.AuthType' => parsePythonEnum($repoRoot . '/src/pymax/api/auth/enums.py', 'AuthType'), + 'auth.ProfileOptions' => parsePythonEnum($repoRoot . '/src/pymax/api/auth/enums.py', 'ProfileOptions'), + 'auth.TwoFactorAction' => parsePythonEnum($repoRoot . '/src/pymax/api/auth/enums.py', 'TwoFactorAction'), + 'chats.ChatLinkPrefix' => parsePythonEnum($repoRoot . '/src/pymax/api/chats/enums.py', 'ChatLinkPrefix'), + 'chats.ChatMemberOperation' => parsePythonEnum($repoRoot . '/src/pymax/api/chats/enums.py', 'ChatMemberOperation'), + 'chats.ChatOption' => parsePythonEnum($repoRoot . '/src/pymax/api/chats/enums.py', 'ChatOption'), + 'chats.ChatPayloadKey' => parsePythonEnum($repoRoot . '/src/pymax/api/chats/enums.py', 'ChatPayloadKey'), + 'chats.ControlEvent' => parsePythonEnum($repoRoot . '/src/pymax/api/chats/enums.py', 'ControlEvent'), + 'messages.ItemType' => parsePythonEnum($repoRoot . '/src/pymax/api/messages/enums.py', 'ItemType'), + 'messages.MessagePayloadKey' => parsePythonEnum($repoRoot . '/src/pymax/api/messages/enums.py', 'MessagePayloadKey'), + 'messages.ReadAction' => parsePythonEnum($repoRoot . '/src/pymax/api/messages/enums.py', 'ReadAction'), + 'self.AvatarType' => parsePythonEnum($repoRoot . '/src/pymax/api/self/enums.py', 'AvatarType'), + 'self.SelfPayloadKey' => parsePythonEnum($repoRoot . '/src/pymax/api/self/enums.py', 'SelfPayloadKey'), + 'session.DeviceType' => parsePythonEnum($repoRoot . '/src/pymax/api/session/enums.py', 'DeviceType'), + 'users.ContactAction' => parsePythonEnum($repoRoot . '/src/pymax/api/users/enums.py', 'ContactAction'), + 'users.UserPayloadKey' => parsePythonEnum($repoRoot . '/src/pymax/api/users/enums.py', 'UserPayloadKey'), + ], + 'event_map' => parsePythonEventMap($repoRoot . '/src/pymax/dispatch/mapping.py'), + 'payload_models' => parsePythonPayloadModels($repoRoot . '/src/pymax/api'), + 'service_methods' => parsePythonServiceMethods($repoRoot . '/src/pymax/api'), + 'client_methods' => parsePythonClientMethods($repoRoot . '/src/pymax/infra'), + 'service_method_params' => parsePythonServiceMethodParams($repoRoot . '/src/pymax/api'), + 'client_method_params' => parsePythonClientMethodParams($repoRoot . '/src/pymax/infra'), + 'domain_models' => parsePythonDomainModels($repoRoot . '/src/pymax/types/domain'), + 'event_models' => parsePythonEventModels($repoRoot . '/src/pymax/types/events'), + ]; + + ksort($manifest['commands']); + ksort($manifest['opcodes']); + ksort($manifest['event_types']); + ksort($manifest['domain_enums']); + foreach ($manifest['domain_enums'] as &$values) { + ksort($values); + } + unset($values); + ksort($manifest['api_enums']); + foreach ($manifest['api_enums'] as &$values) { + ksort($values); + } + unset($values); + ksort($manifest['event_map']); + ksort($manifest['payload_models']); + ksort($manifest['service_methods']); + ksort($manifest['client_methods']); + ksort($manifest['service_method_params']); + foreach ($manifest['service_method_params'] as &$methods) { + ksort($methods); + } + unset($methods); + ksort($manifest['client_method_params']); + ksort($manifest['domain_models']); + ksort($manifest['event_models']); + + return $manifest; +} + +/** + * @return array + */ +function parsePyproject(string $path): array +{ + $result = []; + foreach (readLines($path) as $line) { + if (preg_match('/^([A-Za-z0-9_-]+)\s*=\s*"([^"]+)"/', trim($line), $matches)) { + $result[$matches[1]] = $matches[2]; + } + } + + return $result; +} + +/** + * @return array + */ +function parsePythonEnum(string $path, string $className): array +{ + $result = []; + $inside = false; + foreach (readLines($path) as $line) { + if (preg_match('/^class\s+' . preg_quote($className, '/') . '\b/', $line)) { + $inside = true; + continue; + } + if ($inside && preg_match('/^class\s+\w+\b/', $line)) { + break; + } + if (!$inside) { + continue; + } + if (preg_match('/^\s{4}([A-Z][A-Z0-9_]*)\s*=\s*(.+?)\s*$/', $line, $matches)) { + $rawValue = trim($matches[2]); + if (preg_match('/^-?\d+$/', $rawValue)) { + $result[$matches[1]] = (int) $rawValue; + continue; + } + if (preg_match('/^"([^"]*)"$/', $rawValue, $valueMatches)) { + $result[$matches[1]] = $valueMatches[1]; + continue; + } + } + } + + if ($result === []) { + throw new RuntimeException('Unable to parse Python enum ' . $className . ' from ' . $path); + } + + return $result; +} + +/** + * @return array + */ +function parsePythonEventMap(string $path): array +{ + $result = []; + $inside = false; + foreach (readLines($path) as $line) { + if (strpos($line, 'EVENT_MAP') !== false && strpos($line, '{') !== false) { + $inside = true; + continue; + } + if ($inside && preg_match('/^\}/', $line)) { + break; + } + if (!$inside) { + continue; + } + if (preg_match('/^\s{4}Opcode\.([A-Z0-9_]+):\s*(\w+),/', $line, $matches)) { + $result[$matches[1]] = $matches[2]; + } + } + + if ($result === []) { + throw new RuntimeException('Unable to parse Python EVENT_MAP from ' . $path); + } + + return $result; +} + +/** + * @return array> + */ +function parsePythonPayloadModels(string $apiRoot): array +{ + $result = []; + $files = glob($apiRoot . '/*/payloads.py') ?: []; + sort($files); + $aliasEnums = [ + 'ChatOption' => parsePythonEnum($apiRoot . '/chats/enums.py', 'ChatOption'), + ]; + + foreach ($files as $file) { + $domain = basename(dirname($file)); + $className = null; + $fields = []; + $payloadKeys = []; + $lines = readLines($file); + $lineCount = count($lines); + for ($index = 0; $index < $lineCount; $index++) { + $line = $lines[$index]; + if (preg_match('/^class\s+([A-Za-z_][A-Za-z0-9_]*)\b/', $line, $matches)) { + if ($className !== null && $fields !== []) { + $result[$domain . '.' . $className] = [ + 'fields' => array_values(array_unique($fields)), + 'payload_keys' => $payloadKeys, + ]; + } + $className = $matches[1]; + $fields = []; + $payloadKeys = []; + continue; + } + if ($className !== null && preg_match('/^\s{4}([a-z_][A-Za-z0-9_]*)\s*:(.*)$/', $line, $matches)) { + $fieldName = $matches[1]; + $expression = collectPythonFieldExpression($lines, $index, $matches[2]); + $fields[] = $fieldName; + $payloadKeys[$fieldName] = pythonPayloadKey($fieldName, $expression, $aliasEnums); + } + } + if ($className !== null && $fields !== []) { + $result[$domain . '.' . $className] = [ + 'fields' => array_values(array_unique($fields)), + 'payload_keys' => $payloadKeys, + ]; + } + } + + return $result; +} + +/** + * @return array> + */ +function parsePythonDomainModels(string $domainRoot): array +{ + $targets = [ + 'attachments/audio.py' => ['AudioAttachment'], + 'attachments/call.py' => ['CallAttachment'], + 'attachments/contact.py' => ['ContactAttachment'], + 'attachments/control.py' => ['ControlAttachment'], + 'attachments/file.py' => ['FileAttachment', 'FileRequest'], + 'attachments/keyboards/inline.py' => ['InlineKeyboardAttachment'], + 'attachments/photo.py' => ['PhotoAttachment'], + 'attachments/share.py' => ['ShareAttachment'], + 'attachments/sticker.py' => ['StickerAttachment'], + 'attachments/unknown.py' => ['UnknownAttachment'], + 'attachments/video.py' => ['VideoAttachment', 'VideoRequest'], + 'auth.py' => [ + 'CheckCodeResponse', + 'CheckPasswordResponse', + 'CheckQrResponse', + 'ConfirmRegistrationResponse', + 'PasswordChallenge', + 'QrStatus', + 'RequestQrResponse', + 'StartAuthResponse', + 'Token', + 'TokenAttrs', + ], + 'bots.py' => ['InitData'], + 'chat.py' => ['Chat'], + 'element.py' => ['Element', 'ElementAttributes'], + 'error.py' => ['MaxApiError'], + 'folder.py' => ['Folder', 'FolderList', 'FolderUpdate'], + 'login.py' => ['LoginConfig', 'LoginResponse'], + 'member.py' => ['Member'], + 'message.py' => ['Message', 'ReactionCounter', 'ReactionInfo', 'ReadState'], + 'name.py' => ['Name'], + 'presence.py' => ['Presence'], + 'profile.py' => ['Profile'], + 'session.py' => ['Session'], + 'sync.py' => ['SyncOverrides', 'SyncState'], + 'user.py' => ['ContactInfo', 'User'], + ]; + + $result = []; + foreach ($targets as $fileName => $classNames) { + $path = $domainRoot . '/' . $fileName; + foreach (parsePythonModelClasses($path, $classNames) as $className => $definition) { + $result[$className] = $definition; + } + } + + return $result; +} + +/** + * @return array> + */ +function parsePythonEventModels(string $eventsRoot): array +{ + $targets = [ + 'file.py' => ['FileUploadSignal'], + 'mark.py' => ['MessageReadEvent'], + 'message.py' => ['MessageDeleteEvent'], + 'presence.py' => ['PresenceEvent'], + 'reaction.py' => ['ReactionUpdateEvent'], + 'typing.py' => ['TypingEvent'], + 'video.py' => ['VideoUploadSignal'], + ]; + + $result = []; + foreach ($targets as $fileName => $classNames) { + $path = $eventsRoot . '/' . $fileName; + foreach (parsePythonModelClasses($path, $classNames) as $className => $definition) { + $result[$className] = $definition; + } + } + + return $result; +} + +/** + * @param list $classNames + * @return array> + */ +function parsePythonModelClasses(string $path, array $classNames): array +{ + $wanted = array_fill_keys($classNames, true); + $result = []; + $className = null; + $useCamelAlias = true; + $fields = []; + $payloadKeys = []; + $lines = readLines($path); + $lineCount = count($lines); + + for ($index = 0; $index < $lineCount; $index++) { + $line = $lines[$index]; + if (preg_match('/^class\s+([A-Za-z_][A-Za-z0-9_]*)\b/', $line, $matches)) { + if ($className !== null && $fields !== []) { + $result[$className] = [ + 'fields' => array_values(array_unique($fields)), + 'payload_keys' => $payloadKeys, + ]; + } + $className = isset($wanted[$matches[1]]) ? $matches[1] : null; + $useCamelAlias = strpos($line, 'CamelModel') !== false; + $fields = []; + $payloadKeys = []; + continue; + } + if ($className !== null && preg_match('/^\s{4}([a-z][A-Za-z0-9_]*)\s*:(.*)$/', $line, $matches)) { + $fieldName = $matches[1]; + if (strpos($fieldName, '_') === 0) { + continue; + } + $expression = collectPythonFieldExpression($lines, $index, $matches[2]); + $fields[] = $fieldName; + $payloadKeys[$fieldName] = pythonPayloadKey($fieldName, $expression, [], $useCamelAlias); + } + } + if ($className !== null && $fields !== []) { + $result[$className] = [ + 'fields' => array_values(array_unique($fields)), + 'payload_keys' => $payloadKeys, + ]; + } + + foreach ($classNames as $wantedClass) { + if (!isset($result[$wantedClass])) { + throw new RuntimeException('Unable to parse Python domain model ' . $wantedClass . ' from ' . $path); + } + } + + return $result; +} + +/** + * @return array> + */ +function parsePythonServiceMethods(string $apiRoot): array +{ + $result = []; + $files = glob($apiRoot . '/*/service.py') ?: []; + sort($files); + + foreach ($files as $file) { + $domain = basename(dirname($file)); + $methods = []; + foreach (parsePythonPublicMethods($file) as $pythonMethod => $definition) { + $methods[$pythonMethod] = phpServiceMethodName($domain, $pythonMethod); + } + ksort($methods); + $result[$domain] = $methods; + } + + return $result; +} + +/** + * @return array>> + */ +function parsePythonServiceMethodParams(string $apiRoot): array +{ + $result = []; + $files = glob($apiRoot . '/*/service.py') ?: []; + sort($files); + + foreach ($files as $file) { + $domain = basename(dirname($file)); + $methods = []; + foreach (parsePythonPublicMethods($file) as $pythonMethod => $definition) { + $methods[$pythonMethod] = [ + 'php_method' => phpServiceMethodName($domain, $pythonMethod), + 'params' => $definition['params'], + 'php_params' => pythonParamsToPhpParams($definition['params']), + ]; + } + ksort($methods); + $result[$domain] = $methods; + } + + return $result; +} + +function phpServiceMethodName(string $domain, string $pythonMethod): string +{ + $overrides = [ + 'auth.check_2fa' => 'checkTwoFactor', + 'auth.remove_2fa' => 'removeTwoFactor', + 'auth.set_2fa' => 'setTwoFactor', + ]; + $key = $domain . '.' . $pythonMethod; + + return isset($overrides[$key]) ? $overrides[$key] : pythonSnakeToCamel($pythonMethod); +} + +/** + * @return array + */ +function parsePythonClientMethods(string $infraRoot): array +{ + $result = []; + $files = glob($infraRoot . '/*.py') ?: []; + sort($files); + $skip = ['__init__.py' => true, 'base.py' => true, 'protocol.py' => true]; + + foreach ($files as $file) { + if (isset($skip[basename($file)])) { + continue; + } + foreach (parsePythonPublicMethods($file) as $pythonMethod => $definition) { + $result[$pythonMethod] = phpClientMethodName($pythonMethod); + } + } + ksort($result); + + return $result; +} + +/** + * @return array, php_params: list}> + */ +function parsePythonClientMethodParams(string $infraRoot): array +{ + $result = []; + $files = glob($infraRoot . '/*.py') ?: []; + sort($files); + $skip = ['__init__.py' => true, 'base.py' => true, 'protocol.py' => true]; + + foreach ($files as $file) { + if (isset($skip[basename($file)])) { + continue; + } + foreach (parsePythonPublicMethods($file) as $pythonMethod => $definition) { + $result[$pythonMethod] = [ + 'php_method' => phpClientMethodName($pythonMethod), + 'params' => $definition['params'], + 'php_params' => pythonParamsToPhpParams($definition['params']), + ]; + } + } + ksort($result); + + return $result; +} + +function phpClientMethodName(string $pythonMethod): string +{ + $overrides = [ + 'check_2fa' => 'checkTwoFactor', + 'remove_2fa' => 'removeTwoFactor', + 'set_2fa' => 'setTwoFactor', + ]; + + return isset($overrides[$pythonMethod]) ? $overrides[$pythonMethod] : pythonSnakeToCamel($pythonMethod); +} + +/** + * @return array}> + */ +function parsePythonPublicMethods(string $path): array +{ + $result = []; + $lines = readLines($path); + $lineCount = count($lines); + + for ($index = 0; $index < $lineCount; $index++) { + $line = $lines[$index]; + if (!preg_match('/^\s{4}(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/', $line, $matches)) { + continue; + } + $pythonMethod = $matches[1]; + if (strpos($pythonMethod, '_') === 0) { + continue; + } + + $signature = trim($line); + $balance = pythonExpressionBalance($signature); + while ($balance > 0 && isset($lines[$index + 1])) { + $index++; + $signature .= ' ' . trim($lines[$index]); + $balance += pythonExpressionBalance(trim($lines[$index])); + } + + $result[$pythonMethod] = [ + 'params' => parsePythonSignatureParams($signature), + ]; + } + + return $result; +} + +/** + * @return list + */ +function parsePythonSignatureParams(string $signature): array +{ + $start = strpos($signature, '('); + if ($start === false) { + return []; + } + $end = findMatchingParen($signature, $start); + if ($end === null) { + return []; + } + + $inside = substr($signature, $start + 1, $end - $start - 1); + $params = []; + foreach (splitTopLevelComma($inside) as $rawParam) { + $rawParam = trim($rawParam); + if ($rawParam === '' || $rawParam === '*' || strpos($rawParam, '**') === 0) { + continue; + } + if (strpos($rawParam, '*') === 0) { + $rawParam = ltrim($rawParam, '*'); + } + $name = preg_split('/[:=]/', $rawParam, 2)[0] ?? ''; + $name = trim($name); + if ($name === '' || $name === 'self' || $name === 'cls' || $name === '_') { + continue; + } + $params[] = $name; + } + + return $params; +} + +function findMatchingParen(string $value, int $start): ?int +{ + $depth = 0; + $length = strlen($value); + for ($offset = $start; $offset < $length; $offset++) { + $char = $value[$offset]; + if ($char === '(') { + $depth++; + continue; + } + if ($char === ')') { + $depth--; + if ($depth === 0) { + return $offset; + } + } + } + + return null; +} + +/** + * @return list + */ +function splitTopLevelComma(string $value): array +{ + $parts = []; + $current = ''; + $depth = 0; + $length = strlen($value); + for ($offset = 0; $offset < $length; $offset++) { + $char = $value[$offset]; + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + $depth--; + } + if ($char === ',' && $depth === 0) { + $parts[] = trim($current); + $current = ''; + continue; + } + $current .= $char; + } + if (trim($current) !== '') { + $parts[] = trim($current); + } + + return $parts; +} + +/** + * @param list $params + * @return list + */ +function pythonParamsToPhpParams(array $params): array +{ + return array_map(static function (string $param): string { + return pythonSnakeToCamel($param); + }, $params); +} + +/** + * @param list $lines + */ +function collectPythonFieldExpression(array $lines, int &$index, string $rest): string +{ + $equalsPosition = strpos($rest, '='); + if ($equalsPosition === false) { + return ''; + } + + $expression = trim(substr($rest, $equalsPosition + 1)); + $balance = pythonExpressionBalance($expression); + while ($balance > 0 && isset($lines[$index + 1])) { + $nextLine = $lines[$index + 1]; + if (!preg_match('/^\s{8,}\S|^\s*$/', $nextLine)) { + break; + } + $index++; + $expression .= ' ' . trim($nextLine); + $balance += pythonExpressionBalance(trim($nextLine)); + } + + return $expression; +} + +function pythonExpressionBalance(string $expression): int +{ + return substr_count($expression, '(') + substr_count($expression, '[') + substr_count($expression, '{') + - substr_count($expression, ')') - substr_count($expression, ']') - substr_count($expression, '}'); +} + +/** + * @param array> $aliasEnums + */ +function pythonPayloadKey(string $fieldName, string $expression, array $aliasEnums, bool $useCamelAlias = true): string +{ + foreach (['serialization_alias', 'alias'] as $aliasArgument) { + if (preg_match('/' . $aliasArgument . '\s*=\s*"([^"]+)"/', $expression, $matches)) { + return $matches[1]; + } + if (preg_match('/' . $aliasArgument . '\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\.([A-Z][A-Z0-9_]*)\.value/', $expression, $matches)) { + $enumName = $matches[1]; + $constantName = $matches[2]; + if (isset($aliasEnums[$enumName][$constantName])) { + return (string) $aliasEnums[$enumName][$constantName]; + } + } + } + + return $useCamelAlias ? pythonSnakeToCamel($fieldName) : $fieldName; +} + +function pythonSnakeToCamel(string $fieldName): string +{ + $fieldName = rtrim($fieldName, '_'); + + return preg_replace_callback('/_([a-z])/', static function (array $matches): string { + return strtoupper($matches[1]); + }, $fieldName) ?? $fieldName; +} + +/** + * @return array + */ +function phpConstants(string $className): array +{ + $reflection = new ReflectionClass($className); + $constants = $reflection->getConstants(); + ksort($constants); + + return $constants; +} + +/** + * @param array $expected + * @param array $actual + * @param list $errors + */ +function compareMaps(array $expected, array $actual, string $label, array &$errors): void +{ + if ($expected === $actual) { + return; + } + + foreach ($expected as $name => $value) { + if (!array_key_exists($name, $actual)) { + $errors[] = $label . ' missing in PHP: ' . $name; + } elseif ($actual[$name] !== $value) { + $errors[] = $label . ' value mismatch for ' . $name . ': expected ' . var_export($value, true) . ', got ' . var_export($actual[$name], true); + } + } + foreach ($actual as $name => $value) { + if (!array_key_exists($name, $expected)) { + $errors[] = $label . ' extra in PHP: ' . $name; + } + } +} + +/** + * @param list $referenceOpcodes + * @param list $errors + */ +function compareEventResolverCoverage(string $repoRoot, array $referenceOpcodes, array &$errors): void +{ + $path = $repoRoot . '/src/PHPMax/Dispatch/EventResolver.php'; + $contents = (string) file_get_contents($path); + preg_match_all('/Opcode::([A-Z0-9_]+)/', $contents, $matches); + $phpOpcodes = array_values(array_unique($matches[1])); + sort($phpOpcodes); + sort($referenceOpcodes); + + foreach ($referenceOpcodes as $opcode) { + if (!in_array($opcode, $phpOpcodes, true)) { + $errors[] = 'EventResolver does not cover PyMax event opcode: ' . $opcode; + } + } + foreach ($phpOpcodes as $opcode) { + if (!in_array($opcode, $referenceOpcodes, true)) { + $errors[] = 'EventResolver covers opcode not present in PyMax EVENT_MAP: ' . $opcode; + } + } +} + +/** + * @param array> $payloadModels + * @param list $errors + */ +function comparePayloadModelCoverage(array $payloadModels, array &$errors): void +{ + foreach ($payloadModels as $referenceName => $definition) { + $className = phpPayloadClassName($referenceName); + if ($className === null || !class_exists($className)) { + $errors[] = 'Payload model missing in PHP: ' . $referenceName; + continue; + } + + $expectedKeys = []; + if (isset($definition['payload_keys']) && is_array($definition['payload_keys'])) { + $expectedKeys = array_values($definition['payload_keys']); + } + $actualKeys = phpPayloadKeys($className); + sort($expectedKeys); + sort($actualKeys); + if ($expectedKeys === $actualKeys) { + continue; + } + + foreach ($expectedKeys as $key) { + if (!in_array($key, $actualKeys, true)) { + $errors[] = 'Payload model ' . $referenceName . ' missing PHP serialized key: ' . $key; + } + } + foreach ($actualKeys as $key) { + if (!in_array($key, $expectedKeys, true)) { + $errors[] = 'Payload model ' . $referenceName . ' has extra PHP serialized key: ' . $key; + } + } + } +} + +/** + * @param array> $serviceMethods + * @param list $errors + */ +function compareServiceMethodCoverage(array $serviceMethods, array &$errors): void +{ + $classes = phpServiceClasses(); + + foreach ($serviceMethods as $domain => $methods) { + if (!isset($classes[$domain]) || !class_exists($classes[$domain])) { + $errors[] = 'Service missing in PHP for PyMax api domain: ' . $domain; + continue; + } + foreach ($methods as $pythonMethod => $phpMethod) { + if (!method_exists($classes[$domain], $phpMethod)) { + $errors[] = 'Service method missing in PHP: ' . $domain . '.' . $pythonMethod . ' -> ' . $phpMethod; + } + } + } +} + +/** + * @param array $clientMethods + * @param list $errors + */ +function compareClientMethodCoverage(array $clientMethods, array &$errors): void +{ + foreach ($clientMethods as $pythonMethod => $phpMethod) { + if (!method_exists('PHPMax\\Client', $phpMethod)) { + $errors[] = 'Client method missing in PHP: ' . $pythonMethod . ' -> ' . $phpMethod; + } + } +} + +/** + * @return array + */ +function phpServiceClasses(): array +{ + return [ + 'auth' => 'PHPMax\\Api\\Auth\\AuthService', + 'bots' => 'PHPMax\\Api\\Bots\\BotsService', + 'chats' => 'PHPMax\\Api\\Chats\\ChatService', + 'messages' => 'PHPMax\\Api\\Messages\\MessageService', + 'self' => 'PHPMax\\Api\\Account\\AccountService', + 'session' => 'PHPMax\\Api\\Session\\SessionService', + 'uploads' => 'PHPMax\\Api\\Uploads\\UploadService', + 'users' => 'PHPMax\\Api\\Users\\UserService', + ]; +} + +/** + * @param array>> $serviceMethodParams + * @param list $errors + */ +function compareServiceMethodParameterCoverage(array $serviceMethodParams, array &$errors): void +{ + $classes = phpServiceClasses(); + + foreach ($serviceMethodParams as $domain => $methods) { + if (!isset($classes[$domain]) || !class_exists($classes[$domain])) { + $errors[] = 'Service missing in PHP for PyMax api domain: ' . $domain; + continue; + } + foreach ($methods as $pythonMethod => $definition) { + $phpMethod = isset($definition['php_method']) ? (string) $definition['php_method'] : phpServiceMethodName($domain, $pythonMethod); + if (!method_exists($classes[$domain], $phpMethod)) { + continue; + } + $expected = isset($definition['php_params']) && is_array($definition['php_params']) + ? array_values($definition['php_params']) + : []; + $actual = phpMethodParameterNames($classes[$domain], $phpMethod); + if ($expected !== $actual) { + $errors[] = 'Service method parameter mismatch: ' . $domain . '.' . $pythonMethod + . ' -> ' . $phpMethod . ' expected [' . implode(', ', $expected) . '] got [' . implode(', ', $actual) . ']'; + } + } + } +} + +/** + * @param array> $clientMethodParams + * @param list $errors + */ +function compareClientMethodParameterCoverage(array $clientMethodParams, array &$errors): void +{ + foreach ($clientMethodParams as $pythonMethod => $definition) { + $phpMethod = isset($definition['php_method']) ? (string) $definition['php_method'] : phpClientMethodName($pythonMethod); + if (!method_exists('PHPMax\\Client', $phpMethod)) { + continue; + } + $expected = isset($definition['php_params']) && is_array($definition['php_params']) + ? array_values($definition['php_params']) + : []; + $actual = phpMethodParameterNames('PHPMax\\Client', $phpMethod); + if ($expected !== $actual) { + $errors[] = 'Client method parameter mismatch: ' . $pythonMethod + . ' -> ' . $phpMethod . ' expected [' . implode(', ', $expected) . '] got [' . implode(', ', $actual) . ']'; + } + } +} + +/** + * @return list + */ +function phpMethodParameterNames(string $className, string $method): array +{ + $reflection = new ReflectionMethod($className, $method); + $params = []; + foreach ($reflection->getParameters() as $parameter) { + $params[] = $parameter->getName(); + } + + return $params; +} + +/** + * @param array> $domainModels + * @param list $errors + */ +function compareDomainModelCoverage(array $domainModels, array &$errors): void +{ + $classes = [ + 'AudioAttachment' => AudioAttachment::class, + 'CallAttachment' => CallAttachment::class, + 'Chat' => Chat::class, + 'CheckCodeResponse' => CheckCodeResponse::class, + 'CheckPasswordResponse' => CheckPasswordResponse::class, + 'CheckQrResponse' => CheckQrResponse::class, + 'ConfirmRegistrationResponse' => ConfirmRegistrationResponse::class, + 'ContactAttachment' => ContactAttachment::class, + 'ContactInfo' => ContactInfo::class, + 'ControlAttachment' => ControlAttachment::class, + 'Element' => Element::class, + 'ElementAttributes' => ElementAttributes::class, + 'FileAttachment' => FileAttachment::class, + 'FileRequest' => FileRequest::class, + 'Folder' => Folder::class, + 'FolderList' => FolderList::class, + 'FolderUpdate' => FolderUpdate::class, + 'InlineKeyboardAttachment' => InlineKeyboardAttachment::class, + 'InitData' => InitData::class, + 'LoginConfig' => LoginConfig::class, + 'LoginResponse' => LoginResponse::class, + 'MaxApiError' => MaxApiError::class, + 'Member' => Member::class, + 'Message' => Message::class, + 'Name' => Name::class, + 'PasswordChallenge' => PasswordChallenge::class, + 'PhotoAttachment' => PhotoAttachment::class, + 'Presence' => Presence::class, + 'Profile' => Profile::class, + 'QrStatus' => QrStatus::class, + 'ReactionCounter' => ReactionCounter::class, + 'ReactionInfo' => ReactionInfo::class, + 'ReadState' => ReadState::class, + 'RequestQrResponse' => RequestQrResponse::class, + 'Session' => Session::class, + 'ShareAttachment' => ShareAttachment::class, + 'StartAuthResponse' => StartAuthResponse::class, + 'StickerAttachment' => StickerAttachment::class, + 'SyncOverrides' => SyncOverrides::class, + 'SyncState' => SyncState::class, + 'Token' => Token::class, + 'TokenAttrs' => TokenAttrs::class, + 'UnknownAttachment' => UnknownAttachment::class, + 'User' => User::class, + 'VideoAttachment' => VideoAttachment::class, + 'VideoRequest' => VideoRequest::class, + ]; + + foreach ($domainModels as $name => $definition) { + if (!isset($classes[$name])) { + $errors[] = 'Domain model has no PHP class mapping: ' . $name; + continue; + } + $expectedKeys = []; + if (isset($definition['payload_keys']) && is_array($definition['payload_keys'])) { + $expectedKeys = array_values($definition['payload_keys']); + } + $actualKeys = phpPayloadKeys($classes[$name]); + sort($expectedKeys); + sort($actualKeys); + if ($expectedKeys === $actualKeys) { + continue; + } + + foreach ($expectedKeys as $key) { + if (!in_array($key, $actualKeys, true)) { + $errors[] = 'Domain model ' . $name . ' missing PHP serialized key: ' . $key; + } + } + foreach ($actualKeys as $key) { + if (!in_array($key, $expectedKeys, true)) { + $errors[] = 'Domain model ' . $name . ' has extra PHP serialized key: ' . $key; + } + } + } +} + +/** + * @param array> $eventModels + * @param list $errors + */ +function compareEventModelCoverage(array $eventModels, array &$errors): void +{ + $classes = [ + 'FileUploadSignal' => FileUploadSignal::class, + 'MessageDeleteEvent' => MessageDeleteEvent::class, + 'MessageReadEvent' => MessageReadEvent::class, + 'PresenceEvent' => PresenceEvent::class, + 'ReactionUpdateEvent' => ReactionUpdateEvent::class, + 'TypingEvent' => TypingEvent::class, + 'VideoUploadSignal' => VideoUploadSignal::class, + ]; + + foreach ($eventModels as $name => $definition) { + if (!isset($classes[$name])) { + $errors[] = 'Event model has no PHP class mapping: ' . $name; + continue; + } + $expectedKeys = []; + if (isset($definition['payload_keys']) && is_array($definition['payload_keys'])) { + $expectedKeys = array_values($definition['payload_keys']); + } + $actualKeys = phpPayloadKeys($classes[$name]); + sort($expectedKeys); + sort($actualKeys); + if ($expectedKeys === $actualKeys) { + continue; + } + + foreach ($expectedKeys as $key) { + if (!in_array($key, $actualKeys, true)) { + $errors[] = 'Event model ' . $name . ' missing PHP serialized key: ' . $key; + } + } + foreach ($actualKeys as $key) { + if (!in_array($key, $expectedKeys, true)) { + $errors[] = 'Event model ' . $name . ' has extra PHP serialized key: ' . $key; + } + } + } +} + +function phpPayloadClassName(string $referenceName): ?string +{ + $parts = explode('.', $referenceName, 2); + if (count($parts) !== 2) { + return null; + } + + $domain = $parts[0]; + $className = $parts[1]; + $namespaces = [ + 'auth' => 'PHPMax\\Api\\Auth\\', + 'bots' => 'PHPMax\\Api\\Bots\\', + 'chats' => 'PHPMax\\Api\\Chats\\', + 'messages' => 'PHPMax\\Api\\Messages\\', + 'self' => 'PHPMax\\Api\\Account\\', + 'session' => 'PHPMax\\Api\\Session\\', + 'uploads' => 'PHPMax\\Api\\Uploads\\', + 'users' => 'PHPMax\\Api\\Users\\', + ]; + if ($domain === 'users' && $className === '_ContactPayload') { + $className = 'ContactPayload'; + } + if (!isset($namespaces[$domain])) { + return null; + } + + return $namespaces[$domain] . $className; +} + +/** + * @return list + */ +function phpPayloadKeys(string $className): array +{ + $method = new ReflectionMethod($className, 'schema'); + $method->setAccessible(true); + $schema = $method->invoke(null); + if (!is_array($schema)) { + return []; + } + + $keys = []; + foreach ($schema as $property => $definition) { + if (is_array($definition) && isset($definition['payload'])) { + $keys[] = (string) $definition['payload']; + continue; + } + $keys[] = (string) $property; + } + + return array_values(array_unique($keys)); +} + +/** + * @return list + */ +function readLines(string $path): array +{ + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new RuntimeException('Unable to read file: ' . $path); + } + + return $lines; +} + +function currentPyMaxReferenceCommit(string $repoRoot): ?string +{ + $current = getcwd(); + if ($current === false || !chdir($repoRoot)) { + return null; + } + + $output = []; + $exitCode = 1; + exec('git log -1 --first-parent --format=%H -- src/pymax pyproject.toml 2>/dev/null', $output, $exitCode); + if ($exitCode !== 0 || !isset($output[0]) || trim($output[0]) === '') { + $output = []; + $exitCode = 1; + exec('git rev-parse HEAD 2>/dev/null', $output, $exitCode); + } + chdir($current); + + return $exitCode === 0 && isset($output[0]) ? trim($output[0]) : null; +} diff --git a/tools/integration-check.php b/tools/integration-check.php new file mode 100644 index 0000000..c2f3f1c --- /dev/null +++ b/tools/integration-check.php @@ -0,0 +1,668 @@ + */ + private $clientOptions = []; + + public function run(): int + { + if ($this->shouldPrintPlan()) { + $this->printPlan(); + return 0; + } + + if (!$this->flag('PHPMAX_INTEGRATION')) { + $this->out('SKIP integration checks: set PHPMAX_INTEGRATION=1 and PHPMAX_TOKEN to run real MAX checks.'); + return 0; + } + + $token = $this->env('PHPMAX_TOKEN'); + if ($token === null && !$this->flag('PHPMAX_AUTH_SMS')) { + $this->err('FAIL integration checks: PHPMAX_TOKEN is required when PHPMAX_INTEGRATION=1 unless PHPMAX_AUTH_SMS=1 is set.'); + return 2; + } + + try { + $this->prepareEnabledRun($token); + } catch (Throwable $e) { + $this->err('FAIL integration preflight: ' . get_class($e) . ': ' . $this->redactSecrets($e->getMessage())); + + return 2; + } + + $this->out('PHPMax integration checks are enabled.'); + $this->out('Session workdir: ' . $this->workDir); + if ($this->env('PHPMAX_PROXY') !== null) { + $this->out('Proxy: configured'); + } + + $this->check('tcp-login', function (): void { + $this->withClient($this->clientOptions, function (Client $client): void { + if ($client->loginResponse() === null) { + throw new PHPMaxException('Login response was not populated'); + } + if ($client->me() === null) { + throw new PHPMaxException('Profile state was not populated'); + } + }); + }); + + $this->check('local-session-stored', function (): void { + $sessionName = $this->env('PHPMAX_SESSION_NAME') ?: 'integration-session.json'; + $store = new JsonFileSessionStore($this->workDir, $sessionName); + try { + $session = $store->loadSession(); + if ($session === null) { + throw new PHPMaxException('No local session was saved'); + } + if ($session->token === '' || $session->deviceId === '' || $session->mtInstanceId === null || $session->mtInstanceId === '') { + throw new PHPMaxException('Saved local session is incomplete'); + } + $phone = $this->env('PHPMAX_PHONE'); + if ($phone !== null && $store->loadSessionByPhone($phone) === null) { + throw new PHPMaxException('Saved local session is not indexed by configured phone'); + } + } finally { + $store->close(); + } + }); + + $this->check('saved-session-login', function (): void { + $sessionOptions = $this->buildSavedSessionOptions($this->env('PHPMAX_SESSION_NAME') ?: 'integration-session.json'); + $this->withClient($sessionOptions, function (Client $client): void { + if ($client->loginResponse() === null || $client->me() === null) { + throw new PHPMaxException('Saved-session login did not populate login/profile state'); + } + }); + }); + + $this->check('fetch-chats', function (): void { + if (!$this->flag('PHPMAX_FETCH_CHATS')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_FETCH_CHATS=1 to run real chat-list read check'); + } + $this->withClient($this->clientOptions, function (Client $client): void { + $client->fetchChats(); + }); + }); + + $this->check('sessions-list', function (): void { + if (!$this->flag('PHPMAX_FETCH_SESSIONS')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_FETCH_SESSIONS=1 to run real sessions read check'); + } + $this->withClient($this->clientOptions, function (Client $client): void { + $client->getSessions(); + }); + }); + + $this->check('telemetry-login', function (): void { + if (!$this->flag('PHPMAX_TELEMETRY_LOGIN')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_TELEMETRY_LOGIN=1 to run real telemetry login check'); + } + $this->withClient($this->clientOptions, function (Client $client): void { + if (!$client->sendTelemetryLogin()) { + throw new PHPMaxException('Telemetry login event was not accepted'); + } + }); + }); + + $this->check('telemetry-navigation', function (): void { + if (!$this->flag('PHPMAX_TELEMETRY_NAVIGATION')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_TELEMETRY_NAVIGATION=1 to run real telemetry navigation check'); + } + $this->withClient($this->clientOptions, function (Client $client): void { + if (!$client->sendTelemetryNavigationSession()) { + throw new PHPMaxException('Telemetry navigation batch was not accepted'); + } + }); + }); + + $this->check('bot-init-data', function (): void { + $botId = $this->envInt('PHPMAX_BOT_ID'); + if ($botId === null) { + throw new PHPMaxIntegrationSkip('set PHPMAX_BOT_ID to run bot init data check'); + } + $chatId = $this->envInt('PHPMAX_BOT_CHAT_ID'); + $startParam = $this->env('PHPMAX_BOT_START_PARAM'); + $this->withClient($this->clientOptions, function (Client $client) use ($botId, $chatId, $startParam): void { + $initData = $client->getBotInitData($botId, $chatId, $startParam); + if ($initData->queryId === null || $initData->queryId === '' || $initData->url === null || $initData->url === '') { + throw new PHPMaxException('Bot init data response is incomplete'); + } + }); + }); + + $this->check('photo-upload', function (): void { + if (!$this->flag('PHPMAX_UPLOAD_PHOTO')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_UPLOAD_PHOTO=1 to run real photo upload check'); + } + $photo = $this->photoFixture(); + $this->withClient($this->clientOptions, function (Client $client) use ($photo): void { + $payload = $client->uploadPhoto($photo); + if ($payload->photoToken === null || $payload->photoToken === '') { + throw new PHPMaxException('Photo upload did not return a token'); + } + }); + }); + + $this->check('file-upload', function (): void { + if (!$this->flag('PHPMAX_UPLOAD_FILE')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_UPLOAD_FILE=1 to run real file upload check'); + } + $file = $this->fileFixture(); + $this->withClient($this->clientOptions, function (Client $client) use ($file): void { + $payload = $client->uploadFile($file); + if ($payload->fileId === null || $payload->fileId <= 0) { + throw new PHPMaxException('File upload did not return a file id'); + } + }); + }); + + $this->check('video-upload', function (): void { + if (!$this->flag('PHPMAX_UPLOAD_VIDEO')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_UPLOAD_VIDEO=1 to run real video upload check'); + } + $video = $this->videoFixture(); + $this->withClient($this->clientOptions, function (Client $client) use ($video): void { + $payload = $client->uploadVideo($video); + if ($payload->videoId === null || $payload->videoId <= 0 || $payload->token === null || $payload->token === '') { + throw new PHPMaxException('Video upload did not return a complete attach payload'); + } + }); + }); + + $this->check('file-download-url', function (): void { + $chatId = $this->envInt('PHPMAX_DOWNLOAD_CHAT_ID'); + $messageId = $this->envInt('PHPMAX_DOWNLOAD_MESSAGE_ID'); + $fileId = $this->envInt('PHPMAX_DOWNLOAD_FILE_ID'); + if ($chatId === null || $messageId === null || $fileId === null) { + throw new PHPMaxIntegrationSkip('set PHPMAX_DOWNLOAD_CHAT_ID, PHPMAX_DOWNLOAD_MESSAGE_ID and PHPMAX_DOWNLOAD_FILE_ID'); + } + $this->withClient($this->clientOptions, function (Client $client) use ($chatId, $messageId, $fileId): void { + $request = $client->getFileById($chatId, $messageId, $fileId); + if ($request === null || $request->url === null || $request->url === '') { + throw new PHPMaxException('File download request did not return a URL'); + } + }); + }); + + $this->check('video-download-url', function (): void { + $chatId = $this->envInt('PHPMAX_DOWNLOAD_CHAT_ID'); + $messageId = $this->envInt('PHPMAX_DOWNLOAD_MESSAGE_ID'); + $videoId = $this->envInt('PHPMAX_DOWNLOAD_VIDEO_ID'); + if ($chatId === null || $messageId === null || $videoId === null) { + throw new PHPMaxIntegrationSkip('set PHPMAX_DOWNLOAD_CHAT_ID, PHPMAX_DOWNLOAD_MESSAGE_ID and PHPMAX_DOWNLOAD_VIDEO_ID'); + } + $this->withClient($this->clientOptions, function (Client $client) use ($chatId, $messageId, $videoId): void { + $request = $client->getVideoById($chatId, $messageId, $videoId); + if ($request === null || $request->url === null || $request->url === '') { + throw new PHPMaxException('Video download request did not return a URL'); + } + }); + }); + + $this->check('websocket-login', function (): void { + if (!$this->flag('PHPMAX_WEBSOCKET')) { + throw new PHPMaxIntegrationSkip('set PHPMAX_WEBSOCKET=1 to run real WebSocket login check'); + } + $token = $this->env('PHPMAX_TOKEN'); + if ($token === null) { + throw new PHPMaxIntegrationSkip('set PHPMAX_TOKEN to run WebSocket login check'); + } + $options = $this->buildClientOptions($token, $this->env('PHPMAX_WEB_SESSION_NAME') ?: 'web-integration-session.json'); + $client = new WebClient(new ClientOptions($options)); + $client->open(); + try { + if ($client->loginResponse() === null || $client->me() === null) { + throw new PHPMaxException('WebSocket login did not populate login/profile state'); + } + $client->runFor((int) max(1, $this->envFloat('PHPMAX_WEBSOCKET_RUN_SECONDS', 1.0))); + } finally { + $client->close(); + } + }); + + $this->out(sprintf('Summary: %d passed, %d skipped, %d failed.', $this->passed, $this->skipped, $this->failed)); + + return $this->failed > 0 ? 1 : 0; + } + + private function prepareEnabledRun(?string $token): void + { + $this->workDir = $this->env('PHPMAX_WORKDIR') ?: sys_get_temp_dir() . '/phpmax-integration'; + $this->ensureWorkDir($this->workDir); + $this->validateConfiguredEnvValues(); + $this->clientOptions = $this->buildClientOptions($token, $this->env('PHPMAX_SESSION_NAME') ?: 'integration-session.json'); + $this->validateClientConstruction($this->clientOptions); + + if ($this->flag('PHPMAX_WEBSOCKET')) { + $webOptions = $this->buildClientOptions($token, $this->env('PHPMAX_WEB_SESSION_NAME') ?: 'web-integration-session.json'); + $this->validateWebClientConstruction($webOptions); + } + } + + /** + * @param array $options + */ + private function withClient(array $options, callable $callback): void + { + $client = new Client(new ClientOptions($options)); + $client->open(); + try { + $callback($client); + } finally { + $client->close(); + } + } + + /** + * @return array + */ + private function buildClientOptions(?string $token, string $sessionName): array + { + $uploadChunkSize = $this->envInt('PHPMAX_UPLOAD_CHUNK_SIZE'); + $options = [ + 'workDir' => $this->workDir, + 'sessionName' => $sessionName, + 'requestTimeout' => $this->envFloat('PHPMAX_REQUEST_TIMEOUT', 30.0), + 'connectTimeout' => $this->envFloat('PHPMAX_CONNECT_TIMEOUT', 30.0), + 'uploadProcessingTimeout' => $this->envFloat('PHPMAX_UPLOAD_PROCESSING_TIMEOUT', 60.0), + 'uploadHttpTimeout' => $this->envFloat('PHPMAX_UPLOAD_HTTP_TIMEOUT', 900.0), + 'uploadChunkSize' => $uploadChunkSize !== null ? $uploadChunkSize : 1048576, + 'pingInterval' => $this->envFloat('PHPMAX_PING_INTERVAL', 30.0), + ]; + + if ($token !== null) { + $options['token'] = $token; + } elseif ($this->flag('PHPMAX_AUTH_SMS')) { + $options['authFlow'] = new SmsAuthFlow(new ConsoleSmsCodeProvider()); + } + + $phone = $this->env('PHPMAX_PHONE'); + if ($phone !== null) { + $options['phone'] = $phone; + } + $proxy = $this->env('PHPMAX_PROXY'); + if ($proxy !== null) { + $options['proxy'] = $proxy; + } + + return $options; + } + + /** + * @return array + */ + private function buildSavedSessionOptions(string $sessionName): array + { + $options = $this->buildClientOptions(null, $sessionName); + unset($options['authFlow'], $options['token']); + + return $options; + } + + /** + * @param array $options + */ + private function validateClientConstruction(array $options): void + { + $client = new Client(new ClientOptions($options)); + $client->close(); + } + + /** + * @param array $options + */ + private function validateWebClientConstruction(array $options): void + { + $client = new WebClient(new ClientOptions($options)); + $client->close(); + } + + private function validateConfiguredEnvValues(): void + { + if ($this->flag('PHPMAX_AUTH_SMS') && $this->env('PHPMAX_PHONE') === null) { + throw new RuntimeException('PHPMAX_PHONE is required when PHPMAX_AUTH_SMS=1'); + } + + foreach ([ + 'PHPMAX_BOT_CHAT_ID', + 'PHPMAX_DOWNLOAD_CHAT_ID', + ] as $name) { + $this->envInt($name); + } + + foreach ([ + 'PHPMAX_BOT_ID', + 'PHPMAX_DOWNLOAD_MESSAGE_ID', + 'PHPMAX_DOWNLOAD_FILE_ID', + 'PHPMAX_DOWNLOAD_VIDEO_ID', + 'PHPMAX_UPLOAD_CHUNK_SIZE', + ] as $name) { + $this->requirePositiveIntEnv($name); + } + + foreach ([ + 'PHPMAX_REQUEST_TIMEOUT', + 'PHPMAX_CONNECT_TIMEOUT', + 'PHPMAX_UPLOAD_PROCESSING_TIMEOUT', + 'PHPMAX_UPLOAD_HTTP_TIMEOUT', + 'PHPMAX_WEBSOCKET_RUN_SECONDS', + ] as $name) { + $this->requirePositiveFloatEnv($name); + } + $this->requireNonNegativeFloatEnv('PHPMAX_PING_INTERVAL'); + + foreach ([ + 'PHPMAX_UPLOAD_PHOTO_PATH', + 'PHPMAX_UPLOAD_FILE_PATH', + 'PHPMAX_UPLOAD_VIDEO_PATH', + ] as $name) { + $path = $this->env($name); + if ($path !== null && (!is_file($path) || !is_readable($path))) { + throw new RuntimeException($name . ' must point to a readable file'); + } + } + + if ($this->flag('PHPMAX_UPLOAD_VIDEO') && $this->env('PHPMAX_UPLOAD_VIDEO_PATH') === null) { + throw new RuntimeException('PHPMAX_UPLOAD_VIDEO_PATH is required when PHPMAX_UPLOAD_VIDEO=1'); + } + } + + private function requirePositiveIntEnv(string $name): void + { + $value = $this->envInt($name); + if ($value !== null && $value <= 0) { + throw new RuntimeException($name . ' must be a positive integer'); + } + } + + private function requirePositiveFloatEnv(string $name): void + { + if ($this->env($name) === null) { + return; + } + + if ($this->envFloat($name, 1.0) <= 0.0) { + throw new RuntimeException($name . ' must be greater than 0'); + } + } + + private function requireNonNegativeFloatEnv(string $name): void + { + if ($this->env($name) === null) { + return; + } + + if ($this->envFloat($name, 0.0) < 0.0) { + throw new RuntimeException($name . ' must be greater than or equal to 0'); + } + } + + private function check(string $name, callable $callback): void + { + $this->out('RUN ' . $name); + try { + $callback(); + $this->passed++; + $this->out('OK ' . $name); + } catch (PHPMaxIntegrationSkip $e) { + $this->skipped++; + $this->out('SKIP ' . $name . ': ' . $this->redactSecrets($e->getMessage())); + } catch (Throwable $e) { + $this->failed++; + $this->err('FAIL ' . $name . ': ' . get_class($e) . ': ' . $this->redactSecrets($e->getMessage())); + } + } + + private function photoFixture(): Photo + { + $path = $this->env('PHPMAX_UPLOAD_PHOTO_PATH'); + if ($path !== null) { + return Photo::fromPath($path); + } + + return Photo::fromRaw($this->tinyPng(), 'phpmax-integration.png'); + } + + private function fileFixture(): File + { + $path = $this->env('PHPMAX_UPLOAD_FILE_PATH'); + if ($path !== null) { + return File::fromPath($path); + } + + return File::fromRaw('PHPMax integration file fixture' . "\n", 'phpmax-integration.txt'); + } + + private function videoFixture(): Video + { + $path = $this->env('PHPMAX_UPLOAD_VIDEO_PATH'); + if ($path !== null) { + return Video::fromPath($path); + } + + throw new PHPMaxIntegrationSkip('set PHPMAX_UPLOAD_VIDEO_PATH to a small video file before enabling video upload'); + } + + private function shouldPrintPlan(): bool + { + global $argv; + + if ($this->flag('PHPMAX_INTEGRATION_PLAN')) { + return true; + } + + return in_array('--plan', array_slice($argv, 1), true); + } + + private function printPlan(): void + { + $this->out('PHPMax integration plan (no network requests).'); + $this->out('Run token checks with: PHPMAX_INTEGRATION=1 PHPMAX_TOKEN= just integration-check'); + $this->out('Run interactive SMS checks with: PHPMAX_INTEGRATION=1 PHPMAX_AUTH_SMS=1 PHPMAX_PHONE= just integration-check'); + $this->out(''); + $this->out('Base check:'); + $this->out(' tcp-login: requires PHPMAX_TOKEN or PHPMAX_AUTH_SMS=1 with PHPMAX_PHONE'); + $this->out(' local-session-stored: verifies token/device/phone session persistence'); + $this->out(' saved-session-login: reopens from saved session without token/SMS auth flow'); + $this->out(''); + $this->out('Optional checks:'); + foreach ($this->integrationPlanRows() as $row) { + $status = $this->planRowConfigured($row['vars']) ? 'ready' : 'needs env'; + $this->out(sprintf(' %-22s %s (%s)', $row['name'] . ':', $status, implode(', ', $row['vars']))); + } + $this->out(''); + $this->out('Shared options are read when set, but values are never printed:'); + $this->out(' PHPMAX_WORKDIR, PHPMAX_SESSION_NAME, PHPMAX_WEB_SESSION_NAME, PHPMAX_PHONE, PHPMAX_PROXY,'); + $this->out(' PHPMAX_REQUEST_TIMEOUT, PHPMAX_CONNECT_TIMEOUT, PHPMAX_PING_INTERVAL,'); + $this->out(' PHPMAX_UPLOAD_PROCESSING_TIMEOUT, PHPMAX_UPLOAD_HTTP_TIMEOUT, PHPMAX_UPLOAD_CHUNK_SIZE'); + } + + /** + * @return array}> + */ + private function integrationPlanRows(): array + { + return [ + ['name' => 'fetch-chats', 'vars' => ['PHPMAX_FETCH_CHATS=1']], + ['name' => 'sessions-list', 'vars' => ['PHPMAX_FETCH_SESSIONS=1']], + ['name' => 'telemetry-login', 'vars' => ['PHPMAX_TELEMETRY_LOGIN=1']], + ['name' => 'telemetry-navigation', 'vars' => ['PHPMAX_TELEMETRY_NAVIGATION=1']], + ['name' => 'bot-init-data', 'vars' => ['PHPMAX_BOT_ID']], + ['name' => 'photo-upload', 'vars' => ['PHPMAX_UPLOAD_PHOTO=1']], + ['name' => 'file-upload', 'vars' => ['PHPMAX_UPLOAD_FILE=1']], + ['name' => 'video-upload', 'vars' => ['PHPMAX_UPLOAD_VIDEO=1', 'PHPMAX_UPLOAD_VIDEO_PATH']], + ['name' => 'file-download-url', 'vars' => ['PHPMAX_DOWNLOAD_CHAT_ID', 'PHPMAX_DOWNLOAD_MESSAGE_ID', 'PHPMAX_DOWNLOAD_FILE_ID']], + ['name' => 'video-download-url', 'vars' => ['PHPMAX_DOWNLOAD_CHAT_ID', 'PHPMAX_DOWNLOAD_MESSAGE_ID', 'PHPMAX_DOWNLOAD_VIDEO_ID']], + ['name' => 'websocket-login', 'vars' => ['PHPMAX_WEBSOCKET=1']], + ]; + } + + /** + * @param array $requirements + */ + private function planRowConfigured(array $requirements): bool + { + foreach ($requirements as $requirement) { + if (substr($requirement, -2) === '=1') { + $name = substr($requirement, 0, -2); + if (!$this->flag($name)) { + return false; + } + continue; + } + + if ($this->env($requirement) === null) { + return false; + } + } + + return true; + } + + private function tinyPng(): string + { + return (string) base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + true + ); + } + + private function ensureWorkDir(string $dir): void + { + if (file_exists($dir) && !is_dir($dir)) { + throw new RuntimeException('Integration workdir is not a directory: ' . $dir); + } + if (is_dir($dir)) { + if (!is_writable($dir)) { + throw new RuntimeException('Integration workdir is not writable: ' . $dir); + } + return; + } + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Unable to create integration workdir: ' . $dir); + } + @chmod($dir, 0700); + if (!is_writable($dir)) { + throw new RuntimeException('Integration workdir is not writable: ' . $dir); + } + } + + private function flag(string $name): bool + { + $value = $this->env($name); + if ($value === null) { + return false; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + private function env(string $name): ?string + { + $value = getenv($name); + if ($value === false) { + return null; + } + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function envInt(string $name): ?int + { + $value = $this->env($name); + if ($value === null) { + return null; + } + if (!preg_match('/^-?\d+$/', $value)) { + throw new RuntimeException($name . ' must be an integer'); + } + + return (int) $value; + } + + private function envFloat(string $name, float $default): float + { + $value = $this->env($name); + if ($value === null) { + return $default; + } + if (!is_numeric($value)) { + throw new RuntimeException($name . ' must be numeric'); + } + + return (float) $value; + } + + private function out(string $message): void + { + fwrite(STDOUT, $message . PHP_EOL); + } + + private function err(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } + + private function redactSecrets(string $message): string + { + foreach ([ + 'PHPMAX_TOKEN', + 'PHPMAX_PHONE', + ] as $name) { + $value = $this->env($name); + if ($value !== null) { + $message = str_replace($value, '', $message); + } + } + + $proxy = $this->env('PHPMAX_PROXY'); + if ($proxy !== null) { + $message = str_replace($proxy, '', $message); + $parts = parse_url($proxy); + if (is_array($parts)) { + foreach (['user', 'pass'] as $key) { + if (isset($parts[$key]) && $parts[$key] !== '') { + $encoded = (string) $parts[$key]; + $decoded = rawurldecode($encoded); + $message = str_replace($encoded, '', $message); + $message = str_replace($decoded, '', $message); + } + } + } + } + + return $message; + } +} + +exit((new PHPMaxIntegrationRunner())->run()); diff --git a/tools/php74-compat-check.php b/tools/php74-compat-check.php new file mode 100644 index 0000000..44f6d45 --- /dev/null +++ b/tools/php74-compat-check.php @@ -0,0 +1,205 @@ + $paths + * @return list + */ +function collectPhpFiles(string $repoRoot, array $paths): array +{ + $files = []; + foreach ($paths as $path) { + $absolute = $path !== '' && $path[0] === '/' ? $path : $repoRoot . '/' . $path; + if (is_file($absolute)) { + if (substr($absolute, -4) === '.php') { + $files[] = $absolute; + } + continue; + } + if (!is_dir($absolute)) { + throw new RuntimeException('Path does not exist: ' . $path); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($absolute, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile()) { + continue; + } + if (substr($file->getFilename(), -4) === '.php') { + $files[] = $file->getPathname(); + } + } + } + + sort($files); + + return array_values(array_unique($files)); +} + +/** + * @return list + */ +function scanPhp74Compatibility(string $file): array +{ + $source = file_get_contents($file); + if ($source === false) { + throw new RuntimeException('Unable to read PHP file: ' . $file); + } + + $code = stripPhpCommentsAndStrings($source); + $issues = []; + + addPatternIssue($issues, $file, $source, '/^[ \t]*#\[/m', 'PHP attributes require PHP 8.0+'); + addPatternIssue($issues, $file, $code, '/(^|\n)\s*enum\s+[A-Za-z_][A-Za-z0-9_]*/m', 'native enum declarations require PHP 8.1+'); + addPatternIssue($issues, $file, $code, '/\bmatch\s*\(/', 'match expressions require PHP 8.0+'); + addPatternIssue($issues, $file, $code, '/\?->/', 'nullsafe operator requires PHP 8.0+'); + addPatternIssue($issues, $file, $code, '/\breadonly\s+(?:class|public|protected|private|\$)/', 'readonly classes/properties require PHP 8.1+'); + addPatternIssue($issues, $file, $code, '/\b(?:str_contains|str_starts_with|str_ends_with|get_debug_type|enum_exists)\s*\(/', 'called function is not available in PHP 7.4'); + + addPatternIssue( + $issues, + $file, + $code, + '/function\s+__construct\s*\([^)]*\b(?:public|protected|private)\s+(?:readonly\s+)?(?:\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*\s+)?\$\w+/s', + 'constructor property promotion requires PHP 8.0+' + ); + addPatternIssue( + $issues, + $file, + $code, + '/function\s*(?:[A-Za-z_][A-Za-z0-9_]*)?\s*\([^)]*\)\s*:\s*\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*(?:\s*\|\s*\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*)+/s', + 'union return types require PHP 8.0+' + ); + addPatternIssue( + $issues, + $file, + $code, + '/(?:^|[,(])\s*\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*(?:\s*\|\s*\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*)+\s*(?:&\s*)?\$\w+/m', + 'union parameter types require PHP 8.0+' + ); + addPatternIssue( + $issues, + $file, + $code, + '/\b(?:public|protected|private|var)\s+(?:static\s+)?\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*(?:\s*\|\s*\??[A-Za-z_\\\\][A-Za-z0-9_\\\\]*)+\s+\$\w+/', + 'union property types require PHP 8.0+' + ); + addPatternIssue( + $issues, + $file, + $code, + '/(?:^|[,(])\s*(?:mixed|never)\s+(?:&\s*)?\$\w+/m', + 'mixed/never parameter types require PHP 8.0+' + ); + addPatternIssue( + $issues, + $file, + $code, + '/function\s*(?:[A-Za-z_][A-Za-z0-9_]*)?\s*\([^)]*\)\s*:\s*(?:mixed|never|static|false|true|null)\b/s', + 'this return type is not available in PHP 7.4' + ); + addPatternIssue( + $issues, + $file, + $code, + '/\b(?:public|protected|private|var)\s+(?:static\s+)?(?:mixed|never|false|true|null)\s+\$\w+/', + 'this property type is not available in PHP 7.4' + ); + + return $issues; +} + +function stripPhpCommentsAndStrings(string $source): string +{ + $tokens = token_get_all($source); + $result = ''; + foreach ($tokens as $token) { + if (!is_array($token)) { + $result .= $token; + continue; + } + + $id = $token[0]; + $text = $token[1]; + if ($id === T_COMMENT || $id === T_DOC_COMMENT || $id === T_CONSTANT_ENCAPSED_STRING || $id === T_ENCAPSED_AND_WHITESPACE) { + $result .= blankTokenText($text); + continue; + } + if (defined('T_INLINE_HTML') && $id === T_INLINE_HTML) { + $result .= blankTokenText($text); + continue; + } + + $result .= $text; + } + + return $result; +} + +function blankTokenText(string $text): string +{ + $blank = preg_replace('/[^\r\n]/', ' ', $text); + + return $blank === null ? '' : $blank; +} + +/** + * @param list $issues + */ +function addPatternIssue(array &$issues, string $file, string $source, string $pattern, string $message): void +{ + if (preg_match($pattern, $source, $matches, PREG_OFFSET_CAPTURE) !== 1) { + return; + } + + $offset = (int) $matches[0][1]; + $issues[] = relativePath($file) . ':' . lineForOffset($source, $offset) . ': ' . $message; +} + +function lineForOffset(string $source, int $offset): int +{ + if ($offset <= 0) { + return 1; + } + + return substr_count(substr($source, 0, $offset), "\n") + 1; +} + +function relativePath(string $file): string +{ + $root = dirname(__DIR__) . '/'; + if (strpos($file, $root) === 0) { + return substr($file, strlen($root)); + } + + return $file; +} diff --git a/tools/run-php-tests.php b/tools/run-php-tests.php new file mode 100644 index 0000000..01265ec --- /dev/null +++ b/tools/run-php-tests.php @@ -0,0 +1,82 @@ +isFile()) { + continue; + } + if (substr($file->getFilename(), -8) !== 'Test.php') { + continue; + } + $files[] = $file->getPathname(); +} + +sort($files); + +$failures = 0; +$assertions = 0; + +$assert = static function ($condition, string $message = 'Assertion failed') use (&$assertions): void { + $assertions++; + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$assertSame = static function ($expected, $actual, string $message = '') use (&$assertions): void { + $assertions++; + if ($expected !== $actual) { + $details = 'Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true); + throw new RuntimeException($message !== '' ? $message . ': ' . $details : $details); + } +}; + +$assertThrows = static function (string $exceptionClass, callable $callback, string $message = '') use (&$assertions): void { + $assertions++; + try { + $callback(); + } catch (Throwable $e) { + if ($e instanceof $exceptionClass) { + return; + } + throw new RuntimeException( + ($message !== '' ? $message . ': ' : '') . + 'Expected ' . $exceptionClass . ', got ' . get_class($e) . ' with message: ' . $e->getMessage() + ); + } + + throw new RuntimeException(($message !== '' ? $message . ': ' : '') . 'Expected ' . $exceptionClass . ' to be thrown'); +}; + +foreach ($files as $file) { + try { + $test = require $file; + if (!is_callable($test)) { + throw new RuntimeException('Test file must return a callable'); + } + $test($assert, $assertSame, $assertThrows); + fwrite(STDOUT, '.'); + } catch (Throwable $e) { + $failures++; + fwrite(STDOUT, "F\n"); + fwrite(STDERR, $file . "\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + } +} + +fwrite(STDOUT, "\nAssertions: " . $assertions . "\n"); + +if ($failures > 0) { + fwrite(STDERR, "Failures: " . $failures . "\n"); + exit(1); +} + +fwrite(STDOUT, "OK\n"); +