BotManager   F
last analyzed

Complexity

Total Complexity 79

Size/Duplication

Total Lines 447
Duplicated Lines 0 %

Importance

Changes 21
Bugs 0 Features 1
Metric Value
wmc 79
eloc 186
c 21
b 0
f 1
dl 0
loc 447
rs 2.08

26 Methods

Rating   Name   Duplication   Size   Complexity  
A validateRequest() 0 4 2
A isValidRequestSecretToken() 0 10 3
A isValidRequestIp() 0 13 3
A getOutput() 0 6 1
A isValidRequest() 0 9 5
A handleWebhook() 0 5 1
A getAction() 0 3 1
A setCustomGetUpdatesCallback() 0 5 1
A handleOutput() 0 9 2
A getLoopTime() 0 13 4
A handleGetUpdatesLoop() 0 15 2
A handleCron() 0 11 2
A setBotExtrasRequest() 0 21 4
B defaultGetUpdatesCallback() 0 48 9
B validateAndSetWebhook() 0 37 8
A handleGetUpdates() 0 12 2
A getTelegram() 0 3 1
A __construct() 0 8 1
A setBotExtrasTelegram() 0 33 5
A run() 0 24 6
A setBotExtras() 0 6 1
A validateSecret() 0 12 5
A handleRequest() 0 11 3
A inTest() 0 3 2
A getParams() 0 3 1
A getLoopInterval() 0 10 4

How to fix   Complexity   

Complex Class

Complex classes like BotManager often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BotManager, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
/**
3
 * This file is part of the TelegramBotManager package.
4
 *
5
 * (c) Armando Lüscher <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace TelegramBot\TelegramBotManager;
12
13
use Closure;
14
use Exception;
15
use Longman\IPTools\Ip;
16
use Longman\TelegramBot\Entities\CallbackQuery;
17
use Longman\TelegramBot\Entities\ChosenInlineResult;
18
use Longman\TelegramBot\Entities\InlineQuery;
19
use Longman\TelegramBot\Entities\Message;
20
use Longman\TelegramBot\Entities\ServerResponse;
21
use Longman\TelegramBot\Entities\Update;
22
use Longman\TelegramBot\Exception\TelegramException;
23
use Longman\TelegramBot\Request;
24
use Longman\TelegramBot\Telegram;
25
use TelegramBot\TelegramBotManager\Exception\InvalidAccessException;
26
use TelegramBot\TelegramBotManager\Exception\InvalidActionException;
27
use TelegramBot\TelegramBotManager\Exception\InvalidParamsException;
28
use TelegramBot\TelegramBotManager\Exception\InvalidWebhookException;
29
30
class BotManager
31
{
32
    public const VERSION = '2.0.0';
33
34
    /**
35
     * @link https://core.telegram.org/bots/webhooks#the-short-version
36
     * @var array Telegram webhook servers IP ranges
37
     */
38
    public const TELEGRAM_IP_RANGES = ['149.154.160.0/20', '91.108.4.0/22'];
39
40
    private string $output = '';
41
    private Telegram $telegram;
42
    private Params $params;
43
    private Action $action;
44
    private ?Closure $custom_get_updates_callback = null;
45
46
    /**
47
     * @throws InvalidParamsException
48
     * @throws InvalidActionException
49
     * @throws TelegramException
50
     */
51
    public function __construct(array $params)
52
    {
53
        $this->params = new Params($params);
54
        $this->action = new Action($this->params->getScriptParam('a'));
55
56
        $this->telegram = new Telegram(
57
            $this->params->getBotParam('api_key'),
58
            $this->params->getBotParam('bot_username') ?? ''
59
        );
60
    }
61
62
    public static function inTest(): bool
63
    {
64
        return defined('PHPUNIT_TESTSUITE') && PHPUNIT_TESTSUITE === true;
0 ignored issues
show
Bug introduced by
The constant TelegramBot\TelegramBotManager\PHPUNIT_TESTSUITE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
65
    }
66
67
    public function getTelegram(): Telegram
68
    {
69
        return $this->telegram;
70
    }
71
72
    public function getParams(): Params
73
    {
74
        return $this->params;
75
    }
76
77
    public function getAction(): Action
78
    {
79
        return $this->action;
80
    }
