Passed
Push — master ( 097177...94d770 )
by Armando
04:40 queued 01:51
created

Telegram::classNameToCommandName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2.0625

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 8
ccs 3
cts 4
cp 0.75
crap 2.0625
rs 10
1
<?php
2
3
/**
4
 * This file is part of the TelegramBot package.
5
 *
6
 * (c) Avtandil Kikabidze aka LONGMAN <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Longman\TelegramBot;
13
14 1
defined('TB_BASE_PATH') || define('TB_BASE_PATH', __DIR__);
15 1
defined('TB_BASE_COMMANDS_PATH') || define('TB_BASE_COMMANDS_PATH', TB_BASE_PATH . '/Commands');
16
17
use Exception;
18
use InvalidArgumentException;
19
use Longman\TelegramBot\Commands\AdminCommand;
20
use Longman\TelegramBot\Commands\Command;
21
use Longman\TelegramBot\Commands\SystemCommand;
22
use Longman\TelegramBot\Commands\UserCommand;
23
use Longman\TelegramBot\Entities\Chat;
24
use Longman\TelegramBot\Entities\ServerResponse;
25
use Longman\TelegramBot\Entities\Update;
26
use Longman\TelegramBot\Entities\User;
27
use Longman\TelegramBot\Exception\TelegramException;
28
use PDO;
29
use RecursiveDirectoryIterator;
30
use RecursiveIteratorIterator;
31
use RegexIterator;
32
33
class Telegram
34
{
35
    /**
36
     * Version
37
     *
38
     * @var string
39
     */
40
    protected $version = '0.80.0';
41
42
    /**
43
     * Telegram API key
44
     *
45
     * @var string
46
     */
47
    protected $api_key = '';
48
49
    /**
50
     * Telegram Bot username
51
     *
52
     * @var string
53
     */
54
    protected $bot_username = '';
55
56
    /**
57
     * Telegram Bot id
58
     *
59
     * @var int
60
     */
61
    protected $bot_id = 0;
62
63
    /**
64
     * Raw request data (json) for webhook methods
65
     *
66
     * @var string
67
     */
68
    protected $input = '';
69
70
    /**
71
     * Custom commands paths
72
     *
73
     * @var array
74
     */
75
    protected $commands_paths = [];
76
77
    /**
78
     * Custom command class names
79
     * ```
80
     * [
81
     *     'User' => [
82
     *         // command_name => command_class
83
     *         'start' => 'name\space\to\StartCommand',
84
     *     ],
85
     *     'Admin' => [], //etc
86
     * ]
87
     * ```
88
     *
89
     * @var array
90
     */
91
    protected $command_classes = [
92
        Command::AUTH_USER   => [],
93
        Command::AUTH_ADMIN  => [],
94
        Command::AUTH_SYSTEM => [],
95
    ];
96
97
    /**
98
     * Custom commands objects
99
     *
100
     * @var array
101
     */
102
    protected $commands_objects = [];
103
104
    /**
105
     * Current Update object
106
     *
107
     * @var Update
108
     */
109
    protected $update;
110
111
    /**
112
     * Upload path
113
     *
114
     * @var string
115
     */
116
    protected $upload_path = '';
117
118
    /**
119
     * Download path
120
     *
121
     * @var string
122
     */
123
    protected $download_path = '';
124
125
    /**
126
     * MySQL integration
127
     *
128
     * @var bool
129
     */
130
    protected $mysql_enabled = false;
131
132
    /**
133
     * PDO object
134
     *
135
     * @var PDO
136
     */
137
    protected $pdo;
138
139
    /**
140
     * Commands config
141
     *
142
     * @var array
143
     */
144
    protected $commands_config = [];
145
146
    /**
147
     * Admins list
148
     *
149
     * @var array
150
     */
151
    protected $admins_list = [];
152
153
    /**
154
     * ServerResponse of the last Command execution
155
     *
156
     * @var ServerResponse
157
     */
158
    protected $last_command_response;
159
160
    /**
161
     * Check if runCommands() is running in this session
162
     *
163
     * @var bool
164
     */
165
    protected $run_commands = false;
166
167
    /**
168
     * Is running getUpdates without DB enabled
169
     *
170
     * @var bool
171
     */
172
    protected $getupdates_without_database = false;
173
174
    /**
175
     * Last update ID
176
     * Only used when running getUpdates without a database
177
     *
178
     * @var int
179
     */
180
    protected $last_update_id;
181
182
    /**
183
     * The command to be executed when there's a new message update and nothing more suitable is found
184
     */
185
    public const GENERIC_MESSAGE_COMMAND = 'genericmessage';
186
187
    /**
188
     * The command to be executed by default (when no other relevant commands are applicable)
189
     */
190
    public const GENERIC_COMMAND = 'generic';
191
192
    /**
193
     * Update filter method
194
     *
195
     * @var callable
196
     */
197
    protected $update_filter;
