Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.63% covered (success)
95.63%
175 / 183
95.24% covered (success)
95.24%
20 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
TelegramUtils
95.63% covered (success)
95.63%
175 / 183
95.24% covered (success)
95.24%
20 / 21
47
0.00% covered (danger)
0.00%
0 / 1
 getBotName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBotToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFreshPinForUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 startAnonymousChat
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 startChatForUser
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 linkChatUsingPin
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 linkUserUsingPin
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getTelegramExpirationInterval
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFreshPinForChat
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 setNewPinForLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 generateUniqueTelegramPin
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 generateTelegramPin
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getTelegramPinChars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTelegramPinLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAnonymousChat
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getChatState
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setChatState
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 sendConfiguration
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 callTelegramApi
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
9
 fetchTelegramApi
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 renderMarkdown
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Olz\Utils;
4
5use League\CommonMark\Environment\Environment;
6use League\CommonMark\Extension\Autolink\AutolinkExtension;
7use League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension;
8use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
9use League\CommonMark\MarkdownConverter;
10use Olz\Entity\TelegramLink;
11use Olz\Entity\Users\User;
12
13class TelegramUtils {
14    use WithUtilsTrait;
15
16    public function getBotName(): string {
17        return $this->envUtils()->getTelegramBotName();
18    }
19
20    private function getBotToken(): string {
21        return $this->envUtils()->getTelegramBotToken();
22    }
23
24    public function getFreshPinForUser(User $user): string {
25        $telegram_link = $this->startChatForUser($user);
26        $pin = $telegram_link->getPin();
27        $this->generalUtils()->checkNotNull($pin, "Telegram link pin was null");
28        return $pin;
29    }
30
31    public function startAnonymousChat(string $chat_id, string $user_id): TelegramLink {
32        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
33        $existing = $telegram_link_repo->findOneBy(['telegram_chat_id' => $chat_id]);
34        if ($existing != null) {
35            return $existing;
36        }
37
38        $now = new \DateTime($this->dateUtils()->getIsoNow());
39
40        $telegram_link = new TelegramLink();
41        $telegram_link->setPin(null);
42        $telegram_link->setPinExpiresAt(null);
43        $telegram_link->setUser(null);
44        $telegram_link->setTelegramChatId($chat_id);
45        $telegram_link->setTelegramUserId($user_id);
46        $telegram_link->setTelegramChatState([]);
47        $telegram_link->setCreatedAt($now);
48        $telegram_link->setLinkedAt(null);
49
50        $this->entityManager()->persist($telegram_link);
51        $this->entityManager()->flush();
52        return $telegram_link;
53    }
54
55    public function startChatForUser(User $user): TelegramLink {
56        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
57        $existing_telegram_link = $telegram_link_repo->findOneBy(['user' => $user->getId()]);
58
59        if ($existing_telegram_link != null) {
60            $this->setNewPinForLink($existing_telegram_link);
61            return $existing_telegram_link;
62        }
63
64        $now = new \DateTime($this->dateUtils()->getIsoNow());
65
66        $telegram_link = new TelegramLink();
67        $telegram_link->setUser($user);
68        $telegram_link->setTelegramChatId(null);
69        $telegram_link->setTelegramUserId(null);
70        $telegram_link->setTelegramChatState([]);
71        $telegram_link->setCreatedAt($now);
72        $telegram_link->setLinkedAt(null);
73        $this->setNewPinForLink($telegram_link);
74
75        $this->entityManager()->persist($telegram_link);
76        $this->entityManager()->flush();
77        return $telegram_link;
78    }
79
80    public function linkChatUsingPin(string $pin, string $chat_id, string $user_id): TelegramLink {
81        $now = new \DateTime($this->dateUtils()->getIsoNow());
82
83        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
84        $existing_telegram_link = $telegram_link_repo->findOneBy(['pin' => $pin]);
85
86        if ($existing_telegram_link == null) {
87            throw new \Exception('Falscher PIN.');
88        }
89        if ($now > $existing_telegram_link->getPinExpiresAt()) {
90            throw new \Exception('PIN ist abgelaufen.');
91        }
92        $existing_telegram_link->setTelegramChatId($chat_id);
93        $existing_telegram_link->setTelegramUserId($user_id);
94        $existing_telegram_link->setLinkedAt($now);
95
96        $this->entityManager()->flush();
97        return $existing_telegram_link;
98    }
99
100    public function linkUserUsingPin(string $pin, User $user): TelegramLink {
101        $now = new \DateTime($this->dateUtils()->getIsoNow());
102
103        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
104        $existing_telegram_link = $telegram_link_repo->findOneBy(['pin' => $pin]);
105        $redundant_telegram_links = $telegram_link_repo->findBy(['user' => $user->getId()]);
106
107        if ($existing_telegram_link == null) {
108            throw new \Exception('Falscher PIN.');
109        }
110        if ($now > $existing_telegram_link->getPinExpiresAt()) {
111            throw new \Exception('PIN ist abgelaufen.');
112        }
113        $existing_telegram_link->setUser($user);
114        $existing_telegram_link->setLinkedAt($now);
115
116        foreach ($redundant_telegram_links as $redundant_telegram_link) {
117            $this->entityManager()->remove($redundant_telegram_link);
118        }
119
120        $this->entityManager()->flush();
121        return $existing_telegram_link;
122    }
123
124    public function getTelegramExpirationInterval(): \DateInterval {
125        return \DateInterval::createFromDateString("+10 min");
126    }
127
128    public function getFreshPinForChat(string $chat_id): string {
129        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
130        $existing = $telegram_link_repo->findOneBy(['telegram_chat_id' => $chat_id]);
131        if ($existing == null) {
132            throw new \Exception('Unbekannter Chat.');
133        }
134        $telegram_link = $this->setNewPinForLink($existing);
135        $pin = $telegram_link->getPin();
136        $this->generalUtils()->checkNotNull($pin, "Telegram link pin was null");
137        return $pin;
138    }
139
140    public function setNewPinForLink(TelegramLink $telegram_link): TelegramLink {
141        $pin = $this->generateUniqueTelegramPin();
142        $now = new \DateTime($this->dateUtils()->getIsoNow());
143        $pin_expires_at = $now->add($this->getTelegramExpirationInterval());
144
145        $telegram_link->setPin($pin);
146        $telegram_link->setPinExpiresAt($pin_expires_at);
147
148        $this->entityManager()->flush();
149
150        return $telegram_link;
151    }
152
153    public function generateUniqueTelegramPin(): string {
154        while (true) {
155            $pin = $this->generateTelegramPin();
156            $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
157            $existing = $telegram_link_repo->findOneBy(['pin' => $pin]);
158            $now = new \DateTime($this->dateUtils()->getIsoNow());
159            if ($existing == null || $now > $existing->getPinExpiresAt()) {
160                return $pin;
161            }
162        }
163        // @codeCoverageIgnoreStart
164        // Reason: Unreachable.
165    }
166
167    // @codeCoverageIgnoreEnd
168
169    public function generateTelegramPin(): string {
170        $pin_chars = $this->getTelegramPinChars();
171        $pin_length = $this->getTelegramPinLength();
172        $pin = '';
173        for ($i = 0; $i < $pin_length; $i++) {
174            $char_index = random_int(0, strlen($pin_chars) - 1);
175            $pin .= substr($pin_chars, $char_index, 1);
176        }
177        return $pin;
178    }
179
180    /** @return non-empty-string */
181    public function getTelegramPinChars(): string {
182        return '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
183    }
184
185    public function getTelegramPinLength(): int {
186        return 8;
187    }
188
189    public function isAnonymousChat(string $chat_id): bool {
190        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
191        $existing = $telegram_link_repo->findOneBy(['telegram_chat_id' => $chat_id]);
192        if ($existing == null) {
193            return true;
194        }
195        return $existing->getUser() == null;
196    }
197
198    /** @return array<string, mixed> */
199    public function getChatState(string $chat_id): ?array {
200        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
201        $existing = $telegram_link_repo->findOneBy(['telegram_chat_id' => $chat_id]);
202        if ($existing == null) {
203            return null;
204        }
205        return $existing->getTelegramChatState();
206    }
207
208    /** @param array<string, mixed> $chat_state */
209    public function setChatState(string $chat_id, array $chat_state): void {
210        $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
211        $existing = $telegram_link_repo->findOneBy(['telegram_chat_id' => $chat_id]);
212        if ($existing == null) {
213            throw new \Exception('Unbekannter Chat.');
214        }
215        $existing->setTelegramChatState($chat_state);
216    }
217
218    public function sendConfiguration(): void {
219        try {
220            $response = $this->callTelegramApi('setMyCommands', [
221                'commands' => json_encode([
222                    [
223                        'command' => '/ich',
224                        'description' => 'Wer bin ich?',
225                    ],
226                ]),
227                'scope' => json_encode(['type' => 'all_private_chats']),
228            ]);
229            $response_json = json_encode($response);
230        } catch (\Throwable $th) {
231            $this->log()->error("Telegram API: Could not 'setMyCommands'");
232        }
233
234        $base_url = $this->envUtils()->getBaseHref();
235        $code_href = $this->envUtils()->getCodeHref();
236        $authenticity_code = $this->envUtils()->getTelegramAuthenticityCode();
237        $query = "authenticityCode={$authenticity_code}";
238        $url = "{$base_url}{$code_href}api/onTelegram?{$query}";
239        try {
240            $response = $this->callTelegramApi('setWebhook', [
241                'url' => $url,
242            ]);
243            $response_json = json_encode($response);
244        } catch (\Throwable $th) {
245            $this->log()->error("Telegram API: Could not 'setWebhook'");
246        }
247    }
248
249    /**
250     * @param array<string, mixed> $args
251     *
252     * @return array<string, mixed>
253     */
254    public function callTelegramApi(string $command, array $args): array {
255        $response = $this->fetchTelegramApi($command, $args, $this->getBotToken());
256        if (!$response) {
257            $this->log()->warning("Telegram API response was empty");
258            $json = json_encode(['ok' => false]);
259            throw new \Exception("{$json}");
260        }
261        if (isset($response['ok']) && !$response['ok']) {
262            $error_code = $response['error_code'] ?? null;
263            $description = $response['description'] ?? null;
264            $response_json = json_encode($response);
265            if (
266                $error_code == 403
267                && (
268                    $description == 'Forbidden: bot was blocked by the user'
269                    || $description == 'Forbidden: user is deactivated'
270                )
271                && isset($args['chat_id'])
272            ) {
273                $this->log()->notice("Telegram API response was not OK: {$response_json}");
274                $this->log()->notice("Permanently forbidden. Remove telegram link!");
275                $telegram_link_repo = $this->entityManager()->getRepository(TelegramLink::class);
276                $telegram_link = $telegram_link_repo->findOneBy([
277                    'telegram_chat_id' => $args['chat_id'],
278                ]);
279                if ($telegram_link) {
280                    $this->entityManager()->remove($telegram_link);
281                    $this->entityManager()->flush();
282                    $this->log()->notice("Telegram link removed!");
283                }
284                throw new \Exception("{$response_json}");
285            }
286            $this->log()->error("Telegram API response was not OK: {$response_json}");
287            throw new \Exception("{$response_json}");
288        }
289        $this->log()->info("Telegram API {$command} call successful", [$args, $response]);
290        return $response;
291    }
292
293    /**
294     * @param array<string, mixed> $args
295     *
296     * @return ?array<string, mixed>
297     */
298    public function fetchTelegramApi(string $command, array $args, string $bot_token): ?array {
299        $telegram_url = "https://api.telegram.org/bot{$bot_token}/{$command}";
300
301        $ch = curl_init();
302        curl_setopt($ch, CURLOPT_URL, $telegram_url);
303        curl_setopt($ch, CURLOPT_POST, true);
304        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($args, '', '&'));
305        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
306        $telegram_result = curl_exec($ch);
307        return json_decode(!is_bool($telegram_result) ? $telegram_result : '', true);
308    }
309
310    public function renderMarkdown(string $markdown): string {
311        $environment = new Environment([
312            'html_input' => 'strip',
313            'allow_unsafe_links' => false,
314            'max_nesting_level' => 100,
315        ]);
316        $environment->addExtension(new InlinesOnlyExtension());
317        $environment->addExtension(new StrikethroughExtension());
318        $environment->addExtension(new AutolinkExtension());
319        $converter = new MarkdownConverter($environment);
320        $rendered = $converter->convert($markdown);
321        return strval($rendered);
322    }
323}