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

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