198
199
    /**
200
     * Telegram constructor.
201
     *
202
     * @param string $api_key
203
     * @param string $bot_username
204
     *
205
     * @throws TelegramException
206
     */
207 33
    public function __construct(string $api_key, string $bot_username = '')
208
    {
209 33
        if (empty($api_key)) {
210 1
            throw new TelegramException('API KEY not defined!');
211
        }
212 33
        preg_match('/(\d+):[\w\-]+/', $api_key, $matches);
213 33
        if (!isset($matches[1])) {
214 1
            throw new TelegramException('Invalid API KEY defined!');
215
        }
216 33
        $this->bot_id  = (int) $matches[1];
217 33
        $this->api_key = $api_key;
218
219 33
        $this->bot_username = $bot_username;
220
221
        //Add default system commands path
222 33
        $this->addCommandsPath(TB_BASE_COMMANDS_PATH . '/SystemCommands');
223
224 33
        Request::initialize($this);
225
    }
226
227
    /**
228
     * Initialize Database connection
229
     *
230
     * @param array  $credentials
231
     * @param string $table_prefix
232
     * @param string $encoding
233
     *
234
     * @return Telegram
235
     * @throws TelegramException
236
     */
237 9
    public function enableMySql(array $credentials, string $table_prefix = '', string $encoding = 'utf8mb4'): Telegram
238
    {
239 9
        $this->pdo = DB::initialize($credentials, $this, $table_prefix, $encoding);
240 9
        ConversationDB::initializeConversation();
241 9
        $this->mysql_enabled = true;
242
243 9
        return $this;
244
    }
245
246
    /**
247
     * Initialize Database external connection
248
     *
249
     * @param PDO    $external_pdo_connection PDO database object
250
     * @param string $table_prefix
251
     *
252
     * @return Telegram
253
     * @throws TelegramException
254
     */
255
    public function enableExternalMySql(PDO $external_pdo_connection, string $table_prefix = ''): Telegram
256
    {
257
        $this->pdo = DB::externalInitialize($external_pdo_connection, $this, $table_prefix);
258
        ConversationDB::initializeConversation();
259
        $this->mysql_enabled = true;
260
261
        return $this;
262
    }
263
264
    /**
265
     * Get commands list
266
     *
267
     * @return array $commands
268
     * @throws TelegramException
269
     */
270 1
    public function getCommandsList(): array
271
    {
272 1
        $commands = [];
273
274 1
        foreach ($this->commands_paths as $path) {
275
            try {
276
                //Get all "*Command.php" files
277 1
                $files = new RegexIterator(
278 1
                    new RecursiveIteratorIterator(
279 1
                        new RecursiveDirectoryIterator($path)
280
                    ),
281
                    '/^.+Command.php$/'
282
                );
283
284 1
                foreach ($files as $file) {
285
                    // Convert filename to command
286 1
                    $command = $this->classNameToCommandName(substr($file->getFilename(), 0, -4));
287
288
                    // Invalid Classname
289 1
                    if (is_null($command)) {
290
                        continue;
291
                    }
292
293
                    // Already registered
294 1
                    if (array_key_exists($command, $commands)) {
295
                        continue;
296
                    }
297
298 1
                    require_once $file->getPathname();
299
300 1
                    $command_obj = $this->getCommandObject($command, $file->getPathname());
301 1
                    if ($command_obj instanceof Command) {
302 1
                        $commands[$command] = $command_obj;
303
                    }
304
                }
305
            } catch (Exception $e) {
306
                throw new TelegramException('Error getting commands from path: ' . $path, $e);
0 ignored issues
show
Bug introduced by
$e of type Exception is incompatible with the type integer expected by parameter $code of Longman\TelegramBot\Exce...xception::__construct(). ( Ignorable by Annotation )

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

306
                throw new TelegramException('Error getting commands from path: ' . $path, /** @scrutinizer ignore-type */ $e);
Loading history...
307
            }
308
        }
309
310 1
        return $commands;
311
    }
312
313
    /**
314
     * Get classname of predefined commands
315
     *
316
     * @see command_classes
317
     *
318
     * @param string $auth     Auth of command
319
     * @param string $command  Command name
320
     * @param string $filepath Path to the command file
321
     *
322
     * @return string|null
323
     */
324 2
    public function getCommandClassName(string $auth, string $command, string $filepath = ''): ?string
325
    {
326 2
        $command = mb_strtolower($command);
327
328
        // Invalid command
329 2
        if (trim($command) === '') {
330
            return null;
331
        }
332
333 2
        $auth    = $this->ucFirstUnicode($auth);
334
335
        // First, check for directly assigned command class.
336 2
        if ($command_class = $this->command_classes[$auth][$command] ?? null) {
337 1
            return $command_class;
338
        }
339
340
        // Start with default namespace.
341 2
        $command_namespace = __NAMESPACE__ . '\\Commands\\' . $auth . 'Commands';
342
343
        // Check if we can get the namespace from the file (if passed).
344 2
        if ($filepath && !($command_namespace = $this->getFileNamespace($filepath))) {
345
            return null;
346
        }
347
348 2
        $command_class = $command_namespace . '\\' . $this->commandNameToClassName($command);
349
350 2
        if (class_exists($command_class)) {
351 1
            return $command_class;
352
        }
353
354 1
        return null;
355
    }
