Passed
Push — master ( 018640...72d7c3 )
by Armando
02:09
created

BotManager::isValidRequestIp()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
c 0
b 0
f 0
dl 0
loc 13
rs 10
cc 3
nc 3
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 = '2.0.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
    private string $output = '';
40
    private Telegram $telegram;
41
    private Params $params;
42
    private Action $action;
43
    private ?Closure $custom_get_updates_callback = null;
0 ignored issues
show
Bug introduced by
The type TelegramBot\TelegramBotManager\Closure was not found. Did you mean Closure? If so, make sure to prefix the type with \.
Loading history...
44
45
    /**
46
     * @throws InvalidParamsException
47
     * @throws InvalidActionException
48
     * @throws TelegramException
49
     */
50
    public function __construct(array $params)
51
    {
52
        $this->params = new Params($params);
53
        $this->action = new Action($this->params->getScriptParam('a'));
54
55
        $this->telegram = new Telegram(
56
            $this->params->getBotParam('api_key'),
57
            $this->params->getBotParam('bot_username') ?? ''
58
        );
59
    }
60
61
    public static function inTest(): bool
62
    {
63
        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...
64
    }
65
66
    public function getTelegram(): Telegram
67
    {
68
        return $this->telegram;
69
    }
70
71
    public function getParams(): Params
72
    {
73
        return $this->params;
74
    }
75
76
    public function getAction(): Action
77
    {
78
        return $this->action;
79
    }
80
81
    /**
82
     * @throws TelegramException
83
     * @throws InvalidAccessException
84
     * @throws InvalidWebhookException
85
     * @throws Exception
86
     */
87
    public function run(): static
88
    {
89
        $this->validateSecret();
90
        $this->validateRequest();
91
92
        if ($this->action->isAction('webhookinfo')) {
93
            $webhookinfo = Request::getWebhookInfo();
94
            /** @noinspection ForgottenDebugOutputInspection */
95
            print_r($webhookinfo->getResult() ?: $webhookinfo->printError(true));
96
            return $this;
97
        }
98
        if ($this->action->isAction(['set', 'unset', 'reset'])) {
99
            return $this->validateAndSetWebhook();
100
        }
101
102
        $this->setBotExtras();
103
104
        if ($this->action->isAction('handle')) {
105
            $this->handleRequest();
106
        } elseif ($this->action->isAction('cron')) {
107
            $this->handleCron();
108
        }
109
110
        return $this;
111
    }
112
113
    /**
114
     * @throws InvalidAccessException
115
     */
116
    public function validateSecret(bool $force = false): static
117
    {
118
        // If we're running from CLI, secret isn't necessary.
119
        if ($force || 'cli' !== PHP_SAPI) {
120
            $secret     = $this->params->getBotParam('secret');
121
            $secret_get = $this->params->getScriptParam('s');
122
            if (!isset($secret, $secret_get) || $secret !== $secret_get) {
123
                throw new InvalidAccessException('Invalid access');
124
            }
125
        }
126
127
        return $this;
128
    }
129
130
    /**
131
     * @throws TelegramException
132
     * @throws InvalidWebhookException
133
     */
134
    public function validateAndSetWebhook(): static
