Passed
Push — validate-secret_token ( 7dd905 )
by Armando
02:30
created

BotManager::isValidRequestSecretToken()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 10
rs 10
cc 3
nc 2
nop 0
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
    public const VERSION = '1.7.0';
32
33
    /**
34
     * @link https://core.telegram.org/bots/webhooks#the-short-version
35
     * @var array Telegram webhook servers IP ranges
36
     */
37
    public const TELEGRAM_IP_RANGES = ['149.154.160.0/20', '91.108.4.0/22'];
38
39
    /**
40
     * @var string The output for testing, instead of echoing
41
     */
42
    private $output = '';
43
44
    /**
45
     * @var Telegram
46
     */
47
    private $telegram;
48
49
    /**
50
     * @var Params Object that manages the parameters.
51
     */
52
    private $params;
53
54
    /**
55
     * @var Action Object that contains the current action.
56
     */
57
    private $action;
58
59
    /**
60
     * @var callable
61
     */
62
    private $custom_get_updates_callback;
63
64
    /**
65
     * BotManager constructor.
66
     *
67
     * @param array $params
68
     *
69
     * @throws InvalidParamsException
70
     * @throws InvalidActionException
71
     * @throws TelegramException
72
     * @throws Exception
73
     */
74
    public function __construct(array $params)
75
    {
76
        $this->params = new Params($params);
77
        $this->action = new Action($this->params->getScriptParam('a'));
78
79
        // Set up a new Telegram instance.
80
        $this->telegram = new Telegram(
81
            $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

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