356
357
    /**
358
     * Get an object instance of the passed command
359
     *
360
     * @param string $command
361
     * @param string $filepath
362
     *
363
     * @return Command|null
364
     */
365 1
    public function getCommandObject(string $command, string $filepath = ''): ?Command
366
    {
367 1
        if (isset($this->commands_objects[$command])) {
368
            return $this->commands_objects[$command];
369
        }
370
371 1
        $which = [Command::AUTH_SYSTEM];
372 1
        $this->isAdmin() && $which[] = Command::AUTH_ADMIN;
373 1
        $which[] = Command::AUTH_USER;
374
375 1
        foreach ($which as $auth) {
376 1
            $command_class = $this->getCommandClassName($auth, $command, $filepath);
377
378 1
            if ($command_class) {
379 1
                $command_obj = new $command_class($this, $this->update);
380
381 1
                if ($auth === Command::AUTH_SYSTEM && $command_obj instanceof SystemCommand) {
382 1
                    return $command_obj;
383
                }
384
                if ($auth === Command::AUTH_ADMIN && $command_obj instanceof AdminCommand) {
385
                    return $command_obj;
386
                }
387
                if ($auth === Command::AUTH_USER && $command_obj instanceof UserCommand) {
388
                    return $command_obj;
389
                }
390
            }
391
        }
392
393
        return null;
394
    }
395
396
    /**
397
     * Get namespace from php file by src path
398
     *
399
     * @param string $src (absolute path to file)
400
     *
401
     * @return string|null ("Longman\TelegramBot\Commands\SystemCommands" for example)
402
     */
403 1
    protected function getFileNamespace(string $src): ?string
404
    {
405 1
        $content = file_get_contents($src);
406 1
        if (preg_match('#^\s*namespace\s+(.+?);#m', $content, $m)) {
407 1
            return $m[1];
408
        }
409
410
        return null;
411
    }
412
413
    /**
414
     * Set custom input string for debug purposes
415
     *
416
     * @param string $input (json format)
417
     *
418
     * @return Telegram
419
     */
420
    public function setCustomInput(string $input): Telegram
421
    {
422
        $this->input = $input;
423
424
        return $this;
425
    }
426
427
    /**
428
     * Get custom input string for debug purposes
429
     *
430
     * @return string
431
     */
432
    public function getCustomInput(): string
433
    {
434
        return $this->input;
435
    }
436
437
    /**
438
     * Get the ServerResponse of the last Command execution
439
     *
440
     * @return ServerResponse
441
     */
442
    public function getLastCommandResponse(): ServerResponse
443
    {
444
        return $this->last_command_response;
445
    }
446
447
    /**
448
     * Handle getUpdates method
449
     *
450
     * @todo Remove backwards compatibility for old signature and force $data to be an array.
451
     *
452
     * @param array|int|null $data
453
     * @param int|null       $timeout
454
     *
455
     * @return ServerResponse
456
     * @throws TelegramException
457
     */
458
    public function handleGetUpdates($data = null, ?int $timeout = null): ServerResponse
459
    {
460
        if (empty($this->bot_username)) {
461
            throw new TelegramException('Bot Username is not defined!');
462
        }
463
464
        if (!DB::isDbConnected() && !$this->getupdates_without_database) {
465
            return new ServerResponse(
466
                [
467
                    'ok'          => false,
468
                    'description' => 'getUpdates needs MySQL connection! (This can be overridden - see documentation)',
469
                ],
470
                $this->bot_username
471
            );
472
        }
473
474
        $offset = 0;
475
        $limit  = null;
476
477
        // By default, get update types sent by Telegram.
478
        $allowed_updates = [];
479
480
        // @todo Backwards compatibility for old signature, remove in next version.
481
        if (!is_array($data)) {
482
            $limit = $data;
483
484
            @trigger_error(
485
                sprintf('Use of $limit and $timeout parameters in %s is deprecated. Use $data array instead.', __METHOD__),
486
                E_USER_DEPRECATED
487
            );
488
        } else {
489
            $offset          = $data['offset'] ?? $offset;
490
            $limit           = $data['limit'] ?? $limit;
491
            $timeout         = $data['timeout'] ?? $timeout;
492
            $allowed_updates = $data['allowed_updates'] ?? $allowed_updates;
493
        }
494
495
        // Take custom input into account.
496
        if ($custom_input = $this->getCustomInput()) {
497
            try {
498
                $input = json_decode($this->input, true, 512, JSON_THROW_ON_ERROR);
499
                if (empty($input)) {
500
                    throw new TelegramException('Custom input is empty');
501
                }
502
                $response = new ServerResponse($input, $this->bot_username);
503
            } catch (\Throwable $e) {
504
                throw new TelegramException('Invalid custom input JSON: ' . $e->getMessage());
505
            }
506
        } else {
507
            if (DB::isDbConnected() && $last_update = DB::selectTelegramUpdate(1)) {
508
                // Get last Update id from the database.
509
                $last_update          = reset($last_update);
510
                $this->last_update_id = $last_update['id'] ?? null;
511
            }
512
513
            if ($this->last_update_id !== null) {
514
                $offset = $this->last_update_id + 1; // As explained in the telegram bot API documentation.
515
            }
516
517
            $response = Request::getUpdates(compact('offset', 'limit', 'timeout', 'allowed_updates'));
518
        }
519
520
        if ($response->isOk()) {
521
            // Log update.
522
            TelegramLog::update($response->toJson());
523
524
            // Process all updates
525
            /** @var Update $update */
526
            foreach ($response->getResult() as $update) {
527
                $this->processUpdate($update);
528
            }
529
530
            if (!DB::isDbConnected() && !$custom_input && $this->last_update_id !== null && $offset === 0) {
531
                // Mark update(s) as read after handling
532
                $offset = $this->last_update_id + 1;
533
                $limit  = 1;
534
535
                Request::getUpdates(compact('offset', 'limit', 'timeout', 'allowed_updates'));
536
            }
537
        }
538
539
        return $response;
540
    }
