feat: max-bot-test initial — full surface coverage
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
- Slim 4 PSR-15 webhook, PHP-DI 7, Symfony Console 8 - 13 update handlers, 30+ command scenarios, all 7 button types - Composer pulls pkirillw/max-bot-api-php from GitHub vcs - Multi-stage Dockerfile (php-fpm 8.4-alpine) + nginx - docker-compose.yml targets server (npm_default external) - docker-compose.override.yml for local dev (gitignored) - .gitea/workflows/deploy.yml mirrors ghost-blog-bot pattern Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
37
src/Command/AbstractCommand.php
Normal file
37
src/Command/AbstractCommand.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Pkirillw\MaxBotApi\Api;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
return $this->doExecute($io, $input, $output);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||
$io->error($e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int;
|
||||
}
|
||||
29
src/Command/GetBotInfoCommand.php
Normal file
29
src/Command/GetBotInfoCommand.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'bot:info', description: 'GET /me — print BotInfo.')]
|
||||
final class GetBotInfoCommand extends AbstractCommand
|
||||
{
|
||||
|
||||
protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$info = $this->api->bots->getBot();
|
||||
$io->text('name: ' . $info->name);
|
||||
$io->text('username: @' . ($info->username ?? '—'));
|
||||
$io->text('user_id: ' . $info->userId);
|
||||
$io->text('description: ' . ($info->description ?? '—'));
|
||||
$io->text('commands:');
|
||||
foreach ($info->commands as $c) {
|
||||
$io->text(" /{$c->name} — {$c->description}");
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
30
src/Command/ListSubscriptionsCommand.php
Normal file
30
src/Command/ListSubscriptionsCommand.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'subs:list', description: 'GET /subscriptions — list registered webhooks.')]
|
||||
final class ListSubscriptionsCommand extends AbstractCommand
|
||||
{
|
||||
|
||||
protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$subs = $this->api->subscriptions->getSubscriptions();
|
||||
if ($subs->subscriptions === []) {
|
||||
$io->text('(no subscriptions)');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($subs->subscriptions as $s) {
|
||||
$rows[] = [$s->url, $s->version ?? '—', implode(',', $s->updateTypes) ?: '(all)'];
|
||||
}
|
||||
$io->table(['url', 'version', 'update_types'], $rows);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
96
src/Command/SelfTestCommand.php
Normal file
96
src/Command/SelfTestCommand.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Bootstrap;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Pkirillw\MaxBotApi\Builder\Keyboard;
|
||||
use Pkirillw\MaxBotApi\Builder\Message as MessageBuilder;
|
||||
use Pkirillw\MaxBotApi\Scheme\Enum\Intent;
|
||||
use Pkirillw\MaxBotApi\Scheme\PinMessageBody;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'selftest', description: 'Smoke-test every endpoint group end-to-end.')]
|
||||
final class SelfTestCommand extends AbstractCommand
|
||||
{
|
||||
|
||||
/** @var list<array{0:string,1:string,2:bool}> */
|
||||
private array $results = [];
|
||||
|
||||
protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$config = Bootstrap::envConfig();
|
||||
$chatId = (int) $config['selftest.chat_id'];
|
||||
|
||||
if ($chatId <= 0) {
|
||||
$io->warning('SELFTEST_CHAT_ID is not set — will skip chat-scoped checks (send/edit/delete/pin/leave).');
|
||||
}
|
||||
|
||||
$this->probe($io, 'bots.getBot', fn () => $this->api->bots->getBot()->name);
|
||||
$this->probe($io, 'chats.getChats', fn () => count($this->api->chats->getChats(count: 1)->chats) . ' chat(s)');
|
||||
$this->probe($io, 'subscriptions.getSubscriptions', fn () => count($this->api->subscriptions->getSubscriptions()->subscriptions) . ' sub(s)');
|
||||
$this->probe($io, 'uploads.uploadPhotoFromBytes', function () {
|
||||
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', true);
|
||||
$tokens = $this->api->uploads->uploadPhotoFromBytes($png, 'pixel.png');
|
||||
return $tokens->photos === [] ? 'no tokens returned' : count($tokens->photos) . ' token(s)';
|
||||
});
|
||||
|
||||
if ($chatId > 0) {
|
||||
$this->probe($io, 'messages.send (markdown + keyboard)', function () use ($chatId) {
|
||||
$kb = (new Keyboard())->addRow()->addCallback('selftest', 'selftest:ok', Intent::Positive);
|
||||
$msg = $this->api->messages->sendWithResult(
|
||||
MessageBuilder::new()
|
||||
->setChat($chatId)
|
||||
->setText('*selftest* message')
|
||||
->addKeyboard($kb),
|
||||
);
|
||||
$mid = $msg->body->mid;
|
||||
if ($mid === '') {
|
||||
throw new \RuntimeException('no mid returned');
|
||||
}
|
||||
$this->probe($io, 'messages.editMessage', function () use ($mid, $chatId) {
|
||||
$this->api->messages->editMessage($mid, MessageBuilder::new()->setChat($chatId)->setText('*selftest* edited'));
|
||||
});
|
||||
$this->probe($io, 'chats.sendAction (typing)', fn () => $this->api->chats->sendAction($chatId, \Pkirillw\MaxBotApi\Scheme\Enum\SenderAction::TypingOn));
|
||||
$this->probe($io, 'chats.pinMessage', fn () => $this->api->chats->pinMessage($chatId, new PinMessageBody(messageId: $mid)));
|
||||
$this->probe($io, 'messages.getMessage', fn () => $this->api->messages->getMessage($mid)->body?->text ?? '?');
|
||||
$this->probe($io, 'messages.getMessages', fn () => count($this->api->messages->getMessages($chatId, count: 1)->messages) . ' msg(s)');
|
||||
$this->probe($io, 'messages.deleteMessage', fn () => $this->api->messages->deleteMessage($mid)->success ? 'ok' : 'false');
|
||||
$this->probe($io, 'chats.getChat', fn () => $this->api->chats->getChat($chatId)->title ?? '(no title)');
|
||||
$this->probe($io, 'chats.getChatMembership', fn () => $this->api->chats->getChatMembership($chatId)->userId);
|
||||
$this->probe($io, 'chats.getChatMembers', fn () => count($this->api->chats->getChatMembers($chatId, count: 1)->members) . ' member(s)');
|
||||
});
|
||||
}
|
||||
|
||||
$this->probe($io, 'debugs.sendText', function () {
|
||||
if ($this->api->debugs->getChatId() === null) {
|
||||
return 'skipped (no DEBUG_CHAT_ID)';
|
||||
}
|
||||
$this->api->debugs->sendText('selftest: ' . date('c'));
|
||||
return 'sent';
|
||||
});
|
||||
|
||||
$table = new Table($output);
|
||||
$table->setHeaders(['probe', 'result', 'ok']);
|
||||
$table->setRows($this->results);
|
||||
$table->render();
|
||||
|
||||
$failures = array_filter($this->results, fn($r) => !$r[2]);
|
||||
return $failures === [] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function probe(SymfonyStyle $io, string $name, callable $callback): void
|
||||
{
|
||||
try {
|
||||
$result = $callback();
|
||||
$this->results[] = [$name, (string) $result, true];
|
||||
} catch (\Throwable $e) {
|
||||
$this->results[] = [$name, mb_substr($e->getMessage(), 0, 120), false];
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Command/SubscribeWebhookCommand.php
Normal file
59
src/Command/SubscribeWebhookCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Bootstrap;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'webhook:subscribe', description: 'POST /subscriptions — register this bot webhook with MAX.')]
|
||||
final class SubscribeWebhookCommand extends AbstractCommand
|
||||
{
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('url', 'u', InputOption::VALUE_OPTIONAL, 'Override WEBHOOK_URL from env');
|
||||
$this->addOption('secret', 's', InputOption::VALUE_OPTIONAL, 'Override MAX_BOT_API_SECRET from env');
|
||||
$this->addOption('types', 't', InputOption::VALUE_OPTIONAL, 'Comma-separated update types (default: empty = all)');
|
||||
}
|
||||
|
||||
protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$config = Bootstrap::envConfig();
|
||||
$url = (string) ($input->getOption('url') ?: $config['webhook.url']);
|
||||
$secret = (string) ($input->getOption('secret') ?: $config['bot.secret']);
|
||||
$typesArg = $input->getOption('types');
|
||||
$types = $typesArg === null ? [] : array_values(array_filter(explode(',', (string) $typesArg)));
|
||||
|
||||
if ($url === '' || str_contains($url, 'your-tunnel-host')) {
|
||||
$io->error('WEBHOOK_URL is empty or still default. Run `make logs-tunnel` to read the cloudflared URL and put it in .env.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($secret === '') {
|
||||
$io->error('MAX_BOT_API_SECRET is empty. Generate one (e.g. `openssl rand -hex 32`) and put in .env.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$io->text("Subscribing $url with " . ($types === [] ? 'all update types' : 'types: ' . implode(',', $types)));
|
||||
$result = $this->api->subscriptions->subscribe($url, $types, $secret);
|
||||
if (!$result->success) {
|
||||
$io->error('Subscribe failed: ' . ($result->message ?? 'unknown'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$io->success('Subscribed. Verifying via getSubscriptions()…');
|
||||
|
||||
$subs = $this->api->subscriptions->getSubscriptions();
|
||||
$rows = [];
|
||||
foreach ($subs->subscriptions as $s) {
|
||||
$rows[] = [$s->url, $s->version ?? '—', implode(',', $s->updateTypes) ?: '(all)'];
|
||||
}
|
||||
$io->table(['url', 'version', 'update_types'], $rows);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
src/Command/UnsubscribeWebhookCommand.php
Normal file
35
src/Command/UnsubscribeWebhookCommand.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Bootstrap;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'webhook:unsubscribe', description: 'DELETE /subscriptions — remove webhook registration.')]
|
||||
final class UnsubscribeWebhookCommand extends AbstractCommand
|
||||
{
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('url', InputArgument::OPTIONAL, 'Webhook URL to remove (defaults to WEBHOOK_URL)');
|
||||
}
|
||||
|
||||
protected function doExecute(SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$config = Bootstrap::envConfig();
|
||||
$url = (string) ($input->getArgument('url') ?: $config['webhook.url']);
|
||||
if ($url === '') {
|
||||
$io->error('URL argument required (or set WEBHOOK_URL).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$result = $this->api->subscriptions->unsubscribe($url);
|
||||
$io->success(sprintf('Unsubscribe %s: success=%s', $url, $result->success ? 'true' : 'false'));
|
||||
return $result->success ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user