commit 9385fbdd6ee1e18fc31b982065c58923f0a498ce Author: pkirillw Date: Thu Jun 18 00:01:06 2026 +0300 feat: max-bot-test initial — full surface coverage - Slim 4 PSR-15 webhook, PHP-DI 7, Symfony Console 8 - 13 update handlers, 30+ command scenarios, all 7 button types - Composer pulls pkirillw/max-bot-api-php from GitHub vcs - Multi-stage Dockerfile (php-fpm 8.4-alpine) + nginx - docker-compose.yml targets server (npm_default external) - docker-compose.override.yml for local dev (gitignored) - .gitea/workflows/deploy.yml mirrors ghost-blog-bot pattern Co-Authored-By: Claude Opus 4.7 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf2629a --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# MAX Bot token from BotFather-like UI in MAX +BOT_TOKEN= + +# Shared secret used to authenticate webhook requests from MAX. +# Generated by `make subscribe` or any 32+ byte random string. +MAX_BOT_API_SECRET= + +# Public HTTPS URL where MAX will POST updates. +# Fill in with your own tunnel / reverse proxy / public hostname. +WEBHOOK_URL=https://your-public-host.example/webhook + +# Optional: HTTPS URL of a web app registered against this bot in Botfather. +# When empty, the OpenApp button is skipped in /keyboard (MAX returns 404 for unregistered apps). +WEB_APP_URL= + +# Optional: when set, /debug on uses this chat for Debugs::sendText() +DEBUG_CHAT_ID= + +# Self-test CLI target chat +SELFTEST_CHAT_ID= + +# Diagnostics +APP_ENV=dev +APP_DEBUG=true diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..9113fb8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy Max Bot Test + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Setup SSH + env: + SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" | sed 's/^[[:space:]]*//' > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p ${{ vars.SERVER_PORT }} ${{ vars.SERVER_HOST }} >> ~/.ssh/known_hosts + + - name: Clone or pull on server + run: | + ssh -i ~/.ssh/id_rsa -p ${{ vars.SERVER_PORT }} ${{ vars.SERVER_USER }}@${{ vars.SERVER_HOST }} " + if [ -d /home/pkirillw/dockered-services/max-bot-test/.git ]; then + cd /home/pkirillw/dockered-services/max-bot-test && git pull origin main + else + cd /home/pkirillw/dockered-services && git clone https://git.pkirillw-server.ru/pkirillw/max-bot-test.git + fi" + + - name: Create .env file + env: + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + MAX_BOT_API_SECRET: ${{ secrets.MAX_BOT_API_SECRET }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEB_APP_URL: ${{ vars.WEB_APP_URL }} + DEBUG_CHAT_ID: ${{ vars.DEBUG_CHAT_ID }} + SELFTEST_CHAT_ID: ${{ vars.SELFTEST_CHAT_ID }} + APP_ENV: prod + APP_DEBUG: 'false' + run: | + ssh -i ~/.ssh/id_rsa -p ${{ vars.SERVER_PORT }} ${{ vars.SERVER_USER }}@${{ vars.SERVER_HOST }} "cat > /home/pkirillw/dockered-services/max-bot-test/.env << 'ENVEOF' + BOT_TOKEN=$BOT_TOKEN + MAX_BOT_API_SECRET=$MAX_BOT_API_SECRET + WEBHOOK_URL=$WEBHOOK_URL + WEB_APP_URL=$WEB_APP_URL + DEBUG_CHAT_ID=$DEBUG_CHAT_ID + SELFTEST_CHAT_ID=$SELFTEST_CHAT_ID + APP_ENV=$APP_ENV + APP_DEBUG=$APP_DEBUG + ENVEOF" + + - name: Build and restart + run: | + ssh -i ~/.ssh/id_rsa -p ${{ vars.SERVER_PORT }} ${{ vars.SERVER_USER }}@${{ vars.SERVER_HOST }} " + cd /home/pkirillw/dockered-services/max-bot-test && \ + docker compose up -d --build && \ + docker image prune -f" + + - name: Health check + run: | + ssh -i ~/.ssh/id_rsa -p ${{ vars.SERVER_PORT }} ${{ vars.SERVER_USER }}@${{ vars.SERVER_HOST }} " + sleep 5 && docker compose -f /home/pkirillw/dockered-services/max-bot-test/docker-compose.yml ps" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b45ab4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +.env.local +vendor/ +storage/logs/* +!storage/logs/.gitkeep +storage/uploads/* +!storage/uploads/.gitkeep +.phpunit.result.cache +.phpunit.cache/ +.idea/ +.DS_Store +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f08ae6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM php:8.4-fpm-alpine AS base + +RUN apk add --no-cache \ + libzip-dev \ + zlib-dev \ + unzip \ + git \ + curl \ + && docker-php-ext-install zip pcntl \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +WORKDIR /var/www/html + +# --- builder: composer install without dev deps, no scripts --- +FROM base AS builder +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction + +# --- runtime: copy app code on top of vendor/ --- +FROM base AS runtime +COPY --from=builder /var/www/html/vendor /var/www/html/vendor +COPY composer.json composer.lock ./ +COPY bin ./bin +COPY config ./config +COPY public ./public +COPY src ./src +COPY storage ./storage + +RUN chown -R www-data:www-data /var/www/html/storage + +CMD ["php-fpm"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7260da6 --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +SHELL := /bin/bash +COMPOSE := docker compose +PHP := $(COMPOSE) exec app php +COMPOSER := $(COMPOSE) run --rm app composer + +.PHONY: help up down build install composer update subscribe unsubscribe selftest logs ps test clean + +help: + @echo "Bot commands:" + @echo " make build — build docker images" + @echo " make up — start nginx + php-fpm" + @echo " make down — stop containers" + @echo " make install — composer install (first time)" + @echo " make update — composer update" + @echo " make subscribe — register webhook with MAX (uses WEBHOOK_URL)" + @echo " make unsubscribe — remove webhook" + @echo " make selftest — run smoke tests across all endpoint groups" + @echo " make bot-info — print BotInfo JSON" + @echo " make logs — tail app + nginx logs" + @echo " make test — run PHPUnit" + +build: + $(COMPOSE) build + +up: + $(COMPOSE) up -d + +down: + $(COMPOSE) down + +install: + $(COMPOSE) run --rm app composer install + +composer: + $(COMPOSER) $(CMD) + +update: + $(COMPOSE) run --rm app composer update + +subscribe: + $(PHP) bin/console webhook:subscribe + +unsubscribe: + $(PHP) bin/console webhook:unsubscribe + +selftest: + $(PHP) bin/console selftest + +bot-info: + $(PHP) bin/console bot:info + +ps: + $(COMPOSE) ps + +logs: + $(COMPOSE) logs -f app nginx + +test: + $(COMPOSE) run --rm app vendor/bin/phpunit + +clean: + $(COMPOSE) down -v + rm -rf vendor diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce51b78 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# max-bot-test + +Тестовый бот, демонстрирующий весь публичный API библиотеки +[`pkirillw/max-bot-api-php`](https://github.com/pkirillw/max-bot-api-php.git). + +Получает обновления через PSR-15 webhook (Slim 4 + Nyholm PSR-17), покрывает все 6 +endpoint groups, 13 типов апдейтов, все типы кнопок и вложений. Запускается в Docker +Compose (php-fpm + nginx на `:8080`). Публичный HTTPS-адрес для webhook'а +подключаете своим туннелем (ngrok, cloudflared, обратный прокси и т.д.). + +## Что внутри + +- **6 endpoint groups**: `Bots`, `Chats`, `Messages`, `Uploads`, `Subscriptions`, `Debugs`. +- **13 типов апдейтов** — отдельный handler на каждый. +- **Все 7 типов inline-кнопок** (`callback`, `link`, `message`, `clipboard`, + `request_contact`, `request_geo_location`, `open_app`) + все 3 `Intent`-окраса. +- **Все варианты загрузок**: file / URL / bytes / base64, отдельно для фото и media. +- **Все типы вложений**: photo / audio / video / file / location / contact / sticker. +- **Symfony Console CLI** для subscribe / unsubscribe / list / bot info / selftest. +- **PHPUnit**: unit-тесты на парсинг команд и клавиатуры, integration-тесты на + webhook secret-check. + +## Структура + +``` +src/ +├── Bootstrap.php — Slim + DI container + env loader +├── Handler/ +│ ├── UpdateRouter.php — dispatch UpdateInterface → handler +│ ├── AbstractUpdateHandler.php — общее логирование + error handling +│ └── Update/ — 13 handler'ов под каждый UpdateType +├── Scenario/ — обработчики команд (/help, /chats, /keyboard, …) +├── Command/ — bin/console команды +├── Keyboard/ — KeyboardFactory со всеми типами кнопок +├── Logger/ — Monolog factory +└── Util/ + ├── CommandRouter.php — command name → ScenarioInterface + └── ParsedCommand.php — парсер `/cmd arg1 arg2` и `/cmd:arg` +config/ +├── container.php — PHP-DI definitions +└── routes.php — /webhook + /health +public/index.php — Slim entrypoint +bin/console — CLI entrypoint +``` + +## Установка + +```sh +git clone max-bot-test +cd max-bot-test +cp .env.example .env +``` + +Заполните `.env`: + +- `BOT_TOKEN` — токен бота из @MasterBot или панели MAX. +- `MAX_BOT_API_SECRET` — общий секрет для webhook. Сгенерируйте: + `openssl rand -hex 32`. +- `WEBHOOK_URL` — публичный HTTPS-URL, по которому MAX будет присылать апдейты + (например `https://bot.example.com/webhook`). Поднять публичный доступ можно + любым способом: ngrok, cloudflared, Caddy/Nginx на VPS и т.п. +- `DEBUG_CHAT_ID`, `SELFTEST_CHAT_ID` — ID чата, в который бот может слать + служебные сообщения (обычно ваш диалог с ботом). + +Библиотека `pkirillw/max-bot-api-php` подключена через `repositories` в +`composer.json` как path-симлинк на `/var/www/max-php-client` внутри контейнера +(через volume `../max-php-client` в `docker-compose.yml`). Если библиотека лежит +в другом месте — поправьте volume и/или URL в `composer.json`. + +## Запуск + +```sh +make build # собрать Docker-образы +make up # запустить app + nginx (порт :8080) +make install # composer install +``` + +Пробросьте свой публичный HTTPS на `localhost:8080` и впишите URL в `.env`: + +```sh +WEBHOOK_URL=https://your-public-host/webhook +make up # перезапустить чтобы подхватить новый env +make subscribe # эквивалент: docker compose exec app bin/console webhook:subscribe +``` + +Готово. Откройте диалог с ботом в MAX, отправьте `/help`. + +## Команды бота + +Полный список — в `/help` (или в `src/Scenario/HelpScenario.php`). Кратко: + +**Bot**: `/bot`, `/bot_patch `. +**Chats**: `/chats`, `/chat `, `/members `, `/admins `, +`/action `, +`/pin `, `/edit_chat `, `/add_member <id> <userId>`, +`/remove_member <id> <userId>`, `/leave <id>`. +**Messages**: `/send_text <text>`, `/send_markdown <text>`, `/send_html <text>`, +`/reply <mid> <text>`, `/forward <mid>`, `/edit <mid> <text>`, `/delete <mid>`, +`/messages <id> [count]`, `/message <mid>`, `/markup <userId>`, `/notify <text>`, +`/disable_preview <url>`, `/keyboard`. +**Uploads**: `/upload_photo_file [path]`, `/upload_photo_url <url>`, +`/upload_photo_bytes`, `/upload_photo_base64`, `/upload_audio [path]`, +`/upload_video [path]`, `/upload_file [path]`, +`/upload_url <audio|video|file> <url>`. +**Attachments**: `/attach_location <lat> <lng>`, `/attach_contact <name> <id>`, +`/attach_sticker <code>`. +**Phone**: `/phone_check <phone>`, `/phone_list <phone1,phone2>`. +**Debug**: `/debug <on|off|raw|err|status>`. + +## CLI + +```sh +bin/console webhook:subscribe [--url=…] [--secret=…] [--types=msg_created,…] +bin/console webhook:unsubscribe [url] +bin/console subs:list +bin/console bot:info +bin/console selftest # дымовой тур по всем endpoint groups +``` + +## Тесты + +```sh +make test +# или локально: +vendor/bin/phpunit +``` + +## Логи + +``` +storage/logs/app.log +make logs # app + nginx в реальном времени +``` + +## Известные особенности + +- **Long polling не поддерживается** библиотекой. Получение событий — только + webhook. Запуск без публичного URL невозможен (используйте cloudflared). +- `DebugScenario::off` не может выключить debug-чат в рантайме — `Debugs` не + выставляет chatId обратно в null. Перезапустите контейнер без `DEBUG_CHAT_ID`. +- При `attachment.not.ready` библиотека сама делает до 3 retries с + экспоненциальной задержкой — повторных попыток на уровне бота не требуется. + +## Лицензия + +Apache-2.0 — как и сама библиотека. diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..4de895b --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env php +<?php + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use App\Bootstrap; +use Symfony\Component\Console\Application; + +$container = Bootstrap::container(); +$application = $container->get(Application::class); +/** @var Application $application */ +$application->run(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e33fb4d --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "pkirillw/max-bot-test", + "description": "Test bot that exercises the full surface area of pkirillw/max-bot-api-php.", + "type": "project", + "license": "Apache-2.0", + "keywords": ["max", "bot", "messenger", "test", "demo"], + "require": { + "php": "^8.4", + "pkirillw/max-bot-api-php": "dev-main@dev", + "slim/slim": "^4.0", + "nyholm/psr7": "^1.8", + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "symfony/console": "^7.0 || ^8.0", + "symfony/dotenv": "^7.0 || ^8.0", + "monolog/monolog": "^3.0", + "php-di/php-di": "^7.0", + "psr/log": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0 || ^11.0" + }, + "repositories": { + "max-bot-api-php": { + "type": "vcs", + "url": "https://github.com/pkirillw/max-bot-api-php.git" + } + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "bin": [ + "bin/console" + ], + "config": { + "sort-packages": true, + "allow-plugins": {} + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1455a05 --- /dev/null +++ b/composer.lock @@ -0,0 +1,4084 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "487c72c09f52a8926339dd7b4df05030", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.12.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "eaa81598031cf57a9e36258c8546defffc994cba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/eaa81598031cf57a9e36258c8546defffc994cba", + "reference": "eaa81598031cf57a9e36258c8546defffc994cba", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.5", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.12.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-16T22:11:48+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43", + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-16T21:50:11+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" + }, + { + "name": "pkirillw/max-bot-api-php", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/pkirillw/max-bot-api-php.git", + "reference": "29221e78c8e01bcbdbb04fce96ce40b6ace74cd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pkirillw/max-bot-api-php/zipball/29221e78c8e01bcbdbb04fce96ce40b6ace74cd0", + "reference": "29221e78c8e01bcbdbb04fce96ce40b6ace74cd0", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "nyholm/psr7": "^1.8", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^6.16" + }, + "suggest": { + "guzzlehttp/guzzle": "Recommended PSR-18 implementation", + "guzzlehttp/psr7": "PSR-17 factory from Guzzle", + "nyholm/psr7": "Lightweight PSR-17 factory", + "symfony/http-client": "Alternative PSR-18 implementation" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Pkirillw\\MaxBotApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Pkirillw\\MaxBotApi\\Tests\\": "tests/" + } + }, + "license": [ + "Apache-2.0" + ], + "description": "MAX Bot API client for PHP", + "keywords": [ + "api", + "bot", + "client", + "max", + "messenger" + ], + "support": { + "source": "https://github.com/pkirillw/max-bot-api-php/tree/main", + "issues": "https://github.com/pkirillw/max-bot-api-php/issues" + }, + "time": "2026-06-17T11:52:34+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "slim/slim", + "version": "4.15.2", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim.git", + "reference": "e12cb05ca2a14e8f459d019e87a31dc915b80470" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim/zipball/e12cb05ca2a14e8f459d019e87a31dc915b80470", + "reference": "e12cb05ca2a14e8f459d019e87a31dc915b80470", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.4 || ^2", + "ext-simplexml": "*", + "guzzlehttp/psr7": "^2.6", + "httpsoft/http-message": "^1.1", + "httpsoft/http-server-request": "^1.1", + "laminas/laminas-diactoros": "^2.17 || ^3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.1", + "phpstan/phpstan": "^1 || ^2", + "phpunit/phpunit": "^9.6 || ^10 || ^11 || ^12", + "slim/http": "^1.3", + "slim/psr7": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "vimeo/psalm": "^5 || ^6" + }, + "suggest": { + "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware", + "ext-xml": "Needed to support XML format in BodyParsingMiddleware", + "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim", + "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information." + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\": "Slim" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "https://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "https://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "https://www.lgse.com" + }, + { + "name": "Gabriel Manricks", + "email": "gmanricks@me.com", + "homepage": "http://gabrielmanricks.com" + } + ], + "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs", + "homepage": "https://www.slimframework.com", + "keywords": [ + "api", + "framework", + "micro", + "router" + ], + "support": { + "docs": "https://www.slimframework.com/docs/v4/", + "forum": "https://discourse.slimframework.com/", + "irc": "irc://irc.freenode.net:6667/slimphp", + "issues": "https://github.com/slimphp/Slim/issues", + "rss": "https://www.slimframework.com/blog/feed.rss", + "slack": "https://slimphp.slack.com/", + "source": "https://github.com/slimphp/Slim", + "wiki": "https://github.com/slimphp/Slim/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/slimphp", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slim/slim", + "type": "tidelift" + } + ], + "time": "2026-05-22T08:00:12+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "pkirillw/max-bot-api-php": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/container.php b/config/container.php new file mode 100644 index 0000000..30c74a6 --- /dev/null +++ b/config/container.php @@ -0,0 +1,198 @@ +<?php + +declare(strict_types=1); + +use App\Bootstrap; +use App\Handler\UpdateRouter; +use App\Handler\Update\BotAddedToChatHandler; +use App\Handler\Update\BotRemovedFromChatHandler; +use App\Handler\Update\BotStartedHandler; +use App\Handler\Update\BotStopedFromChatHandler; +use App\Handler\Update\ChatTitleChangedHandler; +use App\Handler\Update\DialogClearedHandler; +use App\Handler\Update\DialogRemovedHandler; +use App\Handler\Update\MessageCallbackHandler; +use App\Handler\Update\MessageCreatedHandler; +use App\Handler\Update\MessageEditedHandler; +use App\Handler\Update\MessageRemovedHandler; +use App\Handler\Update\UserAddedToChatHandler; +use App\Handler\Update\UserRemovedFromChatHandler; +use App\Keyboard\KeyboardFactory; +use App\Logger\LoggerFactory; +use App\Scenario\AttachmentScenario; +use App\Scenario\BotInfoScenario; +use App\Scenario\ChatAdminScenario; +use App\Scenario\ChatsScenario; +use App\Scenario\DebugScenario; +use App\Scenario\HelpScenario; +use App\Scenario\KeyboardScenario; +use App\Scenario\MessagesScenario; +use App\Scenario\PhoneCheckScenario; +use App\Scenario\UploadsScenario; +use App\Util\CommandRouter; +use GuzzleHttp\Client as Guzzle; +use Monolog\Logger; +use Nyholm\Psr7\Factory\Psr17Factory; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Client\Options; +use Pkirillw\MaxBotApi\Webhook\WebhookHandler; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Application; + +return [ + 'config' => fn () => Bootstrap::envConfig(), + + Psr17Factory::class => fn () => new Psr17Factory(), + + Guzzle::class => fn () => new Guzzle(['timeout' => 30]), + + Options::class => function (ContainerInterface $c): Options { + $cfg = $c->get('config'); + $opts = Options::default()->withKeepRawUpdates(true); + if (($cfg['debug.chat_id'] ?? 0) > 0) { + $opts = $opts->withDebugChatId((int) $cfg['debug.chat_id']); + } + return $opts; + }, + + Api::class => function (ContainerInterface $c): Api { + $cfg = $c->get('config'); + return Api::create( + token: (string) $cfg['bot.token'], + http: $c->get(Guzzle::class), + requestFactory: $c->get(Psr17Factory::class), + streamFactory: $c->get(Psr17Factory::class), + options: $c->get(Options::class), + ); + }, + + LoggerInterface::class => fn (ContainerInterface $c) => LoggerFactory::create($c->get('config')), + + KeyboardFactory::class => function (ContainerInterface $c): KeyboardFactory { + $cfg = $c->get('config'); + $url = trim((string) ($cfg['webapp.url'] ?? '')); + return new KeyboardFactory($url !== '' ? $url : null); + }, + + CommandRouter::class => function (ContainerInterface $c): CommandRouter { + $router = new CommandRouter(); + $router->add('start', $c->get(HelpScenario::class)); + $router->add('help', $c->get(HelpScenario::class)); + $router->add('bot', $c->get(BotInfoScenario::class)); + $router->add('bot_patch', $c->get(BotInfoScenario::class)); + $router->add('chats', $c->get(ChatsScenario::class)); + $router->add('chat', $c->get(ChatsScenario::class)); + $router->add('chat_membership', $c->get(ChatsScenario::class)); + $router->add('members', $c->get(ChatsScenario::class)); + $router->add('admins', $c->get(ChatAdminScenario::class)); + $router->add('pin', $c->get(ChatAdminScenario::class)); + $router->add('action', $c->get(ChatAdminScenario::class)); + $router->add('leave', $c->get(ChatAdminScenario::class)); + $router->add('edit_chat', $c->get(ChatAdminScenario::class)); + $router->add('add_member', $c->get(ChatAdminScenario::class)); + $router->add('remove_member', $c->get(ChatAdminScenario::class)); + $router->add('send_text', $c->get(MessagesScenario::class)); + $router->add('send_markdown', $c->get(MessagesScenario::class)); + $router->add('send_html', $c->get(MessagesScenario::class)); + $router->add('reply', $c->get(MessagesScenario::class)); + $router->add('forward', $c->get(MessagesScenario::class)); + $router->add('edit', $c->get(MessagesScenario::class)); + $router->add('delete', $c->get(MessagesScenario::class)); + $router->add('messages', $c->get(MessagesScenario::class)); + $router->add('message', $c->get(MessagesScenario::class)); + $router->add('markup', $c->get(MessagesScenario::class)); + $router->add('notify', $c->get(MessagesScenario::class)); + $router->add('disable_preview', $c->get(MessagesScenario::class)); + $router->add('keyboard', $c->get(KeyboardScenario::class)); + $router->add('upload_photo_file', $c->get(UploadsScenario::class)); + $router->add('upload_photo_url', $c->get(UploadsScenario::class)); + $router->add('upload_photo_bytes', $c->get(UploadsScenario::class)); + $router->add('upload_photo_base64', $c->get(UploadsScenario::class)); + $router->add('upload_audio', $c->get(UploadsScenario::class)); + $router->add('upload_video', $c->get(UploadsScenario::class)); + $router->add('upload_file', $c->get(UploadsScenario::class)); + $router->add('upload_url', $c->get(UploadsScenario::class)); + $router->add('attach_location', $c->get(AttachmentScenario::class)); + $router->add('attach_contact', $c->get(AttachmentScenario::class)); + $router->add('attach_sticker', $c->get(AttachmentScenario::class)); + $router->add('phone_check', $c->get(PhoneCheckScenario::class)); + $router->add('phone_list', $c->get(PhoneCheckScenario::class)); + $router->add('debug', $c->get(DebugScenario::class)); + return $router; + }, + + UpdateRouter::class => function (ContainerInterface $c): UpdateRouter { + $router = new UpdateRouter($c->get(LoggerInterface::class)); + $router->add($c->get(MessageCreatedHandler::class)); + $router->add($c->get(MessageCallbackHandler::class)); + $router->add($c->get(MessageEditedHandler::class)); + $router->add($c->get(MessageRemovedHandler::class)); + $router->add($c->get(BotStartedHandler::class)); + $router->add($c->get(BotAddedToChatHandler::class)); + $router->add($c->get(BotRemovedFromChatHandler::class)); + $router->add($c->get(BotStopedFromChatHandler::class)); + $router->add($c->get(UserAddedToChatHandler::class)); + $router->add($c->get(UserRemovedFromChatHandler::class)); + $router->add($c->get(ChatTitleChangedHandler::class)); + $router->add($c->get(DialogClearedHandler::class)); + $router->add($c->get(DialogRemovedHandler::class)); + return $router; + }, + + WebhookHandler::class => function (ContainerInterface $c): WebhookHandler { + $cfg = $c->get('config'); + $handler = new WebhookHandler( + responseFactory: $c->get(Psr17Factory::class), + secret: (string) $cfg['bot.secret'], + keepRawUpdates: true, + ); + $router = $c->get(UpdateRouter::class); + $logger = $c->get(LoggerInterface::class); + $handler->setHandler(static function (object $update) use ($router, $logger): void { + try { + $router->dispatch($update); + } catch (\Throwable $e) { + $logger->error('Update failed: ' . $e->getMessage(), ['exception' => $e]); + } + }); + return $handler; + }, + + HelpScenario::class => \DI\autowire(), + BotInfoScenario::class => \DI\autowire(), + ChatsScenario::class => \DI\autowire(), + ChatAdminScenario::class => \DI\autowire(), + MessagesScenario::class => \DI\autowire(), + UploadsScenario::class => \DI\autowire(), + KeyboardScenario::class => \DI\autowire(), + AttachmentScenario::class => \DI\autowire(), + PhoneCheckScenario::class => \DI\autowire(), + DebugScenario::class => \DI\autowire(), + + MessageCreatedHandler::class => \DI\autowire(), + MessageCallbackHandler::class => \DI\autowire(), + MessageEditedHandler::class => \DI\autowire(), + MessageRemovedHandler::class => \DI\autowire(), + BotStartedHandler::class => \DI\autowire(), + BotAddedToChatHandler::class => \DI\autowire(), + BotRemovedFromChatHandler::class => \DI\autowire(), + BotStopedFromChatHandler::class => \DI\autowire(), + UserAddedToChatHandler::class => \DI\autowire(), + UserRemovedFromChatHandler::class => \DI\autowire(), + ChatTitleChangedHandler::class => \DI\autowire(), + DialogClearedHandler::class => \DI\autowire(), + DialogRemovedHandler::class => \DI\autowire(), + + Application::class => function (ContainerInterface $c): Application { + $app = new Application('max-bot-test', '1.0.0'); + $app->addCommands([ + $c->get(\App\Command\SubscribeWebhookCommand::class), + $c->get(\App\Command\UnsubscribeWebhookCommand::class), + $c->get(\App\Command\ListSubscriptionsCommand::class), + $c->get(\App\Command\GetBotInfoCommand::class), + $c->get(\App\Command\SelfTestCommand::class), + ]); + return $app; + }, +]; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..420d464 --- /dev/null +++ b/config/routes.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +use Nyholm\Psr7\Factory\Psr17Factory; +use Pkirillw\MaxBotApi\Webhook\WebhookHandler; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\App; + +return static function (App $app): void { + $app->post('/webhook', WebhookHandler::class); + + $app->get('/health', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + $payload = json_encode(['ok' => true, 'time' => date('c')], JSON_UNESCAPED_UNICODE); + $response->getBody()->write((string) $payload); + return $response->withHeader('Content-Type', 'application/json'); + }); +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6eb0e4e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: max-bot-test-app + restart: unless-stopped + env_file: + - .env + environment: + - TZ=Europe/Moscow + volumes: + - ./storage:/var/www/html/storage + networks: + - max-bot-test + - npm_default + + nginx: + image: nginx:alpine + container_name: max-bot-test-nginx + restart: unless-stopped + volumes: + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + networks: + - max-bot-test + - npm_default + +networks: + max-bot-test: + driver: bridge + npm_default: + external: true diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..4a66375 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + root /var/www/html/public; + index index.php; + + client_max_body_size 25m; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTPS on; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..b116d98 --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,14 @@ +expose_php = Off +date.timezone = UTC +display_errors = On +log_errors = On +error_log = /proc/self/fd/2 +error_reporting = E_ALL +memory_limit = 128M +upload_max_filesize = 25M +post_max_size = 26M + +opcache.enable = 1 +opcache.memory_consumption = 128 +opcache.max_accelerated_files = 10000 +opcache.validate_timestamps = 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9697953 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="vendor/autoload.php" + colors="true" + cacheDirectory=".phpunit.cache" + executionOrder="depends,defects" + requireCoverageMetadata="false" + beStrictAboutCoverageMetadata="false" + beStrictAboutOutputDuringTests="true" + displayDetailsOnTestsThatTriggerWarnings="true" + failOnRisky="true" + failOnWarning="true"> + <testsuites> + <testsuite name="Unit"> + <directory>tests/Unit</directory> + </testsuite> + <testsuite name="Integration"> + <directory>tests/Integration</directory> + </testsuite> + </testsuites> + <php> + <env name="APP_ENV" value="test"/> + <env name="BOT_TOKEN" value="test-token"/> + <env name="MAX_BOT_API_SECRET" value="test-secret"/> + </php> +</phpunit> diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ad1b286 --- /dev/null +++ b/public/index.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use App\Bootstrap; + +$app = Bootstrap::app(); +$app->run(); diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..9d4b374 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace App; + +use DI\Container; +use Nyholm\Psr7\Factory\Psr17Factory; +use Slim\App; +use Slim\Factory\AppFactory; +use Symfony\Component\Dotenv\Dotenv; + +final class Bootstrap +{ + public static function loadEnv(): void + { + $envFile = __DIR__ . '/../.env'; + if (is_readable($envFile)) { + (new Dotenv())->usePutenv(true)->load($envFile); + } + } + + /** + * @return array<string, mixed> + */ + public static function envConfig(): array + { + return [ + 'bot.token' => (string) getenv('BOT_TOKEN'), + 'bot.secret' => (string) getenv('MAX_BOT_API_SECRET'), + 'webhook.url' => (string) getenv('WEBHOOK_URL'), + 'webapp.url' => (string) getenv('WEB_APP_URL'), + 'debug.chat_id' => (int) (getenv('DEBUG_CHAT_ID') ?: 0), + 'selftest.chat_id' => (int) (getenv('SELFTEST_CHAT_ID') ?: 0), + 'app.env' => (string) (getenv('APP_ENV') ?: 'dev'), + 'app.debug' => filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN), + ]; + } + + public static function container(): Container + { + self::loadEnv(); + $definitions = require __DIR__ . '/../config/container.php'; + return new Container($definitions); + } + + public static function app(): App + { + $container = self::container(); + $cfg = $container->get('config'); + AppFactory::setContainer($container); + + $app = AppFactory::create(); + $app->addErrorMiddleware( + displayErrorDetails: (bool) ($cfg['app.debug'] ?? false), + logErrors: true, + logErrorDetails: true, + logger: $container->get(\Psr\Log\LoggerInterface::class), + ); + (require __DIR__ . '/../config/routes.php')($app); + return $app; + } +} diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php new file mode 100644 index 0000000..fe06b92 --- /dev/null +++ b/src/Command/AbstractCommand.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use Pkirillw\MaxBotApi\Api; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +abstract class AbstractCommand extends Command +{ + public function __construct( + protected Api $api, + protected LoggerInterface $logger, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + try { + return $this->doExecute($io, $input, $output); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + $io->error($e->getMessage()); + return Command::FAILURE; + } + } + + abstract protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int; +} diff --git a/src/Command/GetBotInfoCommand.php b/src/Command/GetBotInfoCommand.php new file mode 100644 index 0000000..046c889 --- /dev/null +++ b/src/Command/GetBotInfoCommand.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'bot:info', description: 'GET /me — print BotInfo.')] +final class GetBotInfoCommand extends AbstractCommand +{ + + protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int + { + $info = $this->api->bots->getBot(); + $io->text('name: ' . $info->name); + $io->text('username: @' . ($info->username ?? '—')); + $io->text('user_id: ' . $info->userId); + $io->text('description: ' . ($info->description ?? '—')); + $io->text('commands:'); + foreach ($info->commands as $c) { + $io->text(" /{$c->name} — {$c->description}"); + } + return self::SUCCESS; + } +} diff --git a/src/Command/ListSubscriptionsCommand.php b/src/Command/ListSubscriptionsCommand.php new file mode 100644 index 0000000..22b9e1c --- /dev/null +++ b/src/Command/ListSubscriptionsCommand.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'subs:list', description: 'GET /subscriptions — list registered webhooks.')] +final class ListSubscriptionsCommand extends AbstractCommand +{ + + protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int + { + $subs = $this->api->subscriptions->getSubscriptions(); + if ($subs->subscriptions === []) { + $io->text('(no subscriptions)'); + return self::SUCCESS; + } + $rows = []; + foreach ($subs->subscriptions as $s) { + $rows[] = [$s->url, $s->version ?? '—', implode(',', $s->updateTypes) ?: '(all)']; + } + $io->table(['url', 'version', 'update_types'], $rows); + return self::SUCCESS; + } +} diff --git a/src/Command/SelfTestCommand.php b/src/Command/SelfTestCommand.php new file mode 100644 index 0000000..fde192b --- /dev/null +++ b/src/Command/SelfTestCommand.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use App\Bootstrap; +use Symfony\Component\Console\Attribute\AsCommand; +use Pkirillw\MaxBotApi\Builder\Keyboard; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Intent; +use Pkirillw\MaxBotApi\Scheme\PinMessageBody; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'selftest', description: 'Smoke-test every endpoint group end-to-end.')] +final class SelfTestCommand extends AbstractCommand +{ + + /** @var list<array{0:string,1:string,2:bool}> */ + private array $results = []; + + protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int + { + $config = Bootstrap::envConfig(); + $chatId = (int) $config['selftest.chat_id']; + + if ($chatId <= 0) { + $io->warning('SELFTEST_CHAT_ID is not set — will skip chat-scoped checks (send/edit/delete/pin/leave).'); + } + + $this->probe($io, 'bots.getBot', fn () => $this->api->bots->getBot()->name); + $this->probe($io, 'chats.getChats', fn () => count($this->api->chats->getChats(count: 1)->chats) . ' chat(s)'); + $this->probe($io, 'subscriptions.getSubscriptions', fn () => count($this->api->subscriptions->getSubscriptions()->subscriptions) . ' sub(s)'); + $this->probe($io, 'uploads.uploadPhotoFromBytes', function () { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', true); + $tokens = $this->api->uploads->uploadPhotoFromBytes($png, 'pixel.png'); + return $tokens->photos === [] ? 'no tokens returned' : count($tokens->photos) . ' token(s)'; + }); + + if ($chatId > 0) { + $this->probe($io, 'messages.send (markdown + keyboard)', function () use ($chatId) { + $kb = (new Keyboard())->addRow()->addCallback('selftest', 'selftest:ok', Intent::Positive); + $msg = $this->api->messages->sendWithResult( + MessageBuilder::new() + ->setChat($chatId) + ->setText('*selftest* message') + ->addKeyboard($kb), + ); + $mid = $msg->body->mid; + if ($mid === '') { + throw new \RuntimeException('no mid returned'); + } + $this->probe($io, 'messages.editMessage', function () use ($mid, $chatId) { + $this->api->messages->editMessage($mid, MessageBuilder::new()->setChat($chatId)->setText('*selftest* edited')); + }); + $this->probe($io, 'chats.sendAction (typing)', fn () => $this->api->chats->sendAction($chatId, \Pkirillw\MaxBotApi\Scheme\Enum\SenderAction::TypingOn)); + $this->probe($io, 'chats.pinMessage', fn () => $this->api->chats->pinMessage($chatId, new PinMessageBody(messageId: $mid))); + $this->probe($io, 'messages.getMessage', fn () => $this->api->messages->getMessage($mid)->body?->text ?? '?'); + $this->probe($io, 'messages.getMessages', fn () => count($this->api->messages->getMessages($chatId, count: 1)->messages) . ' msg(s)'); + $this->probe($io, 'messages.deleteMessage', fn () => $this->api->messages->deleteMessage($mid)->success ? 'ok' : 'false'); + $this->probe($io, 'chats.getChat', fn () => $this->api->chats->getChat($chatId)->title ?? '(no title)'); + $this->probe($io, 'chats.getChatMembership', fn () => $this->api->chats->getChatMembership($chatId)->userId); + $this->probe($io, 'chats.getChatMembers', fn () => count($this->api->chats->getChatMembers($chatId, count: 1)->members) . ' member(s)'); + }); + } + + $this->probe($io, 'debugs.sendText', function () { + if ($this->api->debugs->getChatId() === null) { + return 'skipped (no DEBUG_CHAT_ID)'; + } + $this->api->debugs->sendText('selftest: ' . date('c')); + return 'sent'; + }); + + $table = new Table($output); + $table->setHeaders(['probe', 'result', 'ok']); + $table->setRows($this->results); + $table->render(); + + $failures = array_filter($this->results, fn($r) => !$r[2]); + return $failures === [] ? self::SUCCESS : self::FAILURE; + } + + private function probe(SymfonyStyle $io, string $name, callable $callback): void + { + try { + $result = $callback(); + $this->results[] = [$name, (string) $result, true]; + } catch (\Throwable $e) { + $this->results[] = [$name, mb_substr($e->getMessage(), 0, 120), false]; + } + } +} diff --git a/src/Command/SubscribeWebhookCommand.php b/src/Command/SubscribeWebhookCommand.php new file mode 100644 index 0000000..f461367 --- /dev/null +++ b/src/Command/SubscribeWebhookCommand.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use App\Bootstrap; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'webhook:subscribe', description: 'POST /subscriptions — register this bot webhook with MAX.')] +final class SubscribeWebhookCommand extends AbstractCommand +{ + + protected function configure(): void + { + $this->addOption('url', 'u', InputOption::VALUE_OPTIONAL, 'Override WEBHOOK_URL from env'); + $this->addOption('secret', 's', InputOption::VALUE_OPTIONAL, 'Override MAX_BOT_API_SECRET from env'); + $this->addOption('types', 't', InputOption::VALUE_OPTIONAL, 'Comma-separated update types (default: empty = all)'); + } + + protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int + { + $config = Bootstrap::envConfig(); + $url = (string) ($input->getOption('url') ?: $config['webhook.url']); + $secret = (string) ($input->getOption('secret') ?: $config['bot.secret']); + $typesArg = $input->getOption('types'); + $types = $typesArg === null ? [] : array_values(array_filter(explode(',', (string) $typesArg))); + + if ($url === '' || str_contains($url, 'your-tunnel-host')) { + $io->error('WEBHOOK_URL is empty or still default. Run `make logs-tunnel` to read the cloudflared URL and put it in .env.'); + return self::FAILURE; + } + if ($secret === '') { + $io->error('MAX_BOT_API_SECRET is empty. Generate one (e.g. `openssl rand -hex 32`) and put in .env.'); + return self::FAILURE; + } + + $io->text("Subscribing $url with " . ($types === [] ? 'all update types' : 'types: ' . implode(',', $types))); + $result = $this->api->subscriptions->subscribe($url, $types, $secret); + if (!$result->success) { + $io->error('Subscribe failed: ' . ($result->message ?? 'unknown')); + return self::FAILURE; + } + $io->success('Subscribed. Verifying via getSubscriptions()…'); + + $subs = $this->api->subscriptions->getSubscriptions(); + $rows = []; + foreach ($subs->subscriptions as $s) { + $rows[] = [$s->url, $s->version ?? '—', implode(',', $s->updateTypes) ?: '(all)']; + } + $io->table(['url', 'version', 'update_types'], $rows); + + return self::SUCCESS; + } +} diff --git a/src/Command/UnsubscribeWebhookCommand.php b/src/Command/UnsubscribeWebhookCommand.php new file mode 100644 index 0000000..b6f26d9 --- /dev/null +++ b/src/Command/UnsubscribeWebhookCommand.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace App\Command; + +use App\Bootstrap; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'webhook:unsubscribe', description: 'DELETE /subscriptions — remove webhook registration.')] +final class UnsubscribeWebhookCommand extends AbstractCommand +{ + + protected function configure(): void + { + $this->addArgument('url', InputArgument::OPTIONAL, 'Webhook URL to remove (defaults to WEBHOOK_URL)'); + } + + protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int + { + $config = Bootstrap::envConfig(); + $url = (string) ($input->getArgument('url') ?: $config['webhook.url']); + if ($url === '') { + $io->error('URL argument required (or set WEBHOOK_URL).'); + return self::FAILURE; + } + $result = $this->api->subscriptions->unsubscribe($url); + $io->success(sprintf('Unsubscribe %s: success=%s', $url, $result->success ? 'true' : 'false')); + return $result->success ? self::SUCCESS : self::FAILURE; + } +} diff --git a/src/Handler/AbstractUpdateHandler.php b/src/Handler/AbstractUpdateHandler.php new file mode 100644 index 0000000..0fd8c27 --- /dev/null +++ b/src/Handler/AbstractUpdateHandler.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler; + +use App\Keyboard\KeyboardFactory; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Exception\ApiException; +use Pkirillw\MaxBotApi\Exception\MaxBotApiException; +use Pkirillw\MaxBotApi\Exception\NetworkException; +use Pkirillw\MaxBotApi\Exception\TimeoutException; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +abstract class AbstractUpdateHandler implements UpdateHandlerInterface +{ + public function __construct( + protected Api $api, + protected LoggerInterface $logger, + protected KeyboardFactory $keyboards, + ) {} + + public function safeHandle(UpdateInterface $update): void + { + $this->logger->info('update: ' . $update::class, [ + 'type' => $update->getUpdateType()->value, + 'chat_id' => $update->getChatId(), + 'user_id' => $update->getUserId(), + ]); + + try { + $this->handle($update); + } catch (ApiException $e) { + $this->logger->error('API error: ' . $e->getMessage(), [ + 'api_code' => $e->apiCode, + 'http_code' => $e->httpCode, + ]); + $this->notifyAdmin($update, sprintf('API error `%s`: %s', $e->apiCode, $e->getMessage())); + } catch (NetworkException $e) { + $this->logger->warning('network: ' . $e->getMessage()); + } catch (TimeoutException $e) { + $this->logger->warning('timeout: ' . $e->getMessage()); + } catch (MaxBotApiException $e) { + $this->logger->error('lib error: ' . $e->getMessage()); + $this->api->debugs->sendErr($e); + $this->notifyAdmin($update, 'Library error: ' . $e->getMessage()); + } + } + + protected function notifyAdmin(UpdateInterface $update, string $text): void + { + $chatId = $update->getChatId(); + if ($chatId <= 0) { + return; + } + try { + $this->api->messages->send( + MessageBuilder::new() + ->setChat($chatId) + ->setText('⚠ ' . $text) + ->setFormat(Format::Markdown), + ); + } catch (\Throwable $e) { + $this->logger->error('notifyAdmin failed: ' . $e->getMessage()); + } + } +} diff --git a/src/Handler/Update/BotAddedToChatHandler.php b/src/Handler/Update/BotAddedToChatHandler.php new file mode 100644 index 0000000..5b48717 --- /dev/null +++ b/src/Handler/Update/BotAddedToChatHandler.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Enum\SenderAction; +use Pkirillw\MaxBotApi\Scheme\Update\BotAddedToChatUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class BotAddedToChatHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\BotAddedToChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof BotAddedToChatUpdate) { + return; + } + $this->logger->info('bot added to chat', [ + 'chat_id' => $update->chatId, + 'by_user_id' => $update->user->userId, + ]); + // Use chats->sendAction to demo SenderAction::TypingOn. + $this->api->chats->sendAction($update->chatId, SenderAction::TypingOn); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->chatId) + ->setText("👋 Спасибо за приглашение! Набери `/help` для списка команд.") + ->setFormat(Format::Markdown), + ); + $this->api->chats->sendAction($update->chatId, SenderAction::TypingOff); + } +} diff --git a/src/Handler/Update/BotRemovedFromChatHandler.php b/src/Handler/Update/BotRemovedFromChatHandler.php new file mode 100644 index 0000000..c519710 --- /dev/null +++ b/src/Handler/Update/BotRemovedFromChatHandler.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\BotRemovedFromChatUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class BotRemovedFromChatHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\BotRemovedFromChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof BotRemovedFromChatUpdate) { + return; + } + $this->logger->info('bot removed from chat', [ + 'chat_id' => $update->chatId, + 'by_user_id' => $update->user->userId, + ]); + if ($this->api->debugs->getChatId() !== null) { + $this->api->debugs->sendText(sprintf('bot removed from chat %d by user %d', $update->chatId, $update->user->userId)); + } + } +} diff --git a/src/Handler/Update/BotStartedHandler.php b/src/Handler/Update/BotStartedHandler.php new file mode 100644 index 0000000..06a7f80 --- /dev/null +++ b/src/Handler/Update/BotStartedHandler.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use App\Scenario\HelpScenario; +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\BotStartedUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class BotStartedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\BotStartedUpdate::class; } + + public function __construct( + Api $api, + LoggerInterface $logger, + \App\Keyboard\KeyboardFactory $keyboards, + private HelpScenario $help, + ) { + parent::__construct($api, $logger, $keyboards); + } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof BotStartedUpdate) { + return; + } + $this->logger->info('bot started', [ + 'chat_id' => $update->chatId, + 'user_id' => $update->user->userId, + 'payload' => $update->payload, + ]); + + // Build a synthetic MessageCreatedUpdate-like ParsedCommand so HelpScenario handles it. + $dummy = new \Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate( + message: $update->user->userId ? new \Pkirillw\MaxBotApi\Scheme\Message( + sender: $update->user, + recipient: new \Pkirillw\MaxBotApi\Scheme\Recipient(chatId: $update->chatId, userId: $update->user->userId), + ) : new \Pkirillw\MaxBotApi\Scheme\Message(), + timestamp: $update->timestamp, + debugRaw: $update->debugRaw, + ); + $command = new ParsedCommand(name: 'help', args: [], raw: '/help', tail: ''); + $this->help->handle($dummy, $command); + } +} diff --git a/src/Handler/Update/BotStopedFromChatHandler.php b/src/Handler/Update/BotStopedFromChatHandler.php new file mode 100644 index 0000000..ab64200 --- /dev/null +++ b/src/Handler/Update/BotStopedFromChatHandler.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\BotStopedFromChatUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class BotStopedFromChatHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\BotStopedFromChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof BotStopedFromChatUpdate) { + return; + } + $this->logger->info('bot stopped (blocked) by user', [ + 'chat_id' => $update->chatId, + 'user_id' => $update->user->userId, + ]); + if ($this->api->debugs->getChatId() !== null) { + $this->api->debugs->sendText(sprintf('user %d stopped the bot', $update->user->userId)); + } + } +} diff --git a/src/Handler/Update/ChatTitleChangedHandler.php b/src/Handler/Update/ChatTitleChangedHandler.php new file mode 100644 index 0000000..ba7b2a7 --- /dev/null +++ b/src/Handler/Update/ChatTitleChangedHandler.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\ChatTitleChangedUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class ChatTitleChangedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\ChatTitleChangedUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof ChatTitleChangedUpdate) { + return; + } + $this->logger->info('chat title changed', [ + 'chat_id' => $update->chatId, + 'new_title' => $update->title, + 'by_user_id' => $update->user->userId, + ]); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->chatId) + ->setText(sprintf('📝 Новый заголовок чата: *%s*', $update->title)) + ->setFormat(Format::Markdown), + ); + } +} diff --git a/src/Handler/Update/DialogClearedHandler.php b/src/Handler/Update/DialogClearedHandler.php new file mode 100644 index 0000000..cb66594 --- /dev/null +++ b/src/Handler/Update/DialogClearedHandler.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\DialogClearedFromChatUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class DialogClearedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\DialogClearedFromChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof DialogClearedFromChatUpdate) { + return; + } + $this->logger->info('dialog cleared', [ + 'chat_id' => $update->chatId, + 'by_user_id' => $update->user->userId, + ]); + // Demonstrate Debugs::send(update) — pushes raw payload to the debug chat. + if ($this->api->debugs->getChatId() !== null) { + $this->api->debugs->send($update); + } + } +} diff --git a/src/Handler/Update/DialogRemovedHandler.php b/src/Handler/Update/DialogRemovedHandler.php new file mode 100644 index 0000000..3267bb3 --- /dev/null +++ b/src/Handler/Update/DialogRemovedHandler.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\DialogRemovedFromChatUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class DialogRemovedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\DialogRemovedFromChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof DialogRemovedFromChatUpdate) { + return; + } + $this->logger->info('dialog removed', [ + 'chat_id' => $update->chatId, + 'by_user_id' => $update->user->userId, + ]); + } +} diff --git a/src/Handler/Update/MessageCallbackHandler.php b/src/Handler/Update/MessageCallbackHandler.php new file mode 100644 index 0000000..1180a48 --- /dev/null +++ b/src/Handler/Update/MessageCallbackHandler.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use App\Keyboard\KeyboardFactory; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\CallbackAnswer; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCallbackUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class MessageCallbackHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\MessageCallbackUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof MessageCallbackUpdate) { + return; + } + $callback = $update->callback; + $this->logger->info('callback: ' . $callback->payload, [ + 'callback_id' => $callback->callbackId, + 'user_id' => $callback->user?->userId ?? 0, + ]); + + // 1. answerOnCallback — show a notification toast. + $this->api->messages->answerOnCallback( + $callback->callbackId, + new CallbackAnswer(notification: 'Нажата кнопка: ' . $callback->payload), + ); + + // 2. also send a regular chat message so the interaction is visible in history. + $chatId = $update->getChatId(); + if ($chatId > 0) { + $this->api->messages->send( + MessageBuilder::new() + ->setChat($chatId) + ->setText(sprintf('*Клик по кнопке*\npayload: `%s`', $callback->payload)) + ->setFormat(Format::Markdown), + ); + } + } +} diff --git a/src/Handler/Update/MessageCreatedHandler.php b/src/Handler/Update/MessageCreatedHandler.php new file mode 100644 index 0000000..4cca4f8 --- /dev/null +++ b/src/Handler/Update/MessageCreatedHandler.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use App\Keyboard\KeyboardFactory; +use App\Scenario\HelpScenario; +use App\Util\CommandRouter; +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; +use Psr\Log\LoggerInterface; + +final class MessageCreatedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate::class; } + + public function __construct( + Api $api, + LoggerInterface $logger, + KeyboardFactory $keyboards, + private CommandRouter $router, + private HelpScenario $help, + ) { + parent::__construct($api, $logger, $keyboards); + } + + public function handle(\Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface $update): void + { + if (!$update instanceof MessageCreatedUpdate) { + return; + } + $command = ParsedCommand::parse($update->getText()); + + if ($command === null) { + // Free-text message — echo it back so the bot feels alive in dialog mode. + $senderName = $update->message->sender?->name ?? 'незнакомец'; + $this->api->messages->send( + \Pkirillw\MaxBotApi\Builder\Message::new() + ->setChat($update->getChatId()) + ->setText(sprintf('Привет, %s! Я бот-демо. Набери /help для списка команд.', $senderName)), + ); + return; + } + + $this->logger->info('command: ' . $command->name, ['args' => $command->args]); + + if ($command->name === 'start') { + $this->help->handle($update, $command); + return; + } + + if (!$this->router->route($update, $command)) { + $this->help->handleUnknown($update, $command); + } + } +} diff --git a/src/Handler/Update/MessageEditedHandler.php b/src/Handler/Update/MessageEditedHandler.php new file mode 100644 index 0000000..182f015 --- /dev/null +++ b/src/Handler/Update/MessageEditedHandler.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageEditedUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class MessageEditedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\MessageEditedUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof MessageEditedUpdate) { + return; + } + $mid = $update->message->body?->mid ?? '?'; + $text = $update->message->body?->text ?? ''; + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText(sprintf('✎ *Отредактировано* (%s): %s', $mid, mb_substr($text, 0, 200))) + ->setFormat(Format::Markdown), + ); + } +} diff --git a/src/Handler/Update/MessageRemovedHandler.php b/src/Handler/Update/MessageRemovedHandler.php new file mode 100644 index 0000000..a0b2709 --- /dev/null +++ b/src/Handler/Update/MessageRemovedHandler.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Update\MessageRemovedUpdate; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class MessageRemovedHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\MessageRemovedUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof MessageRemovedUpdate) { + return; + } + $this->logger->info('message removed', [ + 'message_id' => $update->messageId, + 'chat_id' => $update->chatId, + 'user_id' => $update->userId, + ]); + // No chat context to reply into reliably here — leave a debug trail. + if ($this->api->debugs->getChatId() !== null) { + $this->api->debugs->sendText(sprintf('removed: mid=%s user=%d', $update->messageId, $update->userId)); + } + } +} diff --git a/src/Handler/Update/UserAddedToChatHandler.php b/src/Handler/Update/UserAddedToChatHandler.php new file mode 100644 index 0000000..d9aa3cc --- /dev/null +++ b/src/Handler/Update/UserAddedToChatHandler.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Pkirillw\MaxBotApi\Scheme\Update\UserAddedToChatUpdate; +use Psr\Log\LoggerInterface; + +final class UserAddedToChatHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\UserAddedToChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof UserAddedToChatUpdate) { + return; + } + $this->logger->info('user added to chat', [ + 'chat_id' => $update->chatId, + 'added_user_id' => $update->user->userId, + 'by_inviter_id' => $update->inviterId, + ]); + $name = $update->user->name ?: ('user #' . $update->user->userId); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->chatId) + ->setText(sprintf('👋 Добро пожаловать, *%s*!', $name)) + ->setFormat(Format::Markdown), + ); + } +} diff --git a/src/Handler/Update/UserRemovedFromChatHandler.php b/src/Handler/Update/UserRemovedFromChatHandler.php new file mode 100644 index 0000000..ed2880a --- /dev/null +++ b/src/Handler/Update/UserRemovedFromChatHandler.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler\Update; + +use App\Handler\AbstractUpdateHandler; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Pkirillw\MaxBotApi\Scheme\Update\UserRemovedFromChatUpdate; +use Psr\Log\LoggerInterface; + +final class UserRemovedFromChatHandler extends AbstractUpdateHandler +{ + public function updateClass(): string { return \Pkirillw\MaxBotApi\Scheme\Update\UserRemovedFromChatUpdate::class; } + + public function handle(UpdateInterface $update): void + { + if (!$update instanceof UserRemovedFromChatUpdate) { + return; + } + $this->logger->info('user removed from chat', [ + 'chat_id' => $update->chatId, + 'removed_user_id' => $update->user->userId, + 'by_admin_id' => $update->adminId, + ]); + } +} diff --git a/src/Handler/UpdateHandlerInterface.php b/src/Handler/UpdateHandlerInterface.php new file mode 100644 index 0000000..2731170 --- /dev/null +++ b/src/Handler/UpdateHandlerInterface.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler; + +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; + +interface UpdateHandlerInterface +{ + /** + * @return class-string<UpdateInterface> + */ + public function updateClass(): string; + + public function handle(UpdateInterface $update): void; +} diff --git a/src/Handler/UpdateRouter.php b/src/Handler/UpdateRouter.php new file mode 100644 index 0000000..3ba3330 --- /dev/null +++ b/src/Handler/UpdateRouter.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace App\Handler; + +use Pkirillw\MaxBotApi\Scheme\Update\UpdateInterface; +use Psr\Log\LoggerInterface; + +final class UpdateRouter +{ + /** @var list<UpdateHandlerInterface> */ + private array $handlers = []; + + public function __construct( + private LoggerInterface $logger, + ) {} + + public function add(UpdateHandlerInterface $handler): void + { + $this->handlers[] = $handler; + } + + public function dispatch(object $update): void + { + if (!$update instanceof UpdateInterface) { + $this->logger->warning('unsupported update payload: ' . $update::class); + return; + } + + foreach ($this->handlers as $handler) { + if ($handler->updateClass() === $update::class) { + $handler->safeHandle($update); + return; + } + } + $this->logger->warning('no handler registered for ' . $update::class); + } +} diff --git a/src/Keyboard/KeyboardFactory.php b/src/Keyboard/KeyboardFactory.php new file mode 100644 index 0000000..ca384fb --- /dev/null +++ b/src/Keyboard/KeyboardFactory.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace App\Keyboard; + +use Pkirillw\MaxBotApi\Builder\Keyboard; +use Pkirillw\MaxBotApi\Scheme\Enum\Intent; + +/** + * Pre-built keyboards that exercise every button type supported by the library. + */ +final class KeyboardFactory +{ + public function __construct(private readonly ?string $webAppUrl = null) {} + + /** + * Full keyboard — one row per button type, plus a 3-button callback row with all Intents. + * OpenApp button is only added when WEB_APP_URL points to a real, bot-registered web app. + */ + public function full(): Keyboard + { + $kb = new Keyboard(); + + $row = $kb->addRow(); + $row->addCallback('Callback: Positive', 'cb:positive', Intent::Positive) + ->addCallback('Callback: Negative', 'cb:negative', Intent::Negative) + ->addCallback('Callback: Default', 'cb:default', Intent::Default); + + $kb->addRow()->addLink('Link: dev.max.ru', 'https://dev.max.ru'); + $kb->addRow()->addMessage('Message: send this text'); + $kb->addRow()->addClipboard('Clipboard: copy token', 'TOKEN-12345'); + $kb->addRow()->addContact('Request contact'); + $geo = $kb->addRow(); + $geo->addGeolocation('Request geo') + ->addGeolocation('Request geo (quick)', quick: true); + if ($this->webAppUrl !== null && $this->webAppUrl !== '') { + $kb->addRow()->addOpenApp('Open web app', $this->webAppUrl, 'demo-payload'); + } + + return $kb; + } + + public function simpleCallback(): Keyboard + { + $kb = new Keyboard(); + $kb->addRow() + ->addCallback('Yes', 'vote:yes', Intent::Positive) + ->addCallback('No', 'vote:no', Intent::Negative); + return $kb; + } +} diff --git a/src/Logger/LoggerFactory.php b/src/Logger/LoggerFactory.php new file mode 100644 index 0000000..266931f --- /dev/null +++ b/src/Logger/LoggerFactory.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace App\Logger; + +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Monolog\Processor\IntrospectionProcessor; +use Monolog\Processor\WebProcessor; +use Psr\Log\LoggerInterface; + +final class LoggerFactory +{ + /** + * @param array<string, mixed> $config + */ + public static function create(array $config): LoggerInterface + { + $name = 'bot'; + $debug = (bool) ($config['app.debug'] ?? false); + $path = __DIR__ . '/../../storage/logs/app.log'; + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + $logger = new Logger($name); + $logger->pushHandler(new StreamHandler($path, $debug ? Logger::DEBUG : Logger::INFO)); + $logger->pushHandler(new StreamHandler('php://stderr', $debug ? Logger::DEBUG : Logger::INFO)); + $logger->pushProcessor(new WebProcessor()); + return $logger; + } +} diff --git a/src/Scenario/AbstractScenario.php b/src/Scenario/AbstractScenario.php new file mode 100644 index 0000000..352fe2a --- /dev/null +++ b/src/Scenario/AbstractScenario.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Keyboard\KeyboardFactory; +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Api; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; +use Psr\Log\LoggerInterface; + +abstract class AbstractScenario implements ScenarioInterface +{ + public function __construct( + protected Api $api, + protected LoggerInterface $logger, + protected KeyboardFactory $keyboards, + ) {} + + protected function reply(MessageCreatedUpdate $update, string $text, ?Format $format = null): void + { + $builder = MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($text); + if ($format !== null) { + $builder->setFormat($format); + } + $this->api->messages->send($builder); + } + + protected function replyMarkdown(MessageCreatedUpdate $update, string $text): void + { + $this->reply($update, $text, Format::Markdown); + } + + protected function err(string $message, ParsedCommand $command): void + { + $this->logger->error($message, ['command' => $command->name, 'args' => $command->args]); + } +} diff --git a/src/Scenario/AttachmentScenario.php b/src/Scenario/AttachmentScenario.php new file mode 100644 index 0000000..c87b52c --- /dev/null +++ b/src/Scenario/AttachmentScenario.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class AttachmentScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'attach_location': + $this->attachLocation($update, $command); + return; + case 'attach_contact': + $this->attachContact($update, $command); + return; + case 'attach_sticker': + $this->attachSticker($update, $command); + return; + } + } + + private function attachLocation(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $lat = (float) ($command->arg(0) ?: '55.7558'); + $lng = (float) ($command->arg(1) ?: '37.6173'); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText(sprintf('Location: %.4f, %.4f (Moscow by default)', $lat, $lng)) + ->setFormat(Format::Markdown) + ->addLocation($lat, $lng), + ); + } + + private function attachContact(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $name = $command->arg(0, 'Test Contact'); + $contactId = $command->argInt(1, $update->getUserId()); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText('Contact attachment:') + ->addContact($name, $contactId), + ); + } + + private function attachSticker(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $code = $command->arg(0, '281'); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->addSticker($code), + ); + } +} diff --git a/src/Scenario/BotInfoScenario.php b/src/Scenario/BotInfoScenario.php new file mode 100644 index 0000000..40b663a --- /dev/null +++ b/src/Scenario/BotInfoScenario.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\BotPatch; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class BotInfoScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + if ($command->name === 'bot') { + $this->showBot($update); + return; + } + if ($command->name === 'bot_patch') { + $this->patchBot($update, $command); + return; + } + } + + private function showBot(MessageCreatedUpdate $update): void + { + $info = $this->api->bots->getBot(); + $commands = []; + foreach ($info->commands as $c) { + $commands[] = sprintf(' • /%s — %s', $c->name, $c->description); + } + $text = sprintf( + "*Bot info*\nname: %s\nusername: @%s\nuserId: %d\ndescription: %s\ncommands:\n%s", + $info->name, + $info->username ?? '—', + $info->userId, + $info->description ?? '—', + $commands === [] ? ' (none)' : implode("\n", $commands), + ); + + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($text) + ->setFormat(Format::Markdown), + ); + } + + private function patchBot(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $name = $command->arg(0); + if ($name === null || $name === '') { + $this->replyMarkdown($update, 'Usage: `/bot_patch <newName>`'); + return; + } + $patched = $this->api->bots->patchBot(new BotPatch(name: $name)); + $this->replyMarkdown($update, sprintf('Bot name updated to *%s*', $patched->name)); + } +} diff --git a/src/Scenario/ChatAdminScenario.php b/src/Scenario/ChatAdminScenario.php new file mode 100644 index 0000000..02db9c8 --- /dev/null +++ b/src/Scenario/ChatAdminScenario.php @@ -0,0 +1,168 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\ChatPatch; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Enum\SenderAction; +use Pkirillw\MaxBotApi\Scheme\PinMessageBody; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; +use Pkirillw\MaxBotApi\Scheme\UserIdsList; + +final class ChatAdminScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'members': + $this->listMembers($update, $command); + return; + case 'admins': + $this->listAdmins($update, $command); + return; + case 'action': + $this->sendAction($update, $command); + return; + case 'pin': + $this->pinMessage($update, $command); + return; + case 'edit_chat': + $this->editChat($update, $command); + return; + case 'add_member': + $this->addMember($update, $command); + return; + case 'remove_member': + $this->removeMember($update, $command); + return; + case 'leave': + $this->leaveChat($update, $command); + return; + } + } + + private function listMembers(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/members <chatId>`'); + return; + } + $members = $this->api->chats->getChatMembers($chatId, count: 20); + $lines = []; + foreach ($members->members as $m) { + $lines[] = sprintf('• %d %s @%s owner=%s admin=%s', $m->userId, $m->name, $m->username ?? '', $m->isOwner ? '✓' : '·', $m->isAdmin ? '✓' : '·'); + } + $text = "*Members of $chatId*\n" . ($lines === [] ? '(empty)' : implode("\n", $lines)); + + // Also demo getSpecificChatMembers + getChatAdmins. + $userIds = array_slice(array_map(fn($m) => $m->userId, $members->members), 0, 3); + $specificNote = ''; + if ($userIds !== []) { + $specific = $this->api->chats->getSpecificChatMembers($chatId, $userIds); + $specificNote = sprintf("\n\ngetSpecificChatMembers(%s): %d hit(s)", implode(',', $userIds), count($specific->members)); + } + $admins = $this->api->chats->getChatAdmins($chatId); + $adminNote = sprintf("\ngetChatAdmins(): %d admin(s)", count($admins->members)); + + $this->api->messages->send( + MessageBuilder::new()->setChat($update->getChatId())->setText($text . $specificNote . $adminNote)->setFormat(Format::Markdown), + ); + } + + private function listAdmins(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/admins <chatId>`'); + return; + } + $admins = $this->api->chats->getChatAdmins($chatId); + $lines = []; + foreach ($admins->members as $m) { + $lines[] = sprintf('• %d %s (owner=%s)', $m->userId, $m->name, $m->isOwner ? '✓' : '·'); + } + $this->replyMarkdown($update, "*Admins of $chatId*\n" . ($lines === [] ? '(none)' : implode("\n", $lines))); + } + + private function sendAction(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $actionStr = $command->arg(1); + if ($chatId <= 0 || $actionStr === null) { + $valid = implode(', ', array_map(fn($c) => $c->value, SenderAction::cases())); + $this->replyMarkdown($update, "Usage: `/action <chatId> <$valid>`"); + return; + } + $action = SenderAction::tryFrom($actionStr); + if ($action === null) { + $this->replyMarkdown($update, "Unknown action `$actionStr`"); + return; + } + $this->api->chats->sendAction($chatId, $action); + $this->replyMarkdown($update, "sendAction `$action->value` sent to `$chatId`"); + } + + private function pinMessage(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $messageId = $command->arg(1); + if ($chatId <= 0 || $messageId === null) { + $this->replyMarkdown($update, 'Usage: `/pin <chatId> <messageId>`'); + return; + } + $this->api->chats->pinMessage($chatId, new PinMessageBody(messageId: $messageId, notify: true)); + $this->replyMarkdown($update, "Pinned `$messageId` in chat `$chatId`"); + } + + private function editChat(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $title = $command->arg(1); + if ($chatId <= 0 || $title === null) { + $this->replyMarkdown($update, 'Usage: `/edit_chat <chatId> <newTitle>`'); + return; + } + $chat = $this->api->chats->editChat($chatId, new ChatPatch(title: $title)); + $this->replyMarkdown($update, "Chat `$chatId` title updated to *$chat->title*"); + } + + private function addMember(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $userId = $command->argInt(1); + if ($chatId <= 0 || $userId <= 0) { + $this->replyMarkdown($update, 'Usage: `/add_member <chatId> <userId>`'); + return; + } + $result = $this->api->chats->addMember($chatId, new UserIdsList(userIds: [$userId])); + $this->replyMarkdown($update, sprintf('addMember result: success=%s', $result->success ? 'true' : 'false')); + } + + private function removeMember(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $userId = $command->argInt(1); + if ($chatId <= 0 || $userId <= 0) { + $this->replyMarkdown($update, 'Usage: `/remove_member <chatId> <userId>`'); + return; + } + $result = $this->api->chats->removeMember($chatId, $userId); + $this->replyMarkdown($update, sprintf('removeMember result: success=%s', $result->success ? 'true' : 'false')); + } + + private function leaveChat(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/leave <chatId>`'); + return; + } + $result = $this->api->chats->leaveChat($chatId); + $this->replyMarkdown($update, sprintf('Left chat %d: success=%s', $chatId, $result->success ? 'true' : 'false')); + } +} diff --git a/src/Scenario/ChatsScenario.php b/src/Scenario/ChatsScenario.php new file mode 100644 index 0000000..31c0490 --- /dev/null +++ b/src/Scenario/ChatsScenario.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class ChatsScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'chats': + $this->listChats($update, $command); + return; + case 'chat': + $this->showChat($update, $command); + return; + case 'chat_membership': + $this->showMembership($update, $command); + return; + } + } + + private function listChats(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $count = $command->argInt(0, 10); + $list = $this->api->chats->getChats($count); + $lines = []; + foreach ($list->chats as $chat) { + $lines[] = sprintf('• `%d` %s (type=%s, members=%d)', $chat->chatId, $chat->title ?? '—', $chat->type?->value ?? '?', $chat->participantsCount); + } + $text = "*Chats* (count=$count, marker=" . ($list->marker ?? 'null') . ")\n" . ($lines === [] ? '(empty)' : implode("\n", $lines)); + $this->api->messages->send( + MessageBuilder::new()->setChat($update->getChatId())->setText($text)->setFormat(Format::Markdown), + ); + } + + private function showChat(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/chat <chatId>`'); + return; + } + $chat = $this->api->chats->getChat($chatId); + $text = sprintf( + "*Chat %d*\ntitle: %s\ntype: %s\nstatus: %s\nowner: %d\nmembers: %d\npublic: %s\nlink: %s\ndescription: %s", + $chat->chatId, + $chat->title ?? '—', + $chat->type?->value ?? '?', + $chat->status?->value ?? '?', + $chat->ownerId, + $chat->participantsCount, + $chat->isPublic ? 'yes' : 'no', + $chat->link ?? '—', + $chat->description ?? '—', + ); + $this->api->messages->send( + MessageBuilder::new()->setChat($update->getChatId())->setText($text)->setFormat(Format::Markdown), + ); + } + + private function showMembership(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/chat_membership <chatId>`'); + return; + } + $m = $this->api->chats->getChatMembership($chatId); + $perms = array_map(fn($p) => $p->value, $m->permissions); + $text = sprintf( + "*Membership in %d*\nisOwner: %s\nisAdmin: %s\njoinTime: %d\npermissions: %s", + $chatId, + $m->isOwner ? 'yes' : 'no', + $m->isAdmin ? 'yes' : 'no', + $m->joinTime, + $perms === [] ? '(none)' : '`' . implode('`, `', $perms) . '`', + ); + $this->api->messages->send( + MessageBuilder::new()->setChat($update->getChatId())->setText($text)->setFormat(Format::Markdown), + ); + } +} diff --git a/src/Scenario/DebugScenario.php b/src/Scenario/DebugScenario.php new file mode 100644 index 0000000..7f22355 --- /dev/null +++ b/src/Scenario/DebugScenario.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class DebugScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $mode = $command->arg(0, 'status'); + $debugChatId = $this->api->debugs->getChatId(); + + switch ($mode) { + case 'on': + $this->api->debugs->withChatId($update->getChatId()); + $this->replyMarkdown($update, 'Debugs enabled in this chat. Send `/debug raw` to dump next update, `/debug err` to push a synthetic error.'); + return; + case 'off': + // We can't null out chatId from outside (no setter back to null); re-create Api via options. + // Workaround: send a notice. The library does not currently expose unsetChatId(). + $this->replyMarkdown($update, 'To disable debugs, set DEBUG_CHAT_ID= in .env and restart the container.'); + return; + case 'raw': + if ($debugChatId === null) { + $this->replyMarkdown($update, 'Debugs not enabled. Run `/debug on` first.'); + return; + } + $this->api->debugs->sendText('raw dump: ' . $update->getDebugRaw()); + $this->replyMarkdown($update, 'Sent raw update JSON to debug chat.'); + return; + case 'err': + if ($debugChatId === null) { + $this->replyMarkdown($update, 'Debugs not enabled. Run `/debug on` first.'); + return; + } + $this->api->debugs->sendErr(new \RuntimeException('synthetic error from /debug err')); + $this->replyMarkdown($update, 'Sent synthetic error to debug chat.'); + return; + case 'status': + default: + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText(sprintf('Debug chat: %s', $debugChatId === null ? '(disabled)' : "`$debugChatId`")) + ->setFormat(Format::Markdown), + ); + } + } +} diff --git a/src/Scenario/HelpScenario.php b/src/Scenario/HelpScenario.php new file mode 100644 index 0000000..0a7bc11 --- /dev/null +++ b/src/Scenario/HelpScenario.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Keyboard; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Enum\Intent; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class HelpScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $keyboard = new Keyboard(); + $keyboard->addRow() + ->addCallback('Клавиатуры', 'demo_keyboard', Intent::Positive) + ->addCallback('Загрузки', 'demo_uploads'); + $keyboard->addRow()->addLink('Документация MAX', 'https://dev.max.ru'); + + $text = <<<'MD' + *max-bot-test* — тестовый бот для [`max-bot-api-php`] + + Доступные команды: + + *Bot* + /bot — getBot() + /bot_patch <name> — patchBot() + + *Chats* + /chats [count] — getChats() + /chat <id> — getChat() + getChatMembership() + /members <id> — getChatMembers() + getChatAdmins() + /action <chatId> <typing_on|typing_off|mark_seen|sending_photo|sending_video|sending_audio> — sendAction() + /pin <chatId> <messageId> — pinMessage() + /edit_chat <chatId> <title> — editChat() + /add_member <chatId> <userId> — addMember() + /remove_member <chatId> <userId> — removeMember() + /leave <chatId> — leaveChat() + + *Messages* + /send_text <text> — send() plain + /send_markdown <text> — Format::Markdown + /send_html <text> — Format::Html + /reply <messageId> <text> — setReply() + /forward <messageId> — setForward() + /edit <messageId> <text> — editMessage() + /delete <messageId> — deleteMessage() + /messages <chatId> [count] — getMessages() + /message <messageId> — getMessage() + /markup <userId> — addMarkUp() (mention) + /notify <text> — setNotify(false) (silent) + /disable_preview <url> — setDisableLinkPreview(true) + /keyboard — inline keyboard со всеми 8 типами кнопок + + *Uploads* + /upload_photo_file [path] — uploadPhotoFromFile() + /upload_photo_url <url> — uploadPhotoFromUrl() + /upload_photo_bytes — uploadPhotoFromBytes() + /upload_photo_base64 — uploadPhotoFromBase64() + /upload_audio [path] — uploadMediaFromFile(Audio) + /upload_video [path] — uploadMediaFromFile(Video) + /upload_file [path] — uploadMediaFromFile(File) + /upload_url <audio|video|file> <url> — uploadMediaFromUrl() + + *Attachments* + /attach_location <lat> <lng> — addLocation() + /attach_contact <name> <userId> — addContact() + /attach_sticker <code> — addSticker() + + *Phone (notify)* + /phone_check <phone> — check() + /phone_list <phone1,phone2> — listExist() + + *Subscriptions (CLI)* + `make subscribe`, `make unsubscribe`, `bin/console subs:list` + + *Debug* + /debug <on|off|raw|err> — переключение Debugs + Options::withDebug + MD; + + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($text) + ->setFormat(Format::Markdown) + ->addKeyboard($keyboard), + ); + } + + public function handleUnknown(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $this->replyMarkdown( + $update, + sprintf('Не знаю команду `%s`. Набери `/help`.', $command->name), + ); + } +} diff --git a/src/Scenario/KeyboardScenario.php b/src/Scenario/KeyboardScenario.php new file mode 100644 index 0000000..7c8252b --- /dev/null +++ b/src/Scenario/KeyboardScenario.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class KeyboardScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText('Клавиатура со всеми типами кнопок. Жми — увидишь callback payload.') + ->setFormat(Format::Markdown) + ->addKeyboard($this->keyboards->full()), + ); + } +} diff --git a/src/Scenario/MessagesScenario.php b/src/Scenario/MessagesScenario.php new file mode 100644 index 0000000..54fd4d5 --- /dev/null +++ b/src/Scenario/MessagesScenario.php @@ -0,0 +1,192 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class MessagesScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'send_text': + $this->sendText($update, $command); + return; + case 'send_markdown': + $this->sendFormatted($update, $command, Format::Markdown); + return; + case 'send_html': + $this->sendFormatted($update, $command, Format::Html); + return; + case 'reply': + $this->replyToMessage($update, $command); + return; + case 'forward': + $this->forward($update, $command); + return; + case 'edit': + $this->edit($update, $command); + return; + case 'delete': + $this->delete($update, $command); + return; + case 'messages': + $this->listMessages($update, $command); + return; + case 'message': + $this->getMessage($update, $command); + return; + case 'markup': + $this->markup($update, $command); + return; + case 'notify': + $this->silentMessage($update, $command); + return; + case 'disable_preview': + $this->disablePreview($update, $command); + return; + } + } + + private function sendText(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $text = $command->tail !== '' ? $command->tail : 'Просто текст.'; + $this->api->messages->send(MessageBuilder::new()->setChat($update->getChatId())->setText($text)); + } + + private function sendFormatted(MessageCreatedUpdate $update, ParsedCommand $command, Format $format): void + { + $text = $command->tail !== '' ? $command->tail : '_empty_'; + $this->api->messages->send( + MessageBuilder::new()->setChat($update->getChatId())->setText($text)->setFormat($format), + ); + } + + private function replyToMessage(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $messageId = $command->arg(0); + $text = $command->tail; + if ($messageId === null || str_starts_with($text, $messageId)) { + // tail still includes messageId — strip it. + $text = trim(implode(' ', array_slice($command->args, 1))); + } + if ($messageId === null || $text === '') { + $this->replyMarkdown($update, 'Usage: `/reply <messageId> <text>`'); + return; + } + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setReply($text, $messageId), + ); + } + + private function forward(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $messageId = $command->arg(0); + if ($messageId === null) { + $this->replyMarkdown($update, 'Usage: `/forward <messageId>`'); + return; + } + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setForward($messageId), + ); + } + + private function edit(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $messageId = $command->arg(0); + $text = trim(implode(' ', array_slice($command->args, 1))); + if ($messageId === null || $text === '') { + $this->replyMarkdown($update, 'Usage: `/edit <messageId> <new text>`'); + return; + } + $this->api->messages->editMessage( + $messageId, + MessageBuilder::new()->setChat($update->getChatId())->setText($text), + ); + $this->replyMarkdown($update, "Edited `$messageId`"); + } + + private function delete(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $messageId = $command->arg(0); + if ($messageId === null) { + $this->replyMarkdown($update, 'Usage: `/delete <messageId>`'); + return; + } + $result = $this->api->messages->deleteMessage($messageId); + $this->replyMarkdown($update, sprintf('deleteMessage(%s): success=%s', $messageId, $result->success ? 'true' : 'false')); + } + + private function listMessages(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $chatId = $command->argInt(0); + $count = $command->argInt(1, 5); + if ($chatId <= 0) { + $this->replyMarkdown($update, 'Usage: `/messages <chatId> [count]`'); + return; + } + $list = $this->api->messages->getMessages($chatId, count: $count); + $lines = []; + foreach ($list->messages as $m) { + $lines[] = sprintf('• `%s` %s', $m->body?->mid ?? '?', mb_substr($m->body?->text ?? '', 0, 80)); + } + $this->replyMarkdown($update, "*Messages in $chatId*\n" . ($lines === [] ? '(empty)' : implode("\n", $lines))); + } + + private function getMessage(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $messageId = $command->arg(0); + if ($messageId === null) { + $this->replyMarkdown($update, 'Usage: `/message <messageId>`'); + return; + } + $msg = $this->api->messages->getMessage($messageId); + $sender = $msg->sender?->name ?? '—'; + $text = $msg->body?->text ?? '(no text)'; + $this->replyMarkdown($update, sprintf("*Message `%s`*\nsender: %s\ntext: %s", $messageId, $sender, $text)); + } + + private function markup(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $userId = $command->argInt(0, $update->getUserId()); + $text = sprintf('Hey user #%d — you are mentioned!', $userId); + // Mention the user by adding a markup over the entire text. + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($text) + ->addMarkUp($userId, 0, mb_strlen($text)), + ); + } + + private function silentMessage(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $text = $command->tail !== '' ? $command->tail : 'Тихое сообщение (без уведомления).'; + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($text) + ->setNotify(false), + ); + } + + private function disablePreview(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $url = $command->arg(0, 'https://dev.max.ru'); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText('Ссылка без превью: ' . $url) + ->setDisableLinkPreview(true), + ); + } +} diff --git a/src/Scenario/PhoneCheckScenario.php b/src/Scenario/PhoneCheckScenario.php new file mode 100644 index 0000000..15bb88b --- /dev/null +++ b/src/Scenario/PhoneCheckScenario.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class PhoneCheckScenario extends AbstractScenario +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'phone_check': + $this->checkPhone($update, $command); + return; + case 'phone_list': + $this->listPhones($update, $command); + return; + } + } + + private function checkPhone(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $phone = $command->arg(0, '70001234567'); + $msg = MessageBuilder::new() + ->setChat($update->getChatId()) + ->setPhoneNumbers([$phone]); + $exists = $this->api->messages->check($msg); + $this->replyMarkdown($update, sprintf('`check(%s)` → %s', $phone, $exists ? 'exists' : 'not found')); + } + + private function listPhones(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $raw = $command->arg(0, '70001234567,70009990000'); + $phones = array_values(array_filter(explode(',', $raw))); + $msg = MessageBuilder::new() + ->setChat($update->getChatId()) + ->setPhoneNumbers($phones); + $existing = $this->api->messages->listExist($msg); + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText(sprintf( + "*listExist*\ninput: `%s`\nfound (%d): `%s`", + implode(', ', $phones), + count($existing), + implode('`, `', $existing), + )) + ->setFormat(Format::Markdown), + ); + } +} diff --git a/src/Scenario/ScenarioInterface.php b/src/Scenario/ScenarioInterface.php new file mode 100644 index 0000000..c956174 --- /dev/null +++ b/src/Scenario/ScenarioInterface.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +interface ScenarioInterface +{ + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void; +} diff --git a/src/Scenario/UploadsScenario.php b/src/Scenario/UploadsScenario.php new file mode 100644 index 0000000..d9a6339 --- /dev/null +++ b/src/Scenario/UploadsScenario.php @@ -0,0 +1,154 @@ +<?php + +declare(strict_types=1); + +namespace App\Scenario; + +use App\Util\ParsedCommand; +use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder; +use Pkirillw\MaxBotApi\Scheme\Attachment\PhotoTokens; +use Pkirillw\MaxBotApi\Scheme\Attachment\UploadedInfo; +use Pkirillw\MaxBotApi\Scheme\Enum\Format; +use Pkirillw\MaxBotApi\Scheme\Enum\UploadType; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class UploadsScenario extends AbstractScenario +{ + private const DEFAULT_PHOTO_URL = 'https://placehold.co/600x400/png'; + private const DEFAULT_FILE_URL = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'; + + public function handle(MessageCreatedUpdate $update, ParsedCommand $command): void + { + switch ($command->name) { + case 'upload_photo_file': + $this->uploadPhotoFromFile($update, $command); + return; + case 'upload_photo_url': + $this->uploadPhotoFromUrl($update, $command); + return; + case 'upload_photo_bytes': + $this->uploadPhotoFromBytes($update); + return; + case 'upload_photo_base64': + $this->uploadPhotoFromBase64($update); + return; + case 'upload_audio': + $this->uploadMediaFromFile($update, $command, UploadType::Audio); + return; + case 'upload_video': + $this->uploadMediaFromFile($update, $command, UploadType::Video); + return; + case 'upload_file': + $this->uploadMediaFromFile($update, $command, UploadType::File); + return; + case 'upload_url': + $this->uploadMediaFromUrl($update, $command); + return; + } + } + + private function uploadPhotoFromFile(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $path = $command->arg(0, __DIR__ . '/../../storage/uploads/photo.png'); + if (!is_file($path)) { + $this->replyMarkdown($update, "File not found: `$path`. Положи PNG в storage/uploads/photo.png."); + return; + } + $tokens = $this->api->uploads->uploadPhotoFromFile($path); + $this->sendPhotoWithCaption($update, $tokens, 'photo from file'); + } + + private function uploadPhotoFromUrl(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $url = $command->arg(0, self::DEFAULT_PHOTO_URL); + $tokens = $this->api->uploads->uploadPhotoFromUrl($url); + $this->sendPhotoWithCaption($update, $tokens, "photo from $url"); + } + + private function uploadPhotoFromBytes(MessageCreatedUpdate $update): void + { + // Synthesize a 1x1 PNG via PNG header bytes (no GD dependency). + $png = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + strict: true, + ); + $tokens = $this->api->uploads->uploadPhotoFromBytes($png, 'pixel.png'); + $this->sendPhotoWithCaption($update, $tokens, 'photo from raw bytes'); + } + + private function uploadPhotoFromBase64(MessageCreatedUpdate $update): void + { + $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + $tokens = $this->api->uploads->uploadPhotoFromBase64($base64, 'pixel.png'); + $this->sendPhotoWithCaption($update, $tokens, 'photo from base64'); + } + + private function uploadMediaFromFile(MessageCreatedUpdate $update, ParsedCommand $command, UploadType $type): void + { + $default = __DIR__ . '/../../storage/uploads/' . match ($type) { + UploadType::Audio => 'audio.mp3', + UploadType::Video => 'video.mp4', + UploadType::File => 'doc.pdf', + UploadType::Photo => 'photo.png', + }; + $path = $command->arg(0, $default); + if (!is_file($path)) { + $this->replyMarkdown($update, "File not found: `$path`. Положи файл в storage/uploads/."); + return; + } + $info = $this->api->uploads->uploadMediaFromFile($type, $path); + $this->sendMediaWithCaption($update, $info, $type); + } + + private function uploadMediaFromUrl(MessageCreatedUpdate $update, ParsedCommand $command): void + { + $typeStr = $command->arg(0); + $url = $command->arg(1, self::DEFAULT_FILE_URL); + if ($typeStr === null) { + $this->replyMarkdown($update, 'Usage: `/upload_url <audio|video|file> [url]`'); + return; + } + $type = UploadType::tryFrom($typeStr); + if ($type === null || $type === UploadType::Photo) { + $this->replyMarkdown($update, 'Type must be audio, video or file. Use /upload_photo_url for photos.'); + return; + } + $info = $this->api->uploads->uploadMediaFromUrl($type, $url); + $this->sendMediaWithCaption($update, $info, $type); + } + + private function sendPhotoWithCaption(MessageCreatedUpdate $update, PhotoTokens $tokens, string $caption): void + { + if ($tokens->photos === []) { + $this->replyMarkdown($update, 'Upload returned no photo tokens (server may still be processing).'); + return; + } + $this->api->messages->send( + MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($caption) + ->setFormat(Format::Markdown) + ->addPhoto($tokens), + ); + } + + private function sendMediaWithCaption(MessageCreatedUpdate $update, UploadedInfo $info, UploadType $type): void + { + $caption = match ($type) { + UploadType::Audio => 'audio upload', + UploadType::Video => 'video upload', + UploadType::File => 'file upload', + UploadType::Photo => 'photo upload', + }; + $builder = MessageBuilder::new() + ->setChat($update->getChatId()) + ->setText($caption . ' (token=' . $info->token . ')'); + match ($type) { + UploadType::Audio => $builder->addAudio($info), + UploadType::Video => $builder->addVideo($info), + UploadType::File => $builder->addFile($info), + UploadType::Photo => $builder, + }; + $this->api->messages->send($builder); + } +} diff --git a/src/Util/CommandRouter.php b/src/Util/CommandRouter.php new file mode 100644 index 0000000..4efa765 --- /dev/null +++ b/src/Util/CommandRouter.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace App\Util; + +use App\Scenario\ScenarioInterface; +use Pkirillw\MaxBotApi\Scheme\Update\MessageCreatedUpdate; + +final class CommandRouter +{ + /** @var array<string, ScenarioInterface> */ + private array $scenarios = []; + + public function add(string $commandName, ScenarioInterface $scenario): void + { + $this->scenarios[$commandName] = $scenario; + } + + public function route(MessageCreatedUpdate $update, ParsedCommand $command): bool + { + $scenario = $this->scenarios[$command->name] ?? null; + if ($scenario === null) { + return false; + } + $scenario->handle($update, $command); + return true; + } + + /** + * @return list<string> + */ + public function commands(): array + { + return array_keys($this->scenarios); + } +} diff --git a/src/Util/ParsedCommand.php b/src/Util/ParsedCommand.php new file mode 100644 index 0000000..460fa43 --- /dev/null +++ b/src/Util/ParsedCommand.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace App\Util; + +final readonly class ParsedCommand +{ + /** + * @param list<string> $args + */ + public function __construct( + public string $name, + public array $args, + public string $raw, + public string $tail, + ) {} + + public function arg(int $index, ?string $default = null): ?string + { + return $this->args[$index] ?? $default; + } + + public function argInt(int $index, int $default = 0): int + { + $value = $this->args[$index] ?? ''; + return $value === '' ? $default : (int) $value; + } + + public static function parse(string $text): ?self + { + $text = trim($text); + if ($text === '' || !str_starts_with($text, '/')) { + return null; + } + $parts = preg_split('/\s+/', $text) ?: []; + $head = array_shift($parts) ?? ''; + if ($head === '') { + return null; + } + if (str_contains($head, ':')) { + [$name, $param] = explode(':', $head, 2) + [1 => '']; + if ($param !== '') { + array_unshift($parts, $param); + } + } else { + $name = $head; + } + $name = ltrim($name, '/'); + $tail = implode(' ', $parts); + return new self( + name: $name, + args: array_values($parts), + raw: $text, + tail: $tail, + ); + } +} diff --git a/storage/logs/.gitkeep b/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/uploads/.gitkeep b/storage/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Integration/WebhookSecretTest.php b/tests/Integration/WebhookSecretTest.php new file mode 100644 index 0000000..68850c8 --- /dev/null +++ b/tests/Integration/WebhookSecretTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace App\Tests\Integration; + +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; +use Pkirillw\MaxBotApi\Webhook\WebhookHandler; +use PHPUnit\Framework\TestCase; + +final class WebhookSecretTest extends TestCase +{ + private const SECRET = 'topsecret'; + + public function testRejectsMissingSecretWith401(): void + { + $handler = $this->makeHandler(); + $request = new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody()); + $response = $handler->handle($request); + self::assertSame(401, $response->getStatusCode()); + } + + public function testRejectsWrongSecretWith401(): void + { + $handler = $this->makeHandler(); + $request = (new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody())) + ->withHeader('X-Max-Bot-Api-Secret', 'wrong'); + $response = $handler->handle($request); + self::assertSame(401, $response->getStatusCode()); + } + + public function testAcceptsValidSecretAndInvokesUserlandHandler(): void + { + $handler = $this->makeHandler(); + $received = null; + $handler->setHandler(static function (object $update) use (&$received): void { + $received = $update; + }); + + $request = (new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody())) + ->withHeader('X-Max-Bot-Api-Secret', self::SECRET); + $response = $handler->handle($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertNotNull($received); + self::assertSame('message_created', $received->getUpdateType()->value); + } + + public function testReturns400OnEmptyBody(): void + { + $handler = $this->makeHandler(); + $request = (new ServerRequest('POST', '/webhook')) + ->withHeader('X-Max-Bot-Api-Secret', self::SECRET); + $response = $handler->handle($request); + self::assertSame(400, $response->getStatusCode()); + } + + public function testReturns405OnGet(): void + { + $handler = $this->makeHandler(); + $request = (new ServerRequest('GET', '/webhook')) + ->withHeader('X-Max-Bot-Api-Secret', self::SECRET); + $response = $handler->handle($request); + self::assertSame(405, $response->getStatusCode()); + } + + private function makeHandler(): WebhookHandler + { + return new WebhookHandler(new Psr17Factory(), secret: self::SECRET, keepRawUpdates: true); + } + + private function messageCreatedBody(): string + { + return json_encode([ + 'update_type' => 'message_created', + 'timestamp' => 1_700_000_000_000, + 'message' => [ + 'body' => ['mid' => 'mid-1', 'seq' => 1, 'text' => '/help'], + 'recipient' => ['chat_id' => 100, 'chat_type' => 'dialog'], + 'sender' => ['user_id' => 200, 'name' => 'Tester'], + ], + ], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/KeyboardFactoryTest.php b/tests/Unit/KeyboardFactoryTest.php new file mode 100644 index 0000000..089bc8f --- /dev/null +++ b/tests/Unit/KeyboardFactoryTest.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace App\Tests\Unit; + +use App\Keyboard\KeyboardFactory; +use Pkirillw\MaxBotApi\Scheme\Button\CallbackButton; +use Pkirillw\MaxBotApi\Scheme\Button\ClipboardButton; +use Pkirillw\MaxBotApi\Scheme\Button\LinkButton; +use Pkirillw\MaxBotApi\Scheme\Button\MessageButton; +use Pkirillw\MaxBotApi\Scheme\Button\OpenAppButton; +use Pkirillw\MaxBotApi\Scheme\Button\RequestContactButton; +use Pkirillw\MaxBotApi\Scheme\Button\RequestGeoLocationButton; +use Pkirillw\MaxBotApi\Scheme\Enum\Intent; +use PHPUnit\Framework\TestCase; + +final class KeyboardFactoryTest extends TestCase +{ + public function testFullKeyboardCoversAllButtonTypes(): void + { + $kb = (new KeyboardFactory('https://app.example.com/web'))->full(); + $rows = $kb->getRows(); + self::assertCount(7, $rows, 'full keyboard has 7 rows when WEB_APP_URL is set'); + + // Flatten buttons. + $buttons = []; + foreach ($rows as $row) { + foreach ($row->getButtons() as $button) { + $buttons[] = $button; + } + } + + $types = array_map(fn($b) => $b->getType()->value, $buttons); + self::assertContains('callback', $types); + self::assertContains('link', $types); + self::assertContains('message', $types); + self::assertContains('clipboard', $types); + self::assertContains('request_contact', $types); + self::assertContains('request_geo_location', $types); + self::assertContains('open_app', $types); + } + + public function testFullKeyboardSkipsOpenAppWhenUrlMissing(): void + { + $kb = (new KeyboardFactory(null))->full(); + $rows = $kb->getRows(); + self::assertCount(6, $rows, 'OpenApp row is skipped without WEB_APP_URL'); + + $buttons = []; + foreach ($rows as $row) { + foreach ($row->getButtons() as $button) { + $buttons[] = $button; + } + } + $types = array_map(fn($b) => $b->getType()->value, $buttons); + self::assertNotContains('open_app', $types); + } + + public function testCallbackRowCoversAllIntents(): void + { + $kb = (new KeyboardFactory())->full(); + $callbackRow = $kb->getRows()[0]; + $buttons = $callbackRow->getButtons(); + + self::assertCount(3, $buttons); + self::assertInstanceOf(CallbackButton::class, $buttons[0]); + self::assertSame(Intent::Positive->value, $buttons[0]->intent->value); + self::assertSame(Intent::Negative->value, $buttons[1]->intent->value); + self::assertSame(Intent::Default->value, $buttons[2]->intent->value); + } + + public function testJsonSerializationShape(): void + { + $kb = (new KeyboardFactory())->simpleCallback(); + $rows = $kb->getRows(); + + self::assertCount(1, $rows); + $buttons = $rows[0]->getButtons(); + self::assertCount(2, $buttons); + + $first = $buttons[0]; + self::assertInstanceOf(CallbackButton::class, $first); + self::assertSame('vote:yes', $first->payload); + + $json = $kb->build()->jsonSerialize(); + self::assertArrayHasKey('buttons', $json); + self::assertCount(1, $json['buttons']); + // Each row serializes as a list of {type, text, ...} objects. + self::assertSame('vote:yes', $json['buttons'][0][0]['payload']); + self::assertSame('vote:no', $json['buttons'][0][1]['payload']); + } +} diff --git a/tests/Unit/ParsedCommandTest.php b/tests/Unit/ParsedCommandTest.php new file mode 100644 index 0000000..d4a0995 --- /dev/null +++ b/tests/Unit/ParsedCommandTest.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace App\Tests\Unit; + +use App\Util\ParsedCommand; +use PHPUnit\Framework\TestCase; + +final class ParsedCommandTest extends TestCase +{ + public function testParseSimpleCommand(): void + { + $cmd = ParsedCommand::parse('/help'); + self::assertNotNull($cmd); + self::assertSame('help', $cmd->name); + self::assertSame([], $cmd->args); + self::assertSame('', $cmd->tail); + } + + public function testParseCommandWithArgs(): void + { + $cmd = ParsedCommand::parse('/chat 123'); + self::assertNotNull($cmd); + self::assertSame('chat', $cmd->name); + self::assertSame(['123'], $cmd->args); + self::assertSame('123', $cmd->tail); + self::assertSame(123, $cmd->argInt(0)); + } + + public function testParseColonSyntaxExtractsParam(): void + { + $cmd = ParsedCommand::parse('/bot_patch:NewName'); + self::assertNotNull($cmd); + self::assertSame('bot_patch', $cmd->name); + self::assertSame(['NewName'], $cmd->args); + } + + public function testParseReturnsNullForPlainText(): void + { + self::assertNull(ParsedCommand::parse('hello')); + self::assertNull(ParsedCommand::parse('')); + self::assertNull(ParsedCommand::parse(' ')); + } +}