541
542
    /**
543
     * Handle bot request from webhook
544
     *
545
     * @return bool
546
     *
547
     * @throws TelegramException
548
     */
549
    public function handle(): bool
550
    {
551
        if ($this->bot_username === '') {
552
            throw new TelegramException('Bot Username is not defined!');
553
        }
554
555
        $input = Request::getInput();
556
        if (empty($input)) {
557
            throw new TelegramException('Input is empty! The webhook must not be called manually, only by Telegram.');
558
        }
559
560
        // Log update.
561
        TelegramLog::update($input);
562
563
        $post = json_decode($input, true);
564
        if (empty($post)) {
565
            throw new TelegramException('Invalid input JSON! The webhook must not be called manually, only by Telegram.');
566
        }
567
568
        if ($response = $this->processUpdate(new Update($post, $this->bot_username))) {
569
            return $response->isOk();
570
        }
571
572
        return false;
573
    }
574
575
    /**
576
     * Get the command name from the command type
577
     *
578
     * @param string $type
579
     *
580
     * @return string
581
     */
582
    protected function getCommandFromType(string $type): string
583
    {
584
        return $this->ucFirstUnicode(str_replace('_', '', $type));
585
    }
586
587
    /**
588
     * Process bot Update request
589
     *
590
     * @param Update $update
591
     *
592
     * @return ServerResponse
593
     * @throws TelegramException
594
     */
595 1
    public function processUpdate(Update $update): ServerResponse
596
    {
597 1
        $this->update         = $update;
598 1
        $this->last_update_id = $update->getUpdateId();
599
600 1
        if (is_callable($this->update_filter)) {
601 1
            $reason = 'Update denied by update_filter';
602
            try {
603 1
                $allowed = (bool) call_user_func_array($this->update_filter, [$update, $this, &$reason]);
604
            } catch (Exception $e) {
605
                $allowed = false;
606
            }
607
608 1
            if (!$allowed) {
609 1
                TelegramLog::debug($reason);
610 1
                return new ServerResponse(['ok' => false, 'description' => 'denied']);
611
            }
612
        }
613
614
        //Load admin commands
615
        if ($this->isAdmin()) {
616
            $this->addCommandsPath(TB_BASE_COMMANDS_PATH . '/AdminCommands', false);
617
        }
618
619
        //Make sure we have an up-to-date command list
620
        //This is necessary to "require" all the necessary command files!
621
        $this->commands_objects = $this->getCommandsList();
622
623
        //If all else fails, it's a generic message.
624
        $command = self::GENERIC_MESSAGE_COMMAND;
625
626
        $update_type = $this->update->getUpdateType();
627
        if ($update_type === 'message') {
628
            $message = $this->update->getMessage();
629
            $type    = $message->getType();
630
631
            // Let's check if the message object has the type field we're looking for...
632
            $command_tmp = $type === 'command' ? $message->getCommand() : $this->getCommandFromType($type);
633
            // ...and if a fitting command class is available.
634
            $command_obj = $command_tmp ? $this->getCommandObject($command_tmp) : null;
635
636
            // Empty usage string denotes a non-executable command.
637
            // @see https://github.com/php-telegram-bot/core/issues/772#issuecomment-388616072
638
            if (
639
                ($command_obj === null && $type === 'command')
640
                || ($command_obj !== null && $command_obj->getUsage() !== '')
641
            ) {
642
                $command = $command_tmp;
643
            }
644
        } elseif ($update_type !== null) {
645
            $command = $this->getCommandFromType($update_type);
646
        }
647
648
        //Make sure we don't try to process update that was already processed
649
        $last_id = DB::selectTelegramUpdate(1, $this->update->getUpdateId());
650
        if ($last_id && count($last_id) === 1) {
651
            TelegramLog::debug('Duplicate update received, processing aborted!');
652
            return Request::emptyResponse();
653
        }
654
655
        DB::insertRequest($this->update);
656
657
        return $this->executeCommand($command);
658
    }