135
    {
136
        $webhook = $this->params->getBotParam('webhook');
137
        if (empty($webhook['url'] ?? null) && $this->action->isAction(['set', 'reset'])) {
138
            throw new InvalidWebhookException('Invalid webhook');
139
        }
140
141
        if ($this->action->isAction(['unset', 'reset'])) {
142
            $this->handleOutput($this->telegram->deleteWebhook()->getDescription() . PHP_EOL);
143
            // When resetting the webhook, sleep for a bit to prevent too many requests.
144
            $this->action->isAction('reset') && sleep(1);
145
        }
146
147
        if ($this->action->isAction(['set', 'reset'])) {
148
            $webhook_params = array_filter([
149
                'certificate'     => $webhook['certificate'] ?? null,
150
                'max_connections' => $webhook['max_connections'] ?? null,
151
                'allowed_updates' => $webhook['allowed_updates'] ?? null,
152
                'secret_token'    => $webhook['secret_token'] ?? null,
153
            ], function ($v, $k) {
154
                if ($k === 'allowed_updates') {
155
                    // Special case for allowed_updates, which can be an empty array.
156
                    return is_array($v);
157
                }
158
                return !empty($v);
159
            }, ARRAY_FILTER_USE_BOTH);
160
161
            $webhook['url'] .= parse_url($webhook['url'], PHP_URL_QUERY) === null ? '?' : '&';
162
            $this->handleOutput(
163
                $this->telegram->setWebhook(
164
                    $webhook['url'] . 'a=handle&s=' . $this->params->getBotParam('secret'),
165
                    $webhook_params
166
                )->getDescription() . PHP_EOL
167
            );
168
        }
169
170
        return $this;
171
    }
172
173
    private function handleOutput(string $output): static
174
    {
175
        $this->output .= $output;
176
177
        if (!self::inTest()) {
178
            echo $output;
179
        }
180
181
        return $this;
182
    }
183
184
    /**
185
     * @throws TelegramException
186
     */
187
    public function setBotExtras(): static
188
    {
189
        $this->setBotExtrasTelegram();
190
        $this->setBotExtrasRequest();
191
192
        return $this;
193
    }
194
195
    /**
196
     * @throws TelegramException
197
     */
198
    protected function setBotExtrasTelegram(): static
199
    {
200
        $simple_extras = [
201
            'admins'         => 'enableAdmins',
202
            'commands.paths' => 'addCommandsPaths',
203
            'custom_input'   => 'setCustomInput',
204
            'paths.download' => 'setDownloadPath',
205
            'paths.upload'   => 'setUploadPath',
206
        ];
207
        // For simple telegram extras, just pass the single param value to the Telegram method.
208
        foreach ($simple_extras as $param_key => $method) {
209
            $param = $this->params->getBotParam($param_key);
210
            if (null !== $param) {
211
                $this->telegram->$method($param);
212
            }
213
        }
214
215
        // Database.
216
        if ($mysql_config = $this->params->getBotParam('mysql', [])) {
217
            $this->telegram->enableMySql(
218
                $mysql_config,
219
                $mysql_config['table_prefix'] ?? '',
220
                $mysql_config['encoding'] ?? 'utf8mb4'
221
            );
222
        }
223
224
        // Custom command configs.
225
        $command_configs = $this->params->getBotParam('commands.configs', []);
226
        foreach ($command_configs as $command => $config) {
227
            $this->telegram->setCommandConfig($command, $config);
228
        }
229
230
        return $this;
231
    }
232
233
    /**
234
     * @throws TelegramException
235
     */
236
    protected function setBotExtrasRequest(): static
237
    {
238
        $request_extras = [
239
            // None at the moment...
240
        ];
241
        // For request extras, just pass the single param value to the Request method.
242
        foreach ($request_extras as $param_key => $method) {
243
            $param = $this->params->getBotParam($param_key);
244
            if (null !== $param) {
245
                Request::$method($param);
246
            }
247
        }
248
249
        // Special cases.
250
        $limiter_enabled = $this->params->getBotParam('limiter.enabled');
251
        if ($limiter_enabled !== null) {
252
            $limiter_options = $this->params->getBotParam('limiter.options', []);
253
            Request::setLimiter($limiter_enabled, $limiter_options);
254
        }
255
256
        return $this;
257
    }
258
259
    /**
260
     * @throws TelegramException
261
     */
262
    public function handleRequest(): static
263
    {
264
        if ($this->params->getBotParam('webhook.url')) {
265
            return $this->handleWebhook();
266
        }
267
268
        if ($loop_time = $this->getLoopTime()) {
269
            return $this->handleGetUpdatesLoop($loop_time, $this->getLoopInterval());
270
        }
271
272
        return $this->handleGetUpdates();
273
    }