81
82
    /**
83
     * @throws TelegramException
84
     * @throws InvalidAccessException
85
     * @throws InvalidWebhookException
86
     * @throws Exception
87
     */
88
    public function run(): static
89
    {
90
        $this->validateSecret();
91
        $this->validateRequest();
92
93
        if ($this->action->isAction('webhookinfo')) {
94
            $webhookinfo = Request::getWebhookInfo();
95
            /** @noinspection ForgottenDebugOutputInspection */
96
            print_r($webhookinfo->getResult() ?: $webhookinfo->printError(true));
97
            return $this;
98
        }
99
        if ($this->action->isAction(['set', 'unset', 'reset'])) {
100
            return $this->validateAndSetWebhook();
101
        }
102
103
        $this->setBotExtras();
104
105
        if ($this->action->isAction('handle')) {
106
            $this->handleRequest();
107
        } elseif ($this->action->isAction('cron')) {
108
            $this->handleCron();
109
        }
110
111
        return $this;
112
    }
113
114
    /**
115
     * @throws InvalidAccessException
116
     */
117
    public function validateSecret(bool $force = false): static
118
    {
119
        // If we're running from CLI, secret isn't necessary.
120
        if ($force || 'cli' !== PHP_SAPI) {
121
            $secret     = $this->params->getBotParam('secret');
122
            $secret_get = $this->params->getScriptParam('s');
123
            if (!isset($secret, $secret_get) || $secret !== $secret_get) {
124
                throw new InvalidAccessException('Invalid access');
125
            }
126
        }
127
128
        return $this;
129
    }
130
131
    /**
132
     * @throws TelegramException
133
     * @throws InvalidWebhookException
134
     */
135
    public function validateAndSetWebhook(): static
136
    {
137
        $webhook = $this->params->getBotParam('webhook');
138
        if (empty($webhook['url'] ?? null) && $this->action->isAction(['set', 'reset'])) {
139
            throw new InvalidWebhookException('Invalid webhook');
140
        }
141
142
        if ($this->action->isAction(['unset', 'reset'])) {
143
            $this->handleOutput($this->telegram->deleteWebhook()->getDescription() . PHP_EOL);
144
            // When resetting the webhook, sleep for a bit to prevent too many requests.
145
            $this->action->isAction('reset') && sleep(1);
146
        }
147
148
        if ($this->action->isAction(['set', 'reset'])) {
149
            $webhook_params = array_filter([
150
                'certificate'     => $webhook['certificate'] ?? null,
151
                'max_connections' => $webhook['max_connections'] ?? null,
152
                'allowed_updates' => $webhook['allowed_updates'] ?? null,
153
                'secret_token'    => $webhook['secret_token'] ?? null,
154
            ], function ($v, $k) {
155
                if ($k === 'allowed_updates') {
156
                    // Special case for allowed_updates, which can be an empty array.
157
                    return is_array($v);
158
                }
159
                return !empty($v);
160
            }, ARRAY_FILTER_USE_BOTH);
161
162
            $webhook['url'] .= parse_url($webhook['url'], PHP_URL_QUERY) === null ? '?' : '&';
163
            $this->handleOutput(
164
                $this->telegram->setWebhook(
165
                    $webhook['url'] . 'a=handle&s=' . $this->params->getBotParam('secret'),
166
                    $webhook_params
167
                )->getDescription() . PHP_EOL
168
            );
169
        }
170
171
        return $this;
172
    }
173
174
    private function handleOutput(string $output): static
175
    {
176
        $this->output .= $output;
177
178
        if (!self::inTest()) {
179
            echo $output;
180
        }
181
182
        return $this;
183
    }
184
185
    /**
186
     * @throws TelegramException
187
     */
188
    public function setBotExtras(): static
189
    {
190
        $this->setBotExtrasTelegram();
191
        $this->setBotExtrasRequest();
192
193
        return $this;
194
    }
195
196
    /**
197
     * @throws TelegramException
198
     */
199
    protected function setBotExtrasTelegram(): static