659
660
    /**
661
     * Execute /command
662
     *
663
     * @param string $command
664
     *
665
     * @return ServerResponse
666
     * @throws TelegramException
667
     */
668
    public function executeCommand(string $command): ServerResponse
669
    {
670
        $command = mb_strtolower($command);
671
672
        $command_obj = $this->commands_objects[$command] ?? $this->getCommandObject($command);
673
674
        if (!$command_obj || !$command_obj->isEnabled()) {
675
            //Failsafe in case the Generic command can't be found
676
            if ($command === self::GENERIC_COMMAND) {
677
                throw new TelegramException('Generic command missing!');
678
            }
679
680
            //Handle a generic command or non existing one
681
            $this->last_command_response = $this->executeCommand(self::GENERIC_COMMAND);
682
        } else {
683
            //execute() method is executed after preExecute()
684
            //This is to prevent executing a DB query without a valid connection
685
            $this->last_command_response = $command_obj->preExecute();
686
        }
687
688
        return $this->last_command_response;
689
    }
690
691
    /**
692
     * @deprecated
693
     *
694
     * @param string $command
695
     *
696
     * @return string
697
     */
698
    protected function sanitizeCommand(string $command): string
699
    {
700
        return str_replace(' ', '', $this->ucWordsUnicode(str_replace('_', ' ', $command)));
701
    }
702
703
    /**
704
     * Enable a single Admin account
705
     *
706
     * @param int $admin_id Single admin id
707
     *
708
     * @return Telegram
709
     */
710 1
    public function enableAdmin(int $admin_id): Telegram
711
    {
712 1
        if ($admin_id <= 0) {
713
            TelegramLog::error('Invalid value "' . $admin_id . '" for admin.');
714 1
        } elseif (!in_array($admin_id, $this->admins_list, true)) {
715 1
            $this->admins_list[] = $admin_id;
716
        }
717
718 1
        return $this;
719
    }
720
721
    /**
722
     * Enable a list of Admin Accounts
723
     *
724
     * @param array $admin_ids List of admin ids
725
     *
726
     * @return Telegram
727
     */
728 1
    public function enableAdmins(array $admin_ids): Telegram
729
    {
730 1
        foreach ($admin_ids as $admin_id) {
731 1
            $this->enableAdmin($admin_id);
732
        }
733
734 1
        return $this;
735
    }
736
737
    /**
738
     * Get list of admins
739
     *
740
     * @return array
741
     */
742 1
    public function getAdminList(): array
743
    {
744 1
        return $this->admins_list;
745
    }
746
747
    /**
748
     * Check if the passed user is an admin
749
     *
750
     * If no user id is passed, the current update is checked for a valid message sender.
751
     *
752
     * @param int|null $user_id
753
     *
754
     * @return bool
755
     */
756 1
    public function isAdmin($user_id = null): bool
757
    {
758 1
        if ($user_id === null && $this->update !== null) {
759
            //Try to figure out if the user is an admin
760
            $update_methods = [
761
                'getMessage',
762
                'getEditedMessage',
763
                'getChannelPost',
764
                'getEditedChannelPost',
765
                'getInlineQuery',
766
                'getChosenInlineResult',
767
                'getCallbackQuery',
768
            ];
769
            foreach ($update_methods as $update_method) {
770
                $object = call_user_func([$this->update, $update_method]);
771
                if ($object !== null && $from = $object->getFrom()) {
772
                    $user_id = $from->getId();
773
                    break;
774
                }
775
            }
776
        }
777
778 1
        return ($user_id === null) ? false : in_array($user_id, $this->admins_list, true);
779
    }
780
781
    /**
782
     * Check if user required the db connection
783
     *
784
     * @return bool
785
     */
786
    public function isDbEnabled(): bool
787
    {
788
        return $this->mysql_enabled;
789
    }
790
791
    /**
792
     * Add a single custom command class
793
     *
794
     * @param string $command_class Full command class name
795
     *
796
     * @return Telegram
797
     */
798 2
    public function addCommandClass(string $command_class): Telegram
