feat: max-bot-test initial — full surface coverage
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:
pkirillw
2026-06-18 00:01:06 +03:00
commit 9385fbdd6e
60 changed files with 7191 additions and 0 deletions

24
.env.example Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

198
config/container.php Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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];
}
}
}

View 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;
}
}

View 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;
}
}

View 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());
}
}
}

View 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);
}
}

View 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));
}
}
}

View 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);
}
}

View 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));
}
}
}

View 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),
);
}
}

View 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);
}
}
}

View 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,
]);
}
}

View 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),
);
}
}
}

View 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);
}
}
}

View 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),
);
}
}

View 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));
}
}
}

View 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),
);
}
}

View 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,
]);
}
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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]);
}
}

View 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),
);
}
}

View 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));
}
}

View 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'));
}
}

View 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),
);
}
}

View 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),
);
}
}
}

View 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),
);
}
}

View 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()),
);
}
}

View 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),
);
}
}

View 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),
);
}
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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
View File

0
storage/uploads/.gitkeep Normal file
View File

View 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);
}
}

View 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']);
}
}

View 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(' '));
}
}