Lines 88.23% 75 / 85
Functions and Methods 61.53% 8 / 13
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
TelegramTestCommand 88.23% 75 / 85 61.53% 8 / 13 44.87 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 __invoke 100.00% 22 / 22 100.00% 1 / 1 8
 showSetup 100.00% 6 / 6 100.00% 1 / 1 3
 getMe 90.90% 10 / 11 0.00% 0 / 1 4.01
 listChats 66.66% 4 / 6 0.00% 0 / 1 2.15
 chats 100.00% 7 / 7 100.00% 1 / 1 4
 chat 75.00% 3 / 4 0.00% 0 / 1 4.25
 arrays 100.00% 1 / 1 100.00% 1 / 1 2
 string 100.00% 1 / 1 100.00% 1 / 1 2
 call 50.00% 3 / 6 0.00% 0 / 1 2.50
 post 100.00% 11 / 11 100.00% 1 / 1 3
 hint 100.00% 3 / 3 100.00% 1 / 1 3
 telegramError 50.00% 3 / 6 0.00% 0 / 1 6.00
1<?php
2
3declare(strict_types=1);
4
5namespace App\Command;
6
7use App\Notifier\ChatSender;
8use App\Notifier\ChatText;
9use Symfony\Component\Console\Attribute\AsCommand;
10use Symfony\Component\Console\Attribute\Option;
11use Symfony\Component\Console\Command\Command;
12use Symfony\Component\Console\Style\SymfonyStyle;
13use Symfony\Component\DependencyInjection\Attribute\Autowire;
14use Symfony\Component\Messenger\Exception\ExceptionInterface as MessengerException;
15use Symfony\Component\Notifier\Exception\TransportException;
16use Symfony\Component\Notifier\Exception\TransportExceptionInterface;
17use Symfony\Component\Notifier\Transport\Dsn;
18use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpException;
19use Symfony\Contracts\HttpClient\HttpClientInterface;
20
21/**
22 * Checks the Telegram setup: the bot token (Telegram's getMe names the bot), the chats the bot
23 * has seen messages from (getUpdates, to find the chat IDs) and a test message to each chat,
24 * TELEGRAM_CHAT_ID and TELEGRAM_ERRORS_CHAT_ID, with what to do for Telegram's usual errors;
25 * -v shows Telegram's whole answer.
26 */
27#[AsCommand(name: 'app:telegram:test', description: 'Check the Telegram token and post a test message to the Telegram chats')]
28final readonly class TelegramTestCommand
29{
30    use ChatTestHelpers;
31
32    /** What to do for Telegram's errors, by a part of its description; %bot% and %chat% are replaced. */
33    private const array HINTS = [
34        'Unauthorized' => 'Telegram does not know the token: copy it from @BotFather (/mybots → API Token) into TELEGRAM_DSN, telegram://BOT_TOKEN@default.',
35        'chat not found' => "The bot sees no chat %chat%: add @%bot% to the group or channel, send a message there and run this command again to see its ID (a group's starts with -, a channel's or supergroup's with -100).",
36        'bot was kicked' => '@%bot% was removed from %chat%: add it again.',
37        'bot is not a member' => '@%bot% is not in %chat%: add it, in a channel as an administrator.',
38        'not enough rights' => '@%bot% may not post in %chat%: in a channel make it an administrator with "Post messages", in a group allow members to send messages.',
39        'need administrator rights' => '@%bot% may not post in %chat%: make it an administrator with "Post messages".',
40        'upgraded to a supergroup' => "%chat% became a supergroup with a new ID: use the new ID (Telegram's answer, -v, has it as migrate_to_chat_id).",
41    ];
42
43    public function __construct(
44        private ChatSender $chatSender,
45        private HttpClientInterface $httpClient,
46        #[Autowire(env: 'TELEGRAM_CHAT_ID')]
47        private string $chat,
48        #[Autowire(env: 'TELEGRAM_ERRORS_CHAT_ID')]
49        private string $errorsChat,
50        #[Autowire(param: 'kernel.environment')]
51        private string $environment,
52        #[Autowire(env: 'TELEGRAM_DSN')]
53        private string $dsn,
54    ) {
55    }
56
57    public function __invoke(
58        SymfonyStyle $io,
59        #[Option(description: 'List the chats the bot has seen messages from')]
60        bool $chats = false,
61    ): int {
62        if (\str_starts_with($this->dsn, 'null://')) {
63            $io->warning('TELEGRAM_DSN is null://null: nothing is posted. Set it in .env.local.');
64
65            return Command::FAILURE;
66        }
67
68        $dsn = new Dsn($this->dsn);
69        $token = $dsn->getUser().':'.$dsn->getPassword();
70        $host = 'default' === $dsn->getHost() ? 'api.telegram.org' : $dsn->getHost();
71
72        $this->showSetup($io, $token, $host);
73
74        $bot = $this->getMe($io, $host, $token);
75        if (null === $bot) {
76            return Command::FAILURE;
77        }
78
79        $setChats = \array_filter([$this->chat, $this->errorsChat]);
80        $complete = 2 === \count($setChats);
81        if ($chats || !$complete) {
82            $this->listChats($io, $host, $token, $bot);
83        }
84
85        $text = new ChatText(\sprintf('✅ AgentTools (%s) can post here.', $this->environment));
86        $posted = \array_filter(\array_map(fn (string $chat): bool => $this->post($io, $text, $chat, $bot), $setChats));
87
88        if (!$complete) {
89            $io->warning('Set TELEGRAM_CHAT_ID and TELEGRAM_ERRORS_CHAT_ID in .env.local; a chat that is not set gets nothing.');
90        }
91
92        if (\count($posted) < 2) {
93            return Command::FAILURE;
94        }
95
96        $io->success('Posted to both chats.');
97
98        return Command::SUCCESS;
99    }
100
101    private function showSetup(SymfonyStyle $io, string $token, string $host): void
102    {
103        $io->definitionList(
104            ['Token' => $this->masked($token)],
105            ['Host' => $host],
106            ['Activity chat' => $this->chat ?: '(not set)'],
107            ['Errors chat' => $this->errorsChat ?: '(not set)'],
108        );
109    }
110
111    /**
112     * The bot's user name from Telegram's getMe, null if the token does not work.
113     */
114    private function getMe(SymfonyStyle $io, string $host, string $token): ?string
115    {
116        $result = $this->call($io, $host, $token, 'getMe');
117        if (null === $result) {
118            return null;
119        }
120
121        if (true !== ($result['ok'] ?? false)) {
122            $description = $this->string($result['description'] ?? null);
123            $io->error(\sprintf('Token check (getMe): %s', $description));
124            $this->hint($io, $description, '', '');
125
126            return null;
127        }
128
129        $bot = $this->string(\is_array($result['result'] ?? null) ? $result['result']['username'] ?? null : null);
130        $io->writeln(\sprintf('<info>✔</info> Token works: bot <comment>@%s</comment>', $bot));
131
132        return $bot;
133    }
134
135    /**
136     * The chats in the bot's recent updates (Telegram keeps them for 24 hours).
137     */
138    private function listChats(SymfonyStyle $io, string $host, string $token, string $bot): void
139    {
140        $chats = $this->chats($this->call($io, $host, $token, 'getUpdates') ?? []);
141
142        if ([] === $chats) {
143            $io->writeln(\sprintf('The bot has seen no chats: add @%s to the group or channel, send a message there (in a group, /start@%s) and run this command again.', $bot, $bot));
144
145            return;
146        }
147
148        $io->writeln('Chats the bot has seen:');
149        $io->table(['Chat ID', 'Type', 'Name'], \array_values($chats));
150    }
151
152    /**
153     * The chats of getUpdates' messages, channel posts and membership changes.
154     *
155     * @param array<mixed> $updates
156     *
157     * @return array<string, array{string, string, string}> ID, type and name by ID
158     */
159    private function chats(array $updates): array
160    {
161        $chats = [];
162        foreach ($this->arrays($updates['result'] ?? null) as $update) {
163            foreach ($this->arrays($update) as $part) {
164                $chat = $this->chat($part['chat'] ?? null);
165                if (null !== $chat) {
166                    $chats[$chat[0]] = $chat;
167                }
168            }
169        }
170
171        return $chats;
172    }
173
174    /**
175     * @return array{string, string, string}|null
176     */
177    private function chat(mixed $chat): ?array
178    {
179        $id = \is_array($chat) ? $chat['id'] ?? null : null;
180        if (!\is_array($chat) || !\is_int($id)) {
181            return null;
182        }
183
184        return [(string) $id, $this->string($chat['type'] ?? null), $this->string($chat['title'] ?? $chat['username'] ?? $chat['first_name'] ?? null)];
185    }
186
187    /**
188     * @return array<array<mixed>>
189     */
190    private function arrays(mixed $value): array
191    {
192        return \is_array($value) ? \array_filter($value, \is_array(...)) : [];
193    }
194
195    private function string(mixed $value): string
196    {
197        return \is_string($value) ? $value : '?';
198    }
199
200    /**
201     * @return array<mixed>|null Telegram's answer, null if it cannot be reached
202     */
203    private function call(SymfonyStyle $io, string $host, string $token, string $method): ?array
204    {
205        try {
206            $result = $this->httpClient->request('GET', \sprintf('https://%s/bot%s/%s', $host, $token, $method))->toArray(false);
207        } catch (HttpException $httpException) {
208            $io->error('Telegram cannot be reached: '.\str_replace($token, $this->masked($token), $httpException->getMessage()));
209
210            return null;
211        }
212
213        $io->writeln($method.': '.\json_encode($result, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), SymfonyStyle::VERBOSITY_VERBOSE);
214
215        return $result;
216    }
217
218    private function post(SymfonyStyle $io, ChatText $text, string $chat, string $bot): bool
219    {
220        try {
221            $this->chatSender->sendTo(ChatSender::TELEGRAM, $chat, $text);
222        } catch (TransportExceptionInterface|MessengerException $exception) {
223            $cause = $this->cause($exception);
224            $description = $this->telegramError($cause);
225            $io->writeln(\sprintf('<error>✘</error> %s: %s', $chat, $description ?? $cause->getMessage()));
226            if ($cause instanceof TransportException) {
227                $io->writeln('  Telegram answered: '.$cause->getResponse()->getContent(false), SymfonyStyle::VERBOSITY_VERBOSE);
228            }
229
230            $this->hint($io, $description, $chat, $bot);
231
232            return false;
233        }
234
235        $io->writeln(\sprintf('<info>✔</info> %s: posted', $chat));
236
237        return true;
238    }
239
240    private function hint(SymfonyStyle $io, ?string $description, string $chat, string $bot): void
241    {
242        $hints = \array_filter(self::HINTS, static fn (string $part): bool => \str_contains((string) $description, $part), \ARRAY_FILTER_USE_KEY);
243        foreach (\array_slice($hints, 0, 1) as $hint) {
244            $io->writeln('  → '.\strtr($hint, ['%chat%' => $chat, '%bot%' => '' === $bot ? 'YourBot' : $bot]));
245        }
246    }
247
248    /** Telegram's description of the error, e.g. "Bad Request: chat not found". */
249    private function telegramError(\Throwable $exception): ?string
250    {
251        if (!$exception instanceof TransportException) {
252            return null;
253        }
254
255        try {
256            $description = $exception->getResponse()->toArray(false)['description'] ?? null;
257        } catch (HttpException) {
258            return null;
259        }
260
261        return \is_string($description) ? $description : null;
262    }
263}