799
    {
800 2
        if (!$command_class || !class_exists($command_class)) {
801 1
            $error = sprintf('Command class "%s" does not exist.', $command_class);
802 1
            TelegramLog::error($error);
803 1
            throw new InvalidArgumentException($error);
804
        }
805
806 2
        if (!is_a($command_class, Command::class, true)) {
807
            $error = sprintf('Command class "%s" does not extend "%s".', $command_class, Command::class);
808
            TelegramLog::error($error);
809
            throw new InvalidArgumentException($error);
810
        }
811
812
        // Dummy object to get data from.
813 2
        $command_object = new $command_class($this);
814
815 2
        $auth = null;
816 2
        $command_object->isSystemCommand() && $auth = Command::AUTH_SYSTEM;
817 2
        $command_object->isAdminCommand() && $auth = Command::AUTH_ADMIN;
818 2
        $command_object->isUserCommand() && $auth = Command::AUTH_USER;
819
820 2
        if ($auth) {
821 2
            $command = mb_strtolower($command_object->getName());
822
823 2
            $this->command_classes[$auth][$command] = $command_class;
824
        }
825
826 2
        return $this;
827
    }
828
829
    /**
830
     * Add multiple custom command classes
831
     *
832
     * @param array $command_classes List of full command class names
833
     *
834
     * @return Telegram
835
     */
836 1
    public function addCommandClasses(array $command_classes): Telegram
837
    {
838 1
        foreach ($command_classes as $command_class) {
839 1
            $this->addCommandClass($command_class);
840
        }
841
842 1
        return $this;
843
    }
844
845
    /**
846
     * Set a single custom commands path
847
     *
848
     * @param string $path Custom commands path to set
849
     *
850
     * @return Telegram
851
     */
852
    public function setCommandsPath(string $path): Telegram
853
    {
854
        $this->commands_paths = [];
855
856
        $this->addCommandsPath($path);
857
858
        return $this;
859
    }
860
861
    /**
862
     * Add a single custom commands path
863
     *
864
     * @param string $path   Custom commands path to add
865
     * @param bool   $before If the path should be prepended or appended to the list
866
     *
867
     * @return Telegram
868
     */
869 33
    public function addCommandsPath(string $path, bool $before = true): Telegram
870
    {
871 33
        if (!is_dir($path)) {
872 1
            TelegramLog::error('Commands path "' . $path . '" does not exist.');
873 33
        } elseif (!in_array($path, $this->commands_paths, true)) {
874 33
            if ($before) {
875 33
                array_unshift($this->commands_paths, $path);
876
            } else {
877
                $this->commands_paths[] = $path;
878
            }
879
        }
880
881 33
        return $this;
882
    }
883
884
    /**
885
     * Set multiple custom commands paths
886
     *
887
     * @param array $paths Custom commands paths to add
888
     *
889
     * @return Telegram
890
     */
891
    public function setCommandsPaths(array $paths): Telegram
892
    {
893
        $this->commands_paths = [];
894
895
        $this->addCommandsPaths($paths);
896
897
        return $this;
898
    }
899
900
    /**
901
     * Add multiple custom commands paths
902
     *
903
     * @param array $paths  Custom commands paths to add
904
     * @param bool  $before If the paths should be prepended or appended to the list
905
     *
906
     * @return Telegram
907
     */
908 1
    public function addCommandsPaths(array $paths, bool $before = true): Telegram
909
    {
910 1
        foreach ($paths as $path) {
911 1
            $this->addCommandsPath($path, $before);
912
        }
913
914 1
        return $this;
915
    }
916
917
    /**
918
     * Return the list of commands paths
919
     *
920
     * @return array
921
     */
922 1
    public function getCommandsPaths(): array
923
    {
924 1
        return $this->commands_paths;
925
    }
926
927
    /**
928
     * Return the list of command classes
929
     *
930
     * @return array
931
     */
932 2
    public function getCommandClasses(): array
933
    {
934 2
        return $this->command_classes;
935
    }
936
937
    /**
938
     * Set custom upload path
939
     *
940
     * @param string $path Custom upload path
941
     *
942
     * @return Telegram
943
     */
944 1
    public function setUploadPath(string $path): Telegram
945
    {
946 1
        $this->upload_path = $path;
947
948 1
        return $this;
949
    }
950
951
    /**
952
     * Get custom upload path
953
     *
954
     * @return string
955
     */
956 1
    public function getUploadPath(): string
957
    {
958 1
        return $this->upload_path;
959
    }
960
961
    /**
962
     * Set custom download path
963
     *
964
     * @param string $path Custom download path
965
     *
966
     * @return Telegram
967
     */
968 1
    public function setDownloadPath(string $path): Telegram
969
    {
970 1
        $this->download_path = $path;
971
972 1
        return $this;
973
    }
974
975
    /**
976
     * Get custom download path
977
     *
978
     * @return string
979
     */
980 1
    public function getDownloadPath(): string
981
    {
982 1
        return $this->download_path;
983
    }
984
985
    /**
986
     * Set command config
987
     *
988
     * Provide further variables to a particular commands.
989
     * For example you can add the channel name at the command /sendtochannel
990
     * Or you can add the api key for external service.
991
     *
992
     * @param string $command
993
     * @param array  $config
994
     *
995
     * @return Telegram
996
     */
997 13
    public function setCommandConfig(string $command, array $config): Telegram
998
    {
999 13
        $this->commands_config[$command] = $config;
1000
1001 13
        return $this;
1002
    }
