feat: max-bot-test initial — full surface coverage
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
- 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 <noreply@anthropic.com>
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -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
|
||||
63
.gitea/workflows/deploy.yml
Normal file
63
.gitea/workflows/deploy.yml
Normal file
@@ -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"
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -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
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -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"]
|
||||
63
Makefile
Normal file
63
Makefile
Normal file
@@ -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
|
||||
146
README.md
Normal file
146
README.md
Normal file
@@ -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 <this repo> 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 <name>`.
|
||||
**Chats**: `/chats`, `/chat <id>`, `/members <id>`, `/admins <id>`,
|
||||
`/action <id> <typing_on|typing_off|mark_seen|sending_photo|sending_video|sending_audio>`,
|
||||
`/pin <id> <mid>`, `/edit_chat <id> <title>`, `/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 — как и сама библиотека.
|
||||
14
bin/console
Executable file
14
bin/console
Executable file
@@ -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();
|
||||
48
composer.json
Normal file
48
composer.json
Normal file
@@ -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
|
||||
}
|
||||
4084
composer.lock
generated
Normal file
4084
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
198
config/container.php
Normal file
198
config/container.php
Normal file
@@ -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;
|
||||
},
|
||||
];
|
||||
19
config/routes.php
Normal file
19
config/routes.php
Normal file
@@ -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');
|
||||
});
|
||||
};
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -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
|
||||
24
docker/nginx/default.conf
Normal file
24
docker/nginx/default.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
14
docker/php/php.ini
Normal file
14
docker/php/php.ini
Normal file
@@ -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
|
||||
27
phpunit.xml.dist
Normal file
27
phpunit.xml.dist
Normal file
@@ -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>
|
||||
10
public/index.php
Normal file
10
public/index.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use App\Bootstrap;
|
||||
|
||||
$app = Bootstrap::app();
|
||||
$app->run();
|
||||
63
src/Bootstrap.php
Normal file
63
src/Bootstrap.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
37
src/Command/AbstractCommand.php
Normal file
37
src/Command/AbstractCommand.php
Normal file
@@ -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;
|
||||
}
|
||||
29
src/Command/GetBotInfoCommand.php
Normal file
29
src/Command/GetBotInfoCommand.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
30
src/Command/ListSubscriptionsCommand.php
Normal file
30
src/Command/ListSubscriptionsCommand.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
96
src/Command/SelfTestCommand.php
Normal file
96
src/Command/SelfTestCommand.php
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Command/SubscribeWebhookCommand.php
Normal file
59
src/Command/SubscribeWebhookCommand.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
35
src/Command/UnsubscribeWebhookCommand.php
Normal file
35
src/Command/UnsubscribeWebhookCommand.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
70
src/Handler/AbstractUpdateHandler.php
Normal file
70
src/Handler/AbstractUpdateHandler.php
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/Handler/Update/BotAddedToChatHandler.php
Normal file
39
src/Handler/Update/BotAddedToChatHandler.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
30
src/Handler/Update/BotRemovedFromChatHandler.php
Normal file
30
src/Handler/Update/BotRemovedFromChatHandler.php
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/Handler/Update/BotStartedHandler.php
Normal file
51
src/Handler/Update/BotStartedHandler.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
30
src/Handler/Update/BotStopedFromChatHandler.php
Normal file
30
src/Handler/Update/BotStopedFromChatHandler.php
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Handler/Update/ChatTitleChangedHandler.php
Normal file
36
src/Handler/Update/ChatTitleChangedHandler.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/Handler/Update/DialogClearedHandler.php
Normal file
31
src/Handler/Update/DialogClearedHandler.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Handler/Update/DialogRemovedHandler.php
Normal file
27
src/Handler/Update/DialogRemovedHandler.php
Normal file
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
src/Handler/Update/MessageCallbackHandler.php
Normal file
49
src/Handler/Update/MessageCallbackHandler.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Handler/Update/MessageCreatedHandler.php
Normal file
59
src/Handler/Update/MessageCreatedHandler.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Handler/Update/MessageEditedHandler.php
Normal file
33
src/Handler/Update/MessageEditedHandler.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/Handler/Update/MessageRemovedHandler.php
Normal file
33
src/Handler/Update/MessageRemovedHandler.php
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Handler/Update/UserAddedToChatHandler.php
Normal file
37
src/Handler/Update/UserAddedToChatHandler.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/Handler/Update/UserRemovedFromChatHandler.php
Normal file
28
src/Handler/Update/UserRemovedFromChatHandler.php
Normal file
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
src/Handler/UpdateHandlerInterface.php
Normal file
17
src/Handler/UpdateHandlerInterface.php
Normal file
@@ -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;
|
||||
}
|
||||
39
src/Handler/UpdateRouter.php
Normal file
39
src/Handler/UpdateRouter.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
52
src/Keyboard/KeyboardFactory.php
Normal file
52
src/Keyboard/KeyboardFactory.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
33
src/Logger/LoggerFactory.php
Normal file
33
src/Logger/LoggerFactory.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
43
src/Scenario/AbstractScenario.php
Normal file
43
src/Scenario/AbstractScenario.php
Normal file
@@ -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]);
|
||||
}
|
||||
}
|
||||
63
src/Scenario/AttachmentScenario.php
Normal file
63
src/Scenario/AttachmentScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/Scenario/BotInfoScenario.php
Normal file
61
src/Scenario/BotInfoScenario.php
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
168
src/Scenario/ChatAdminScenario.php
Normal file
168
src/Scenario/ChatAdminScenario.php
Normal file
@@ -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'));
|
||||
}
|
||||
}
|
||||
89
src/Scenario/ChatsScenario.php
Normal file
89
src/Scenario/ChatsScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Scenario/DebugScenario.php
Normal file
55
src/Scenario/DebugScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/Scenario/HelpScenario.php
Normal file
101
src/Scenario/HelpScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/Scenario/KeyboardScenario.php
Normal file
24
src/Scenario/KeyboardScenario.php
Normal file
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
192
src/Scenario/MessagesScenario.php
Normal file
192
src/Scenario/MessagesScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/Scenario/PhoneCheckScenario.php
Normal file
56
src/Scenario/PhoneCheckScenario.php
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/Scenario/ScenarioInterface.php
Normal file
13
src/Scenario/ScenarioInterface.php
Normal file
@@ -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;
|
||||
}
|
||||
154
src/Scenario/UploadsScenario.php
Normal file
154
src/Scenario/UploadsScenario.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
src/Util/CommandRouter.php
Normal file
37
src/Util/CommandRouter.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
58
src/Util/ParsedCommand.php
Normal file
58
src/Util/ParsedCommand.php
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
0
storage/logs/.gitkeep
Normal file
0
storage/logs/.gitkeep
Normal file
0
storage/uploads/.gitkeep
Normal file
0
storage/uploads/.gitkeep
Normal file
85
tests/Integration/WebhookSecretTest.php
Normal file
85
tests/Integration/WebhookSecretTest.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
93
tests/Unit/KeyboardFactoryTest.php
Normal file
93
tests/Unit/KeyboardFactoryTest.php
Normal file
@@ -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']);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/ParsedCommandTest.php
Normal file
45
tests/Unit/ParsedCommandTest.php
Normal file
@@ -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(' '));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user