Passed
Pull Request — develop (#65)
by
unknown
01:38
created

BotManager   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 554
Duplicated Lines 0 %

Importance

Changes 19
Bugs 0 Features 1
Metric Value
wmc 74
eloc 176
c 19
b 0
f 1
dl 0
loc 554
rs 2.48

24 Methods

Rating   Name   Duplication   Size   Complexity  
A getAction() 0 3 1
A setCustomGetUpdatesCallback() 0 4 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 validateAndSetWebhook() 0 36 8
A handleGetUpdates() 0 12 2
A getTelegram() 0 3 1
A __construct() 0 9 1
A setBotExtrasTelegram() 0 33 5
A run() 0 25 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
A validateRequest() 0 4 2
B defaultGetUpdatesCallback() 0 48 9
A getOutput() 0 6 1
A isValidRequest() 0 18 6
A handleWebhook() 0 5 1

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 Exception;
14
use Longman\IPTools\Ip;
15
use Longman\TelegramBot\Entities\CallbackQuery;
16
use Longman\TelegramBot\Entities\ChosenInlineResult;
17
use Longman\TelegramBot\Entities\InlineQuery;
18
use Longman\TelegramBot\Entities\Message;
19
use Longman\TelegramBot\Entities\ServerResponse;
20
use Longman\TelegramBot\Entities\Update;
21
use Longman\TelegramBot\Exception\TelegramException;
22
use Longman\TelegramBot\Request;
23
use Longman\TelegramBot\Telegram;
24
use TelegramBot\TelegramBotManager\Exception\InvalidAccessException;
25
use TelegramBot\TelegramBotManager\Exception\InvalidActionException;
26
use TelegramBot\TelegramBotManager\Exception\InvalidParamsException;
27
use TelegramBot\TelegramBotManager\Exception\InvalidWebhookException;
28
29
class BotManager
30
{
31
    /**
32
     * @link https://core.telegram.org/bots/webhooks#the-short-version
33
     * @var array Telegram webhook servers IP ranges
34
     */
35
    public const TELEGRAM_IP_RANGES = ['149.154.160.0/20', '91.108.4.0/22'];
36
37
    /**
38
     * @var string The output for testing, instead of echoing
39
     */
40
    private $output = '';
41
42
    /**
43
     * @var Telegram
44
     */
45
    private $telegram;
46
47
    /**
48
     * @var Params Object that manages the parameters.
49
     */
50
    private $params;
51
52
    /**
53
     * @var Action Object that contains the current action.
54
     */
55
    private $action;
56
57
    /**
58
     * @var callable
59
     */
60
    private $custom_get_updates_callback;
61
62
    /**
63
     * BotManager constructor.
64
     *
65
     * @param array $params
66
     *
67
     * @throws InvalidParamsException
68
     * @throws InvalidActionException
69
     * @throws TelegramException
70
     * @throws Exception
71
     */
72
    public function __construct(array $params)
73
    {
74
        $this->params = new Params($params);
75
        $this->action = new Action($this->params->getScriptParam('a'));
76
77
        // Set up a new Telegram instance.
78
        $this->telegram = new Telegram(
79
            $this->params->getBotParam('api_key'),
0 ignored issues
show
Bug introduced by
It seems like $this->params->getBotParam('api_key') can also be of type null; however, parameter $api_key of Longman\TelegramBot\Telegram::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

79
            /** @scrutinizer ignore-type */ $this->params->getBotParam('api_key'),
Loading history...
80
            $this->params->getBotParam('bot_username') ?? ''
81
        );
82
    }
83
84
    /**
85
     * Check if we're busy running the PHPUnit tests.
86
     *
87
     * @return bool
88
     */
89
    public static function inTest(): bool
90
    {
91
        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...
92
    }
93
94
    /**
95
     * Return the Telegram object.
96
     *
97
     * @return Telegram
98
     */
99
    public function getTelegram(): Telegram
100
    {
101
        return $this->telegram;
102
    }
103
104
    /**
105
     * Get the Params object.
106
     *
107
     * @return Params
108
     */
109
    public function getParams(): Params
110
    {
111
        return $this->params;
112
    }
113
114
    /**
115
     * Get the Action object.
116
     *
117
     * @return Action
118
     */
119
    public function getAction(): Action
120
    {
121
        return $this->action;
122
    }
123
124
    /**
125
     * Run this thing in all its glory!
126
     *
127
     * @return BotManager
128
     * @throws TelegramException
129
     * @throws InvalidAccessException
130
     * @throws InvalidWebhookException
131
     * @throws Exception
132
     */
133
    public function run(): self
134
    {
135
        // Make sure this is a valid call.
136
        $this->validateSecret();
137
        $this->validateRequest();
138
139
        if ($this->action->isAction('webhookinfo')) {
140
            $webhookinfo = Request::getWebhookInfo();
141
            /** @noinspection ForgottenDebugOutputInspection */
142
            print_r($webhookinfo->getResult() ?: $webhookinfo->printError(true));
143
            return $this;
144
        }
145
        if ($this->action->isAction(['set', 'unset', 'reset'])) {
146
            return $this->validateAndSetWebhook();
147
        }
148
149
        $this->setBotExtras();
150
151
        if ($this->action->isAction('handle')) {
152
            $this->handleRequest();
153
        } elseif ($this->action->isAction('cron')) {
154
            $this->handleCron();
155
        }
156
157
        return $this;
158
    }
159
160
    /**
161
     * Make sure the passed secret is valid.
162
     *
163
     * @param bool $force Force validation, even on CLI.
164
     *
165
     * @return BotManager
166
     * @throws InvalidAccessException
167
     */
168
    public function validateSecret(bool $force = false): self
169
    {
170
        // If we're running from CLI, secret isn't necessary.
171
        if ($force || 'cli' !== PHP_SAPI) {
172
            $secret     = $this->params->getBotParam('secret');
173
            $secret_get = $this->params->getScriptParam('s');
174
            if (!isset($secret, $secret_get) || $secret !== $secret_get) {
175
                throw new InvalidAccessException('Invalid access');
176
            }
177
        }
178
179
        return $this;
180
    }
181
182
    /**
183
     * Make sure the webhook is valid and perform the requested webhook operation.
184
     *
185
     * @return BotManager
186
     * @throws TelegramException
187
     * @throws InvalidWebhookException
188
     */
189
    public function validateAndSetWebhook(): self
190
    {
191
        $webhook = $this->params->getBotParam('webhook');
192
        if (empty($webhook['url'] ?? null) && $this->action->isAction(['set', 'reset'])) {
193
            throw new InvalidWebhookException('Invalid webhook');
194
        }
195
196
        if ($this->action->isAction(['unset', 'reset'])) {
197
            $this->handleOutput($this->telegram->deleteWebhook()->getDescription() . PHP_EOL);
198
            // When resetting the webhook, sleep for a bit to prevent too many requests.
199
            $this->action->isAction('reset') && sleep(1);
200
        }
201
202
        if ($this->action->isAction(['set', 'reset'])) {
203
            $webhook_params = array_filter([
204
                'certificate'     => $webhook['certificate'] ?? null,
205
                'max_connections' => $webhook['max_connections'] ?? null,
206
                'allowed_updates' => $webhook['allowed_updates'] ?? null,
207
            ], function ($v, $k) {
208
                if ($k === 'allowed_updates') {
209
                    // Special case for allowed_updates, which can be an empty array.
210
                    return is_array($v);
211
                }
212
                return !empty($v);
213
            }, ARRAY_FILTER_USE_BOTH);
214
215
            $webhook['url'] .= parse_url($webhook['url'], PHP_URL_QUERY) === null ? '?' : '&';
216
            $this->handleOutput(
217
                $this->telegram->setWebhook(
218
                    $webhook['url'] . 'a=handle&s=' . $this->params->getBotParam('secret'),
219
                    $webhook_params
220
                )->getDescription() . PHP_EOL
221
            );
222
        }
223
224
        return $this;
225
    }
226
227
    /**
228
     * Save the test output and echo it if we're not in a test.
229
     *
230
     * @param string $output
231
     *
232
     * @return BotManager
233
     */
234
    private function handleOutput(string $output): self
235
    {
236
        $this->output .= $output;
237
238
        if (!self::inTest()) {
239
            echo $output;
240
        }
241
242
        return $this;
243
    }
244
245
    /**
246
     * Set any extra bot features that have been assigned on construction.
247
     *
248
     * @return BotManager
249
     * @throws TelegramException
250
     */
251
    public function setBotExtras(): self
252
    {
253
        $this->setBotExtrasTelegram();
254
        $this->setBotExtrasRequest();
255
256
        return $this;
257
    }
258
259
    /**
260
     * Set extra bot parameters for Telegram object.
261
     *
262
     * @return BotManager
263
     * @throws TelegramException
264
     */
265
    protected function setBotExtrasTelegram(): self
266
    {
267
        $simple_extras = [
268
            'admins'         => 'enableAdmins',
269
            'commands.paths' => 'addCommandsPaths',
270
            'custom_input'   => 'setCustomInput',
271
            'paths.download' => 'setDownloadPath',
272
            'paths.upload'   => 'setUploadPath',
273
        ];
274
        // For simple telegram extras, just pass the single param value to the Telegram method.
275
        foreach ($simple_extras as $param_key => $method) {
276
            $param = $this->params->getBotParam($param_key);
277
            if (null !== $param) {
278
                $this->telegram->$method($param);
279
            }
280
        }
281
282
        // Database.
283
        if ($mysql_config = $this->params->getBotParam('mysql', [])) {
284
            $this->telegram->enableMySql(
285
                $mysql_config,
286
                $mysql_config['table_prefix'] ?? '',
287
                $mysql_config['encoding'] ?? 'utf8mb4'
288
            );
289
        }
290
291
        // Custom command configs.
292
        $command_configs = $this->params->getBotParam('commands.configs', []);
293
        foreach ($command_configs as $command => $config) {
294
            $this->telegram->setCommandConfig($command, $config);
295
        }
296
297
        return $this;
298
    }
299
300
    /**
301
     * Set extra bot parameters for Request class.
302
     *
303
     * @return BotManager
304
     * @throws TelegramException
305
     */
306
    protected function setBotExtrasRequest(): self
307
    {
308
        $request_extras = [
309
            // None at the moment...
310
        ];
311
        // For request extras, just pass the single param value to the Request method.
312
        foreach ($request_extras as $param_key => $method) {
313
            $param = $this->params->getBotParam($param_key);
314
            if (null !== $param) {
315
                Request::$method($param);
316
            }
317
        }
318
319
        // Special cases.
320
        $limiter_enabled = $this->params->getBotParam('limiter.enabled');
321
        if ($limiter_enabled !== null) {
322
            $limiter_options = $this->params->getBotParam('limiter.options', []);
323
            Request::setLimiter($limiter_enabled, $limiter_options);
324
        }
325
326
        return $this;
327
    }
328
329
    /**
330
     * Handle the request, which calls either the Webhook or getUpdates method respectively.
331
     *
332
     * @return BotManager
333
     * @throws TelegramException
334
     */
335
    public function handleRequest(): self
336
    {
337
        if ($this->params->getBotParam('webhook.url')) {
338
            return $this->handleWebhook();
339
        }
340
341
        if ($loop_time = $this->getLoopTime()) {
342
            return $this->handleGetUpdatesLoop($loop_time, $this->getLoopInterval());
343
        }
344
345
        return $this->handleGetUpdates();
346
    }
347
348
    /**
349
     * Handle cron.
350
     *
351
     * @return BotManager
352
     * @throws TelegramException
353
     */
354
    public function handleCron(): self
355
    {
356
        $groups = explode(',', $this->params->getScriptParam('g', 'default'));
357
358
        $commands = [];
359
        foreach ($groups as $group) {
360
            $commands[] = $this->params->getBotParam('cron.groups.' . $group, []);
361
        }
362
        $this->telegram->runCommands(array_merge(...$commands));
363
364
        return $this;
365
    }
366
367
    /**
368
     * Get the number of seconds the script should loop.
369
     *
370
     * @return int
371
     */
372
    public function getLoopTime(): int
373
    {
374
        $loop_time = $this->params->getScriptParam('l');
375
376
        if (null === $loop_time) {
377
            return 0;
378
        }
379
380
        if (is_string($loop_time) && '' === trim($loop_time)) {
381
            return 604800; // Default to 7 days.
382
        }
383
384
        return max(0, (int) $loop_time);
385
    }
386
387
    /**
388
     * Get the number of seconds the script should wait after each getUpdates request.
389
     *
390
     * @return int
391
     */
392
    public function getLoopInterval(): int
393
    {
394
        $interval_time = $this->params->getScriptParam('i');
395
396
        if (null === $interval_time || (is_string($interval_time) && '' === trim($interval_time))) {
397
            return 2;
398
        }
399
400
        // Minimum interval is 1 second.
401
        return max(1, (int) $interval_time);
402
    }
403
404
    /**
405
     * Loop the getUpdates method for the passed amount of seconds.
406
     *
407
     * @param int $loop_time_in_seconds
408
     * @param int $loop_interval_in_seconds
409
     *
410
     * @return BotManager
411
     * @throws TelegramException
412
     */
413
    public function handleGetUpdatesLoop(int $loop_time_in_seconds, int $loop_interval_in_seconds = 2): self
414
    {
415
        // Remember the time we started this loop.
416
        $now = time();
417
418
        $this->handleOutput('Looping getUpdates until ' . date('Y-m-d H:i:s', $now + $loop_time_in_seconds) . PHP_EOL);
419
420
        while ($now > time() - $loop_time_in_seconds) {
421
            $this->handleGetUpdates();
422
423
            // Chill a bit.
424
            sleep($loop_interval_in_seconds);
425
        }
426
427
        return $this;
428
    }
429
430
    /**
431
     * Set a custom callback for handling the output of the getUpdates results.
432
     *
433
     * @param callable $callback
434
     *
435
     * @return BotManager
436
     */
437
    public function setCustomGetUpdatesCallback(callable $callback): BotManager
438
    {
439
        $this->custom_get_updates_callback = $callback;
440
        return $this;
441
    }
442
443
    /**
444
     * Handle the updates using the getUpdates method.
445
     *
446
     * @return BotManager
447
     * @throws TelegramException
448
     */
449
    public function handleGetUpdates(): self
450
    {
451
        $get_updates_response = $this->telegram->handleGetUpdates();
452
453
        // Check if the user has set a custom callback for handling the response.
454
        if ($this->custom_get_updates_callback !== null) {
455
            $this->handleOutput(call_user_func($this->custom_get_updates_callback, $get_updates_response));
456
        } else {
457
            $this->handleOutput($this->defaultGetUpdatesCallback($get_updates_response));
458
        }
459
460
        return $this;
461
    }
462
463
    /**
464
     * Return the default output for getUpdates handling.
465
     *
466
     * @param ServerResponse $get_updates_response
467
     *
468
     * @return string
469
     */
470
    protected function defaultGetUpdatesCallback($get_updates_response): string
471
    {
472
        if (!$get_updates_response->isOk()) {
473
            return sprintf(
474
                '%s - Failed to fetch updates' . PHP_EOL . '%s',
475
                date('Y-m-d H:i:s'),
476
                $get_updates_response->printError(true)
477
            );
478
        }
479
480
        /** @var Update[] $results */
481
        $results = array_filter((array) $get_updates_response->getResult());
482
483
        $output = sprintf(
484
            '%s - Updates processed: %d' . PHP_EOL,
485
            date('Y-m-d H:i:s'),
486
            count($results)
487
        );
488
489
        foreach ($results as $result) {
490
            $update_content = $result->getUpdateContent();
491
492
            $chat_id = 'n/a';
493
            $text    = $result->getUpdateType();
494
495
            if ($update_content instanceof Message) {
496
                /** @var Message $update_content */
497
                $chat_id = $update_content->getChat()->getId();
498
                $text    .= ";{$update_content->getType()}";
499
            } elseif ($update_content instanceof InlineQuery || $update_content instanceof ChosenInlineResult) {
500
                /** @var InlineQuery|ChosenInlineResult $update_content */
501
                $chat_id = $update_content->getFrom()->getId();
502
                $text    .= ";{$update_content->getQuery()}";
503
            } elseif ($update_content instanceof CallbackQuery) {
504
                /** @var CallbackQuery $update_content */
505
                $message = $update_content->getMessage();
506
                $chat_id = ($message && $message->getChat()) ? $message->getChat()->getId() : null;
507
                $text    .= ";{$update_content->getData()}";
508
            }
509
510
            $output .= sprintf(
511
                '%d: <%s>' . PHP_EOL,
512
                $chat_id,
513
                preg_replace('/\s+/', ' ', trim($text))
514
            );
515
        }
516
517
        return $output;
518
    }
519
520
    /**
521
     * Handle the updates using the Webhook method.
522
     *
523
     * @return BotManager
524
     * @throws TelegramException
525
     */
526
    public function handleWebhook(): self
527
    {
528
        $this->telegram->handle();
529
530
        return $this;
531
    }
532
533
    /**
534
     * Return the current test output and clear it.
535
     *
536
     * @return string
537
     */
538
    public function getOutput(): string
539
    {
540
        $output       = $this->output;
541
        $this->output = '';
542
543
        return $output;
544
    }
545
546
    /**
547
     * Check if this is a valid request coming from a Telegram API IP address.
548
     *
549
     * @link https://core.telegram.org/bots/webhooks#the-short-version
550
     *
551
     * @return bool
552
     */
553
    public function isValidRequest(): bool
554
    {
555
        // If we're running from CLI, requests are always valid, unless we're running the tests.
556
        if ((!self::inTest() && 'cli' === PHP_SAPI) || false === $this->params->getBotParam('validate_request')) {
557
            return true;
558
        }
559
560
        $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
561
        foreach (['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR'] as $key) {
562
            if (filter_var($_SERVER[$key] ?? null, FILTER_VALIDATE_IP)) {
563
                $ip = $_SERVER[$key];
564
                break;
565
            }
566
        }
567
568
        return Ip::match($ip, array_merge(
569
            self::TELEGRAM_IP_RANGES,
570
            (array) $this->params->getBotParam('valid_ips', [])
571
        ));
572
    }
573
574
    /**
575
     * Make sure this is a valid request.
576
     *
577
     * @throws InvalidAccessException
578
     */
579
    private function validateRequest(): void
580
    {
581
        if (!$this->isValidRequest()) {
582
            throw new InvalidAccessException('Invalid access');
583
        }
584
    }
585
}
586