1003
1004
    /**
1005
     * Get command config
1006
     *
1007
     * @param string $command
1008
     *
1009
     * @return array
1010
     */
1011 16
    public function getCommandConfig(string $command): array
1012
    {
1013 16
        return $this->commands_config[$command] ?? [];
1014
    }
1015
1016
    /**
1017
     * Get API key
1018
     *
1019
     * @return string
1020
     */
1021 1
    public function getApiKey(): string
1022
    {
1023 1
        return $this->api_key;
1024
    }
1025
1026
    /**
1027
     * Get Bot name
1028
     *
1029
     * @return string
1030
     */
1031 2
    public function getBotUsername(): string
1032
    {
1033 2
        return $this->bot_username;
1034
    }
1035
1036
    /**
1037
     * Get Bot Id
1038
     *
1039
     * @return int
1040
     */
1041
    public function getBotId(): int
1042
    {
1043
        return $this->bot_id;
1044
    }
1045
1046
    /**
1047
     * Get Version
1048
     *
1049
     * @return string
1050
     */
1051
    public function getVersion(): string
1052
    {
1053
        return $this->version;
1054
    }
1055
1056
    /**
1057
     * Set Webhook for bot
1058
     *
1059
     * @param string $url
1060
     * @param array  $data Optional parameters.
1061
     *
1062
     * @return ServerResponse
1063
     * @throws TelegramException
1064
     */
1065
    public function setWebhook(string $url, array $data = []): ServerResponse
1066
    {
1067
        if ($url === '') {
1068
            throw new TelegramException('Hook url is empty!');
1069
        }
1070
1071
        $data        = array_intersect_key($data, array_flip([
1072
            'certificate',
1073
            'ip_address',
1074
            'max_connections',
1075
            'allowed_updates',
1076
            'drop_pending_updates',
1077
            'secret_token',
1078
        ]));
1079
        $data['url'] = $url;
1080
1081
        // If the certificate is passed as a path, encode and add the file to the data array.
1082
        if (!empty($data['certificate']) && is_string($data['certificate'])) {
1083
            $data['certificate'] = Request::encodeFile($data['certificate']);
1084
        }
1085
1086
        $result = Request::setWebhook($data);
1087
1088
        if (!$result->isOk()) {
1089
            throw new TelegramException(
1090
                'Webhook was not set! Error: ' . $result->getErrorCode() . ' ' . $result->getDescription()
1091
            );
1092
        }
1093
1094
        return $result;
1095
    }
1096
1097
    /**
1098
     * Delete any assigned webhook
1099
     *
1100
     * @param array $data
1101
     *
1102
     * @return ServerResponse
1103
     * @throws TelegramException
1104
     */
1105
    public function deleteWebhook(array $data = []): ServerResponse
1106
    {
1107
        $result = Request::deleteWebhook($data);
1108
1109
        if (!$result->isOk()) {
1110
            throw new TelegramException(
1111
                'Webhook was not deleted! Error: ' . $result->getErrorCode() . ' ' . $result->getDescription()
1112
            );
1113
        }
1114
1115
        return $result;
1116
    }
1117
1118
    /**
1119
     * Replace function `ucwords` for UTF-8 characters in the class definition and commands
1120
     *
1121
     * @param string $str
1122
     * @param string $encoding (default = 'UTF-8')
1123
     *
1124
     * @return string
1125
     */
1126 2
    protected function ucWordsUnicode(string $str, string $encoding = 'UTF-8'): string
1127
    {
1128 2
        return mb_convert_case($str, MB_CASE_TITLE, $encoding);
1129
    }
1130
1131
    /**
1132
     * Replace function `ucfirst` for UTF-8 characters in the class definition and commands
1133
     *
1134
     * @param string $str
1135
     * @param string $encoding (default = 'UTF-8')
1136
     *
1137
     * @return string
1138
     */
1139 2
    protected function ucFirstUnicode(string $str, string $encoding = 'UTF-8'): string
1140
    {
1141 2
        return mb_strtoupper(mb_substr($str, 0, 1, $encoding), $encoding)
1142 2
            . mb_strtolower(mb_substr($str, 1, mb_strlen($str), $encoding), $encoding);
1143
    }
1144
1145
    /**
1146
     * Enable requests limiter
1147
     *
1148
     * @param array $options
1149
     *
1150
     * @return Telegram
1151
     * @throws TelegramException
1152
     */
1153
    public function enableLimiter(array $options = []): Telegram
1154
    {
1155
        Request::setLimiter(true, $options);
1156
1157
        return $this;
1158
    }
1159
1160
    /**
1161
     * Run provided commands
1162
     *
1163
     * @param array $commands
1164
     *
1165
     * @return ServerResponse[]
1166
     *
1167
     * @throws TelegramException
1168
     */
1169
    public function runCommands(array $commands): array