200
    {
201
        $simple_extras = [
202
            'admins'         => 'enableAdmins',
203
            'commands.paths' => 'addCommandsPaths',
204
            'custom_input'   => 'setCustomInput',
205
            'paths.download' => 'setDownloadPath',
206
            'paths.upload'   => 'setUploadPath',
207
        ];
208
        // For simple telegram extras, just pass the single param value to the Telegram method.
209
        foreach ($simple_extras as $param_key => $method) {
210
            $param = $this->params->getBotParam($param_key);
211
            if (null !== $param) {
212
                $this->telegram->$method($param);
213
            }
214
        }
215
216
        // Database.
217
        if ($mysql_config = $this->params->getBotParam('mysql', [])) {
218
            $this->telegram->enableMySql(
219
                $mysql_config,
220
                $mysql_config['table_prefix'] ?? '',
221
                $mysql_config['encoding'] ?? 'utf8mb4'
222
            );
223
        }
224
225
        // Custom command configs.
226
        $command_configs = $this->params->getBotParam('commands.configs', []);
227
        foreach ($command_configs as $command => $config) {
228
            $this->telegram->setCommandConfig($command, $config);
229
        }
230
231
        return $this;
232
    }
233
234
    /**
235
     * @throws TelegramException
236
     */
237
    protected function setBotExtrasRequest(): static
238
    {
239
        $request_extras = [
240
            // None at the moment...
241
        ];
242
        // For request extras, just pass the single param value to the Request method.
243
        foreach ($request_extras as $param_key => $method) {
244
            $param = $this->params->getBotParam($param_key);
245
            if (null !== $param) {
246
                Request::$method($param);
247
            }
248
        }
249
250
        // Special cases.
251
        $limiter_enabled = $this->params->getBotParam('limiter.enabled');
252
        if ($limiter_enabled !== null) {
253
            $limiter_options = $this->params->getBotParam('limiter.options', []);
254
            Request::setLimiter($limiter_enabled, $limiter_options);
255
        }
256
257
        return $this;
258
    }
259
260
    /**
261
     * @throws TelegramException
262
     */
263
    public function handleRequest(): static
264
    {
265
        if ($this->params->getBotParam('webhook.url')) {
266
            return $this->handleWebhook();
267
        }
268
269
        if ($loop_time = $this->getLoopTime()) {
270
            return $this->handleGetUpdatesLoop($loop_time, $this->getLoopInterval());
271
        }
272
273
        return $this->handleGetUpdates();
274
    }
275
276
    /**
277
     * @throws TelegramException
278
     */
279
    public function handleCron(): static
280
    {
281
        $groups = explode(',', $this->params->getScriptParam('g', 'default'));
282
283
        $commands = [];
284
        foreach ($groups as $group) {
285
            $commands[] = $this->params->getBotParam('cron.groups.' . $group, []);
286
        }
287
        $this->telegram->runCommands(array_merge(...$commands));
288
289
        return $this;
290
    }
291
292
    public function getLoopTime(): int
293
    {
294
        $loop_time = $this->params->getScriptParam('l');
295
296
        if (null === $loop_time) {
297
            return 0;
298
        }
299
300
        if (is_string($loop_time) && '' === trim($loop_time)) {
301
            return 604800; // Default to 7 days.
302
        }
303
304
        return max(0, (int) $loop_time);
305
    }
306
307
    public function getLoopInterval(): int
308
    {
309
        $interval_time = $this->params->getScriptParam('i');
310
311
        if (null === $interval_time || (is_string($interval_time) && '' === trim($interval_time))) {
312
            return 2;
313
        }
314
315
        // Minimum interval is 1 second.
316
        return max(1, (int) $interval_time);
317
    }
318
319
    /**
320
     * @throws TelegramException
321
     */
322
    public function handleGetUpdatesLoop(int $loop_time_in_seconds, int $loop_interval_in_seconds = 2): static
323
    {
324
        // Remember the time we started this loop.
325
        $now = time();
326
327
        $this->handleOutput('Looping getUpdates until ' . date('Y-m-d H:i:s', $now + $loop_time_in_seconds) . PHP_EOL);
328
329
        while ($now > time() - $loop_time_in_seconds) {
330
            $this->handleGetUpdates();
331
332
            // Chill a bit.
333
            sleep($loop_interval_in_seconds);
334
        }
335
336
        return $this;
337
    }
338
339
    public function setCustomGetUpdatesCallback(callable $callback): static
340
    {
341
        $this->custom_get_updates_callback = Closure::fromCallable($callback);
342
343
        return $this;
344
    }
