Lines
86.66%
78 / 90
Methods
60.00%
9 / 15
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __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 | ||
| [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 | ||
| 28 | final 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 | } |
From App\Command\ChatTestHelpers
| 12 | trait 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 | } |