1170
    {
1171
        if (empty($commands)) {
1172
            throw new TelegramException('No command(s) provided!');
1173
        }
1174
1175
        $this->run_commands = true;
1176
1177
        // Check if this request has a user Update / comes from Telegram.
1178
        if ($userUpdate = $this->update) {
1179
            $from = $this->update->getMessage()->getFrom();
1180
            $chat = $this->update->getMessage()->getChat();
1181
        } else {
1182
            // Fall back to the Bot user.
1183
            $from = new User([
1184
                'id'         => $this->getBotId(),
1185
                'first_name' => $this->getBotUsername(),
1186
                'username'   => $this->getBotUsername(),
1187
            ]);
1188
1189
            // Try to get "live" Bot info.
1190
            $response = Request::getMe();
1191
            if ($response->isOk()) {
1192
                /** @var User $result */
1193
                $result = $response->getResult();
1194
1195
                $from = new User([
1196
                    'id'         => $result->getId(),
1197
                    'first_name' => $result->getFirstName(),
1198
                    'username'   => $result->getUsername(),
1199
                ]);
1200
            }
1201
1202
            // Give Bot access to admin commands.
1203
            $this->enableAdmin($from->getId());
1204
1205
            // Lock the bot to a private chat context.
1206
            $chat = new Chat([
1207
                'id'   => $from->getId(),
1208
                'type' => 'private',
1209
            ]);
1210
        }
1211
1212
        $newUpdate = static function ($text = '') use ($from, $chat) {
1213
            return new Update([
1214
                'update_id' => -1,
1215
                'message'   => [
1216
                    'message_id' => -1,
1217
                    'date'       => time(),
1218
                    'from'       => json_decode($from->toJson(), true),
1219
                    'chat'       => json_decode($chat->toJson(), true),
1220
                    'text'       => $text,
1221
                ],
1222
            ]);
1223
        };
1224
1225
        $responses = [];
1226
1227
        foreach ($commands as $command) {
1228
            $this->update = $newUpdate($command);
1229
1230
            // Refresh commands list for new Update object.
1231
            $this->commands_objects = $this->getCommandsList();
1232
1233
            $responses[] = $this->executeCommand($this->update->getMessage()->getCommand());
1234
        }
1235
1236
        // Reset Update to initial context.
1237
        $this->update = $userUpdate;
1238
1239
        return $responses;
1240
    }
1241
1242
    /**
1243
     * Is this session initiated by runCommands()
1244
     *
1245
     * @return bool
1246
     */
1247
    public function isRunCommands(): bool
1248
    {
1249
        return $this->run_commands;
1250
    }
1251
1252
    /**
1253
     * Switch to enable running getUpdates without a database
1254
     *
1255
     * @param bool $enable
1256
     *
1257
     * @return Telegram
1258
     */
1259
    public function useGetUpdatesWithoutDatabase(bool $enable = true): Telegram
1260
    {
1261
        $this->getupdates_without_database = $enable;
1262
1263
        return $this;
1264
    }
1265
1266
    /**
1267
     * Return last update id
1268
     *
1269
     * @return int|null
1270
     */
1271
    public function getLastUpdateId(): ?int
1272
    {
1273
        return $this->last_update_id;
1274
    }
1275
1276
    /**
1277
     * Set an update filter callback
1278
     *
1279
     * @param callable $callback
1280
     *
1281
     * @return Telegram
1282
     */
1283 1
    public function setUpdateFilter(callable $callback): Telegram
1284
    {
1285 1
        $this->update_filter = $callback;
1286
1287 1
        return $this;
1288
    }
1289
1290
    /**
1291
     * Return update filter callback
1292
     *
1293
     * @return callable|null
1294
     */
1295
    public function getUpdateFilter(): ?callable
1296
    {
1297
        return $this->update_filter;
1298
    }
1299
1300
    /**
1301
     * Converts the name of a class into the name of a command.
1302
     *
1303
     * @param string $class For example FooBarCommand
1304
     *
1305
     * @return string|null for example foo_bar. In case of errors, returns null.
1306
     */
1307 1
    protected function classNameToCommandName(string $class): ?string
1308
    {
1309
        // If $class doesn't end with 'Command'
1310 1
        if (substr($class, -7) !== 'Command') {
1311
            return null;
1312
        }
1313
1314 1
        return mb_strtolower(preg_replace('/(.)(?=[\p{Lu}])/u', '$1_', substr($class, 0, -7)));
1315
    }
1316
1317
    /**
1318
     * Converts a command name into the name of a class.
1319
     *
1320
     * @param string $command For example foo_bar
1321
     *
1322
     * @return string|null for example FooBarCommand. In case of errors, returns null.
1323
     */
1324 2
    protected function commandNameToClassName(string $command): ?string
1325
    {
1326 2
        if (trim($command) === '') {
1327
            return null;
1328
        }
1329
1330 2
        return str_replace(' ', '', $this->ucWordsUnicode(str_replace('_', ' ', $command))) . 'Command';
1331
    }
1332
}
1333