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

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