Passed
Pull Request — develop (#53)
by Armando
01:41
created

BotManager   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 576
Duplicated Lines 0 %

Importance

Changes 16
Bugs 0 Features 0
Metric Value
wmc 78
eloc 181
c 16
b 0
f 0
dl 0
loc 576
rs 2.16

25 Methods

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

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 Longman\TelegramBot\TelegramLog;
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
    /**
33
     * @var array Telegram webhook servers IP ranges
34
     * @link https://core.telegram.org/bots/webhooks#the-short-version
35
     */
36
    public const TELEGRAM_IP_RANGES = ['149.154.160.0/20', '91.108.4.0/22'];
37
38
    /**
39
     * @var string The output for testing, instead of echoing
40
     */
41
    private $output = '';
42
43
    /**
44
     * @var Telegram
45
     */
46
    private $telegram;
47
48
    /**
49
     * @var Params Object that manages the parameters.
50
     */
51
    private $params;
52
53
    /**
54
     * @var Action Object that contains the current action.
55
     */
56
    private $action;
57
58
    /**
59
     * @var callable
60
     */
61
    private $custom_get_updates_callback;
62
63
    /**
64
     * BotManager constructor.
65
     *
66
     * @param array $params
67
     *
68
     * @throws InvalidParamsException
69
     * @throws InvalidActionException
70
     * @throws TelegramException
71
     * @throws Exception
72
     */
73
    public function __construct(array $params)
74
    {
75
        // Initialise logging before anything else, to allow errors to be logged.
76
        $this->initLogging($params['logging'] ?? []);
77
78
        $this->params = new Params($params);
79
        $this->action = new Action($this->params->getScriptParam('a'));
80
81
        // Set up a new Telegram instance.
82
        $this->telegram = new Telegram(
83
            $this->params->getBotParam('api_key'),
84
            $this->params->getBotParam('bot_username')
85
        );
86
    }
87
88
    /**
89
     * Check if we're busy running the PHPUnit tests.
90
     *
91
     * @return bool
92
     */
93
    public static function inTest(): bool
94
    {
95
        return defined('PHPUNIT_TESTSUITE') && PHPUNIT_TESTSUITE === true;
96
    }
97
98
    /**
99
     * Return the Telegram object.
100
     *
101
     * @return Telegram
102
     */
103
    public function getTelegram(): Telegram
104
    {
105
        return $this->telegram;
106
    }
107
108
    /**
109
     * Get the Params object.
110
     *
111
     * @return Params
112
     */
113
    public function getParams(): Params
114
    {
115
        return $this->params;
116
    }
117
118
    /**
119
     * Get the Action object.
120
     *
121
     * @return Action
122
     */
123
    public function getAction(): Action
124
    {
125
        return $this->action;
126
    }
127
128
    /**
129
     * Run this thing in all its glory!
130
     *
131
     * @return BotManager
132
     * @throws TelegramException
133
     * @throws InvalidAccessException
134
     * @throws InvalidWebhookException
135
     * @throws Exception
136
     */
137
    public function run(): self
138
    {
139
        // Make sure this is a valid call.
140
        $this->validateSecret();
141
        $this->validateRequest();
142
143
        if ($this->action->isAction('webhookinfo')) {
144
            $webhookinfo = Request::getWebhookInfo();
145
            /** @noinspection ForgottenDebugOutputInspection */
146
            print_r($webhookinfo->getResult() ?: $webhookinfo->printError(true));
147
            return $this;
148
        }
149
        if ($this->action->isAction(['set', 'unset', 'reset'])) {
150
            return $this->validateAndSetWebhook();
151
        }
152
153
        $this->setBotExtras();
154
155
        if ($this->action->isAction('handle')) {
156
            $this->handleRequest();
157
        } elseif ($this->action->isAction('cron')) {
158
            $this->handleCron();
159
        }
160
161
        return $this;
162
    }
163
164
    /**
165
     * Initialise all loggers.
166
     *
167
     * @param array $log_paths
168
     *
169
     * @return BotManager
170
     * @throws Exception
171
     */
172
    public function initLogging(array $log_paths): self
173
    {
174
        empty($log_paths) || self::inTest() || trigger_error(__METHOD__ . ' is deprecated and will be removed soon. Initialise with a preconfigured logger instance instead using "TelegramLog::initialize($logger)".', E_USER_DEPRECATED);
175
176
        foreach ($log_paths as $logger => $logfile) {
177
            ('debug' === $logger) && TelegramLog::initDebugLog($logfile);
0 ignored issues
show
Deprecated Code introduced by
The function Longman\TelegramBot\TelegramLog::initDebugLog() has been deprecated: Initialise a preconfigured logger instance instead. ( Ignorable by Annotation )

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

177
            ('debug' === $logger) && /** @scrutinizer ignore-deprecated */ TelegramLog::initDebugLog($logfile);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
178
            ('error' === $logger) && TelegramLog::initErrorLog($logfile);
0 ignored issues
show
Deprecated Code introduced by
The function Longman\TelegramBot\TelegramLog::initErrorLog() has been deprecated: Initialise a preconfigured logger instance instead. ( Ignorable by Annotation )

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

178
            ('error' === $logger) && /** @scrutinizer ignore-deprecated */ TelegramLog::initErrorLog($logfile);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
179
            ('update' === $logger) && TelegramLog::initUpdateLog($logfile);
0 ignored issues
show
Deprecated Code introduced by
The function Longman\TelegramBot\TelegramLog::initUpdateLog() has been deprecated: Initialise a preconfigured logger instance instead. ( Ignorable by Annotation )

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

179
            ('update' === $logger) && /** @scrutinizer ignore-deprecated */ TelegramLog::initUpdateLog($logfile);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

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