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