274
275
    /**
276
     * @throws TelegramException
277
     */
278
    public function handleCron(): static
279
    {
280
        $groups = explode(',', $this->params->getScriptParam('g', 'default'));
281
282
        $commands = [];
283
        foreach ($groups as $group) {
284
            $commands[] = $this->params->getBotParam('cron.groups.' . $group, []);
285
        }
286
        $this->telegram->runCommands(array_merge(...$commands));
287
288
        return $this;
289
    }
290
291
    public function getLoopTime(): int
292
    {
293
        $loop_time = $this->params->getScriptParam('l');
294
295
        if (null === $loop_time) {
296
            return 0;
297
        }
298
299
        if (is_string($loop_time) && '' === trim($loop_time)) {
300
            return 604800; // Default to 7 days.
301
        }
302
303
        return max(0, (int) $loop_time);
304
    }
305
306
    public function getLoopInterval(): int
307
    {
308
        $interval_time = $this->params->getScriptParam('i');
309
310
        if (null === $interval_time || (is_string($interval_time) && '' === trim($interval_time))) {
311
            return 2;
312
        }
313
314
        // Minimum interval is 1 second.
315
        return max(1, (int) $interval_time);
316
    }
317
318
    /**
319
     * @throws TelegramException
320
     */
321
    public function handleGetUpdatesLoop(int $loop_time_in_seconds, int $loop_interval_in_seconds = 2): static
322
    {
323
        // Remember the time we started this loop.
324
        $now = time();
325
326
        $this->handleOutput('Looping getUpdates until ' . date('Y-m-d H:i:s', $now + $loop_time_in_seconds) . PHP_EOL);
327
328
        while ($now > time() - $loop_time_in_seconds) {
329
            $this->handleGetUpdates();
330
331
            // Chill a bit.
332
            sleep($loop_interval_in_seconds);
333
        }
334
335
        return $this;
336
    }
337
338
    public function setCustomGetUpdatesCallback(callable $callback): static
339
    {
340
        $this->custom_get_updates_callback = Closure::fromCallable($callback);
341
342
        return $this;
343
    }
344
345
    /**
346
     * @throws TelegramException
347
     */
348
    public function handleGetUpdates(): static
349
    {
350
        $get_updates_response = $this->telegram->handleGetUpdates();
351
352
        // Check if the user has set a custom callback for handling the response.
353
        if ($this->custom_get_updates_callback) {
354
            $this->handleOutput(($this->custom_get_updates_callback)($get_updates_response));
355
        } else {
356
            $this->handleOutput($this->defaultGetUpdatesCallback($get_updates_response));
357
        }
358
359
        return $this;
360
    }
361
362
    protected function defaultGetUpdatesCallback(ServerResponse $get_updates_response): string
363
    {
364
        if (!$get_updates_response->isOk()) {
365
            return sprintf(
366
                '%s - Failed to fetch updates' . PHP_EOL . '%s',
367
                date('Y-m-d H:i:s'),
368
                $get_updates_response->printError(true)
369
            );
370
        }
371
372
        /** @var Update[] $results */
373
        $results = array_filter((array) $get_updates_response->getResult());
374
375
        $output = sprintf(
376
            '%s - Updates processed: %d' . PHP_EOL,
377
            date('Y-m-d H:i:s'),
378
            count($results)
379
        );
380
381
        foreach ($results as $result) {
382
            $update_content = $result->getUpdateContent();
383
384
            $chat_id = 'n/a';
385
            $text    = $result->getUpdateType();
386
387
            if ($update_content instanceof Message) {
388
                $chat_id = $update_content->getChat()->getId();
389
                $text    .= ";{$update_content->getType()}";
390
            } elseif ($update_content instanceof InlineQuery || $update_content instanceof ChosenInlineResult) {
391
                /** @var InlineQuery|ChosenInlineResult $update_content */
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