Lines
90.32%
56 / 62
Functions and Methods
66.66%
4 / 6
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| SlackTestCommand | 90.32% | 56 / 62 | 66.66% | 4 / 6 | 26.61 | 0.00% | 0 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| __invoke | 100.00% | 23 / 23 | 100.00% | 1 / 1 | 7 | |||||
| authTest | 83.33% | 15 / 18 | 0.00% | 0 / 1 | 7.23 | |||||
| post | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 3 | |||||
| hint | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 4 | |||||
| slackError | 50.00% | 3 / 6 | 0.00% | 0 / 1 | 6.00 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace App\Command; | |
| 6 | ||
| 7 | use App\Notifier\ChatSender; | |
| 8 | use App\Notifier\ChatText; | |
| 9 | use Symfony\Component\Console\Attribute\AsCommand; | |
| 10 | use Symfony\Component\Console\Command\Command; | |
| 11 | use Symfony\Component\Console\Style\SymfonyStyle; | |
| 12 | use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
| 13 | use Symfony\Component\Messenger\Exception\ExceptionInterface as MessengerException; | |
| 14 | use Symfony\Component\Notifier\Exception\TransportException; | |
| 15 | use Symfony\Component\Notifier\Exception\TransportExceptionInterface; | |
| 16 | use Symfony\Component\Notifier\Transport\Dsn; | |
| 17 | use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpException; | |
| 18 | use Symfony\Contracts\HttpClient\HttpClientInterface; | |
| 19 | ||
| 20 | /** | |
| 21 | * Checks the Slack setup: the bot token (Slack's auth.test names the bot and the workspace) and a | |
| 22 | * test message to each channel, SLACK_CHANNEL and SLACK_ERRORS_CHANNEL, with what | |
| 23 | * to do for Slack's usual errors; -v shows Slack's whole answer. | |
| 24 | */ | |
| 25 | #[AsCommand(name: 'app:slack:test', description: 'Check the Slack token and post a test message to the Slack channels')] | |
| 26 | final readonly class SlackTestCommand | |
| 27 | { | |
| 28 | use ChatTestHelpers; | |
| 29 | ||
| 30 | /** What to do for Slack's error codes; %bot% and %channel% are replaced. */ | |
| 31 | private const array HINTS = [ | |
| 32 | 'not_in_channel' => 'The bot is not in #%channel%: in Slack, type /invite @%bot% in #%channel%. (Or give the app the scope chat:write.public and reinstall it, for public channels.)', | |
| 33 | 'channel_not_found' => 'The bot sees no channel #%channel%: check the name (without #) or use the channel ID (C…, in the channel details); a private channel needs /invite @%bot% first.', | |
| 34 | 'is_archived' => '#%channel% is archived: unarchive it or use another channel.', | |
| 35 | 'invalid_auth' => 'Slack does not know the token: copy the Bot User OAuth Token (xoxb-…) from OAuth & Permissions into SLACK_DSN.', | |
| 36 | 'not_authed' => 'SLACK_DSN has no token: slack://xoxb-…@default.', | |
| 37 | 'token_revoked' => 'The token was revoked: reinstall the app and copy the new Bot User OAuth Token into SLACK_DSN.', | |
| 38 | 'account_inactive' => 'The token was revoked: reinstall the app and copy the new Bot User OAuth Token into SLACK_DSN.', | |
| 39 | 'missing_scope' => 'The app lacks the scope chat:write: add it under OAuth & Permissions → Bot Token Scopes and reinstall the app.', | |
| 40 | ]; | |
| 41 | ||
| 42 | public function __construct( | |
| 43 | private ChatSender $chatSender, | |
| 44 | private HttpClientInterface $httpClient, | |
| 45 | #[Autowire(env: 'SLACK_CHANNEL')] | |
| 46 | private string $channel, | |
| 47 | #[Autowire(env: 'SLACK_ERRORS_CHANNEL')] | |
| 48 | private string $errorsChannel, | |
| 49 | #[Autowire(param: 'kernel.environment')] | |
| 50 | private string $environment, | |
| 51 | #[Autowire(env: 'SLACK_DSN')] | |
| 52 | private string $dsn, | |
| 53 | ) { | |
| 54 | } | |
| 55 | ||
| 56 | public function __invoke(SymfonyStyle $io): int | |
| 57 | { | |
| 58 | if (\str_starts_with($this->dsn, 'null://')) { | |
| 59 | $io->warning('SLACK_DSN is null://null: nothing is posted. Set it in .env.local.'); | |
| 60 | ||
| 61 | return Command::FAILURE; | |
| 62 | } | |
| 63 | ||
| 64 | $dsn = new Dsn($this->dsn); | |
| 65 | $token = (string) $dsn->getUser(); | |
| 66 | $host = 'default' === $dsn->getHost() ? 'slack.com' : $dsn->getHost(); | |
| 67 | ||
| 68 | $io->definitionList( | |
| 69 | ['Token' => $this->masked($token)], | |
| 70 | ['Host' => $host], | |
| 71 | ['Activity channel' => '#'.$this->channel], | |
| 72 | ['Errors channel' => '#'.$this->errorsChannel], | |
| 73 | ); | |
| 74 | ||
| 75 | $bot = $this->authTest($io, $host, $token); | |
| 76 | if (null === $bot) { | |
| 77 | return Command::FAILURE; | |
| 78 | } | |
| 79 | ||
| 80 | $text = new ChatText(\sprintf('✅ AgentTools (%s) can post here.', $this->environment)); | |
| 81 | $posted = true; | |
| 82 | foreach ([$this->channel, $this->errorsChannel] as $channel) { | |
| 83 | $posted = $this->post($io, $text, $channel, $bot) && $posted; | |
| 84 | } | |
| 85 | ||
| 86 | if (!$posted) { | |
| 87 | return Command::FAILURE; | |
| 88 | } | |
| 89 | ||
| 90 | $io->success('Posted to both channels.'); | |
| 91 | ||
| 92 | return Command::SUCCESS; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * The bot's user name from Slack's auth.test, null if the token does not work. | |
| 97 | */ | |
| 98 | private function authTest(SymfonyStyle $io, string $host, string $token): ?string | |
| 99 | { | |
| 100 | try { | |
| 101 | $result = $this->httpClient->request('POST', 'https://'.$host.'/api/auth.test', ['auth_bearer' => $token])->toArray(false); | |
| 102 | } catch (HttpException $httpException) { | |
| 103 | $io->error('Slack cannot be reached: '.$httpException->getMessage()); | |
| 104 | ||
| 105 | return null; | |
| 106 | } | |
| 107 | ||
| 108 | $io->writeln('auth.test: '.\json_encode($result, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), SymfonyStyle::VERBOSITY_VERBOSE); | |
| 109 | ||
| 110 | if (true !== ($result['ok'] ?? false)) { | |
| 111 | $error = \is_string($result['error'] ?? null) ? $result['error'] : 'unknown'; | |
| 112 | $io->error(\sprintf('Token check (auth.test): %s', $error)); | |
| 113 | $this->hint($io, $error, '', ''); | |
| 114 | ||
| 115 | return null; | |
| 116 | } | |
| 117 | ||
| 118 | $bot = \is_string($result['user'] ?? null) ? $result['user'] : ''; | |
| 119 | $io->writeln(\sprintf( | |
| 120 | '<info>✔</info> Token works: bot <comment>@%s</comment> in workspace <comment>%s</comment> (%s)', | |
| 121 | $bot, | |
| 122 | \is_string($result['team'] ?? null) ? $result['team'] : '?', | |
| 123 | \is_string($result['url'] ?? null) ? $result['url'] : '?', | |
| 124 | )); | |
| 125 | ||
| 126 | return $bot; | |
| 127 | } | |
| 128 | ||
| 129 | private function post(SymfonyStyle $io, ChatText $text, string $channel, string $bot): bool | |
| 130 | { | |
| 131 | try { | |
| 132 | $this->chatSender->sendTo(ChatSender::SLACK, $channel, $text); | |
| 133 | } catch (TransportExceptionInterface|MessengerException $exception) { | |
| 134 | $cause = $this->cause($exception); | |
| 135 | $error = $this->slackError($cause); | |
| 136 | $io->writeln(\sprintf('<error>✘</error> #%s: %s', $channel, $error ?? $cause->getMessage())); | |
| 137 | if ($cause instanceof TransportException) { | |
| 138 | $io->writeln(' Slack answered: '.$cause->getResponse()->getContent(false), SymfonyStyle::VERBOSITY_VERBOSE); | |
| 139 | } | |
| 140 | ||
| 141 | $this->hint($io, $error, $channel, $bot); | |
| 142 | ||
| 143 | return false; | |
| 144 | } | |
| 145 | ||
| 146 | $io->writeln(\sprintf('<info>✔</info> #%s: posted', $channel)); | |
| 147 | ||
| 148 | return true; | |
| 149 | } | |
| 150 | ||
| 151 | private function hint(SymfonyStyle $io, ?string $error, string $channel, string $bot): void | |
| 152 | { | |
| 153 | $hint = null === $error ? null : self::HINTS[$error] ?? null; | |
| 154 | if (null !== $hint) { | |
| 155 | $io->writeln(' → '.\strtr($hint, ['%channel%' => $channel, '%bot%' => '' === $bot ? 'YourBot' : $bot])); | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | /** Slack's error code, e.g. not_in_channel. */ | |
| 160 | private function slackError(\Throwable $exception): ?string | |
| 161 | { | |
| 162 | if (!$exception instanceof TransportException) { | |
| 163 | return null; | |
| 164 | } | |
| 165 | ||
| 166 | try { | |
| 167 | $error = $exception->getResponse()->toArray(false)['error'] ?? null; | |
| 168 | } catch (HttpException) { | |
| 169 | return null; | |
| 170 | } | |
| 171 | ||
| 172 | return \is_string($error) ? $error : null; | |
| 173 | } | |
| 174 | } |