Lines 88.05% 59 / 67
Methods 62.50% 5 / 8
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __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
 [App\Command\ChatTestHelpers] cause 50.00% 2 / 4 0.00% 0 / 1 4.12
 [App\Command\ChatTestHelpers] masked 100.00% 1 / 1 100.00% 1 / 1 2
26final 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}

From App\Command\ChatTestHelpers

12trait ChatTestHelpers
13{
14    /** The transport's exception, also when the message bus wrapped it. */
15    private function cause(\Throwable $exception): \Throwable
16    {
17        if ($exception instanceof WrappedExceptionsInterface) {
18            foreach ($exception->getWrappedExceptions(recursive: true) as $wrapped) {
19                return $wrapped;
20            }
21        }
22
23        return $exception;
24    }
25
26    private function masked(string $token): string
27    {
28        return \strlen($token) > 12 ? \substr($token, 0, 9).'…'.\substr($token, -4) : '(too short: '.\strlen($token).' characters)';
29    }
30}