345
346
    /**
347
     * @throws TelegramException
348
     */
349
    public function handleGetUpdates(): static
350
    {
351
        $get_updates_response = $this->telegram->handleGetUpdates();
352
353
        // Check if the user has set a custom callback for handling the response.
354
        if ($this->custom_get_updates_callback) {
355
            $this->handleOutput(($this->custom_get_updates_callback)($get_updates_response));
356
        } else {
357
            $this->handleOutput($this->defaultGetUpdatesCallback($get_updates_response));
358
        }
359
360
        return $this;
361
    }
362
363
    protected function defaultGetUpdatesCallback(ServerResponse $get_updates_response): string
364
    {
365
        if (!$get_updates_response->isOk()) {
366
            return sprintf(
367
                '%s - Failed to fetch updates' . PHP_EOL . '%s',
368
                date('Y-m-d H:i:s'),
369
                $get_updates_response->printError(true)
370
            );
371
        }
372
373
        /** @var Update[] $results */
374
        $results = array_filter((array) $get_updates_response->getResult());
375
376
        $output = sprintf(
377
            '%s - Updates processed: %d' . PHP_EOL,
378
            date('Y-m-d H:i:s'),
379
            count($results)
380
        );
381
382
        foreach ($results as $result) {
383
            $update_content = $result->getUpdateContent();
384
385
            $chat_id = 'n/a';
386
            $text    = $result->getUpdateType();
387
388
            if ($update_content instanceof Message) {
389
                $chat_id = $update_content->getChat()->getId();
390
                $text    .= ";{$update_content->getType()}";
391
            } elseif ($update_content instanceof InlineQuery || $update_content instanceof ChosenInlineResult) {
392
                $chat_id = $update_content->getFrom()->getId();
393
                $text    .= ";{$update_content->getQuery()}";
394
            } elseif ($update_content instanceof CallbackQuery) {
395
                $message = $update_content->getMessage();
396
                if ($message && $message->getChat()) {
397
                    $chat_id = $message->getChat()->getId();
398
                }
399
400
                $text .= ";{$update_content->getData()}";
401
            }
402
403
            $output .= sprintf(
404
                '%s: <%s>' . PHP_EOL,
405
                $chat_id,
406
                preg_replace('/\s+/', ' ', trim($text))
407
            );
408
        }
409
410
        return $output;
411
    }
412
413
    /**
414
     * @throws TelegramException
415
     */
416
    public function handleWebhook(): static
417
    {
418
        $this->telegram->handle();
419
420
        return $this;
421
    }
422
423
    public function getOutput(): string
424
    {
425
        $output       = $this->output;
426
        $this->output = '';
427
428
        return $output;
429
    }
430
431
    public function isValidRequest(): bool
432
    {
433
        // If we're running from CLI, requests are always valid, unless we're running the tests.
434
        if ((!self::inTest() && 'cli' === PHP_SAPI) || false === $this->params->getBotParam('validate_request')) {
435
            return true;
436
        }
437
438
        return $this->isValidRequestIp()
439
            && $this->isValidRequestSecretToken();
440
    }
441
442
    protected function isValidRequestIp(): bool
443
    {
444
        $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
445
        foreach (['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR'] as $key) {
446
            if (filter_var($_SERVER[$key] ?? null, FILTER_VALIDATE_IP)) {
447
                $ip = $_SERVER[$key];
448
                break;
449
            }
450
        }
451
452
        return Ip::match($ip, array_merge(
453
            self::TELEGRAM_IP_RANGES,
454
            (array) $this->params->getBotParam('valid_ips', [])
455
        ));
456
    }
457
458
    protected function isValidRequestSecretToken(): bool
459
    {
460
        $secret_token     = $this->params->getBotParam('webhook.secret_token');
461
        $secret_token_api = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? null;
462
463
        if ($secret_token || $secret_token_api) {
464
            return $secret_token === $secret_token_api;
465
        }
466
467
        return true;
468
    }
469
470
    /**
471
     * @throws InvalidAccessException
472
     */
473
    private function validateRequest(): void
474
    {
475
        if (!$this->isValidRequest()) {
476
            throw new InvalidAccessException('Invalid access');
477
        }
478
    }
479
}
480