├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── examples ├── echobot-getupdates.php └── echobot-webhook.php ├── phpunit.xml ├── src ├── Exception │ ├── InvalidUpdateException.php │ └── TelegramException.php └── Telegram │ ├── Bot.php │ ├── TelegramResponse.php │ └── Update.php └── tests ├── Pest.php └── Unit └── Telegram ├── BotTest.php └── UpdatesTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | max_line_length = 120 11 | 12 | [*.php] 13 | ij_php_phpdoc_param_spaces_between_type_and_name = 1 14 | ij_php_phpdoc_param_spaces_between_tag_and_type = 1 15 | ij_php_phpdoc_param_spaces_between_name_and_description = 1 16 | ij_php_phpdoc_wrap_long_lines = false 17 | ij_php_else_if_style = combine 18 | ij_php_align_multiline_parameters = false 19 | ij_php_method_parameters_right_paren_on_new_line = true 20 | ij_php_method_parameters_new_line_after_left_paren = true 21 | ij_php_space_before_short_closure_left_parenthesis = true 22 | ij_php_space_before_closure_left_parenthesis = true 23 | ij_php_keep_indents_on_empty_lines = false 24 | ij_php_space_after_type_cast = false 25 | ij_php_concat_spaces = false 26 | 27 | 28 | [*.json] 29 | indent_size = 2 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [SebaOfficial] 4 | buy_me_a_coffee: sebadev 5 | custom: ['https://racca.me/donate'] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | 4 | /.phpunit.result.cache 5 | 6 | .php-cs-fixer.cache 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sebastiano Racca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | BotAPI Logo 3 | 4 |
5 |
6 |
7 | Latest Version on Packagist 8 | PHP Version 9 |
10 |
11 | 12 | 13 | # 🛠 Installation 14 | You can install the package via composer: 15 | 16 | ```bash 17 | composer require telegramsdk/botapi 18 | ``` 19 | 20 | # ❔ Usage 21 | * Full documentation can be found [here](https://botapi.racca.me). 22 | * Examples can be found [here](https://botapi.racca.me/docs/category/examples). 23 | 24 | # 📝 Testing 25 | Run Unit tests: 26 | ```bash 27 | composer test 28 | ``` 29 | Before running tests make sure you have `runkit7` installed: 30 | ```bash 31 | composer runkit 32 | ``` 33 | 34 | 35 | # ⚖️ License 36 | This project is under the [MIT License](https://github.com/TelegramSDK/BotAPI/blob/main/LICENSE). 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegramsdk/botapi", 3 | "description": "SDK for the Telegram Bot API.", 4 | "keywords": [ 5 | "telegram", 6 | "api", 7 | "bot", 8 | "library", 9 | "TelegramSDK", 10 | "SDK" 11 | ], 12 | "homepage": "https://github.com/TelegramSDK/BotAPI", 13 | "authors": [ 14 | { 15 | "name": "Sebastiano Racca", 16 | "email": "sebastiano@racca.me", 17 | "role": "Developer", 18 | "homepage": "https://github.com/SebaOfficial" 19 | } 20 | ], 21 | "type": "library", 22 | "license": "MIT", 23 | "autoload": { 24 | "classmap": [ 25 | "src/" 26 | ] 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "TelegramSDK\\BotAPI\\Tests\\": "tests/" 31 | } 32 | }, 33 | "scripts": { 34 | "test": "@php vendor/bin/pest --fail-on-warning --profile", 35 | "lint": "vendor/bin/php-cs-fixer fix ." 36 | }, 37 | "require": { 38 | "guzzlehttp/guzzle": "^7.7", 39 | "php": ">=8.0" 40 | }, 41 | "require-dev": { 42 | "friendsofphp/php-cs-fixer": "^3.46", 43 | "pestphp/pest": "^2.8" 44 | }, 45 | "suggest": { 46 | "telegramsdk/botkit": "This package provides additional features for your IDE." 47 | }, 48 | "config": { 49 | "allow-plugins": { 50 | "pestphp/pest-plugin": true 51 | }, 52 | "optimize-autoloader": true, 53 | "preferred-install": "dist", 54 | "sort-packages": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/echobot-getupdates.php: -------------------------------------------------------------------------------- 1 | isValidToken(true)) { 16 | echo RED_COLOR . "Invalid bot token.\n" . DEFAULT_COLOR; 17 | exit(1); 18 | } 19 | 20 | echo GREEN_COLOR . "Bot Started!\n" . DEFAULT_COLOR; 21 | 22 | for (; ; sleep(5)) { 23 | 24 | $updates = $bot->updates(isset($updates) ? $updates->getLastUpdateId() : null); 25 | 26 | foreach($updates->result as $update) { 27 | if(isset($update->message)) { 28 | $chat = $update->getChat(); 29 | 30 | try { 31 | 32 | $res = $bot->copyMessage([ 33 | "chat_id" => $chat->id, 34 | "from_chat_id" => $chat->id, 35 | "message_id" => $update->getMessage()->message_id 36 | ]); 37 | 38 | echo GREEN_COLOR . "Replied to " . $chat->id . "\n" . DEFAULT_COLOR; 39 | 40 | } catch (TelegramException $e) { 41 | echo RED_COLOR . "Coulnd't reply to " . $chat->id . ": " . $e->getResponseBody()->description . "\n" . DEFAULT_COLOR; 42 | } 43 | 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/echobot-webhook.php: -------------------------------------------------------------------------------- 1 | updates(); 10 | 11 | if(isset($update->update_id)) { 12 | 13 | if(isset($update->message)) { 14 | $chat = $update->getChat(); 15 | 16 | $bot->copyMessage([ 17 | "chat_id" => $chat->id, 18 | "from_chat_id" => $chat->id, 19 | "message_id" => $update->getMessage()->message_id 20 | ]); 21 | } 22 | 23 | } else { 24 | echo "No updates from telegram where found.\n"; 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | ./app 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Exception/InvalidUpdateException.php: -------------------------------------------------------------------------------- 1 | httpStatusCode = $httpStatusCode; 39 | $this->responseBody = $responseBody; 40 | } 41 | 42 | /** 43 | * Get the response body associated with the exception. 44 | * 45 | * @return array|object|null The response body. 46 | */ 47 | public function getResponseBody(): array|object|null 48 | { 49 | return $this->responseBody; 50 | } 51 | 52 | /** 53 | * Get the HTTP status code associated with the exception. 54 | * 55 | * @return int|null The HTTP status code. 56 | */ 57 | public function getHttpCode(): ?int 58 | { 59 | return $this->httpStatusCode; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Telegram/Bot.php: -------------------------------------------------------------------------------- 1 | token = $token; 44 | $this->updatesMethod = $updatesMethod ?? -1; // No update method, will throw an exception on $this->updates() 45 | $this->apiURL = $apiURL; 46 | $this->asPayload($replyWithPayload); 47 | } 48 | 49 | /** 50 | * Gets the api url. 51 | * 52 | * @return string The url. 53 | */ 54 | public function getApiUrl(): string 55 | { 56 | return $this->apiURL; 57 | } 58 | 59 | /** 60 | * Checks if the provided token is valid. 61 | * 62 | * @param bool $thoroughCheck If false, performs a superficial check; otherwise, verifies with the Telegram API. 63 | * 64 | * @return bool True if the token is valid; otherwise, false. 65 | */ 66 | public function isValidToken(bool $thoroughCheck): bool 67 | { 68 | if (!preg_match('/[0-9]+:[A-Za-z0-9]+/', $this->token)) { 69 | return false; 70 | } 71 | 72 | if (!$thoroughCheck) { 73 | return true; 74 | } 75 | 76 | try { 77 | return $this->getMe()->body->ok; 78 | } catch (TelegramException $e) { 79 | return false; 80 | } 81 | } 82 | 83 | /** 84 | * Replies directly to the webhook update with a payload in the body. 85 | * 86 | * @param string $method The API method. 87 | * @param array|object $arguments The arguments for the method. 88 | * 89 | * @return void 90 | */ 91 | protected function replyAsPayload(string $method, array|object $arguments = []): void 92 | { 93 | $payload = json_encode(['method' => $method, ...$arguments], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); 94 | 95 | header('Content-Type: application/json'); 96 | header('Content-Length: ' . strlen($payload)); 97 | echo $payload; 98 | 99 | fastcgi_finish_request(); 100 | } 101 | 102 | /** 103 | * Download a file from the server. 104 | * 105 | * @param string $path The file_path given by /getFile. 106 | * @param string $destination The destination of the file to be downloaded. 107 | * @param int $timeout The request timeout. 108 | * 109 | * @return bool Wheter the download was successfull or not. 110 | */ 111 | public function downloadFile(string $path, string $destination, $timeout = 10): bool 112 | { 113 | try { 114 | $client = new GuzzleClient([ 115 | 'timeout' => $timeout, 116 | 'stream' => true, 117 | 'sink' => $destination, 118 | ]); 119 | $client->request('GET', $this->apiURL . 'file/bot' . $this->token . "/$path"); 120 | 121 | return true; 122 | } catch(RequestException $e) { 123 | unlink($destination); 124 | return false; 125 | } 126 | } 127 | 128 | /** 129 | * Sends a request to the Telegram API. 130 | * 131 | * @param string $method The method to call. 132 | * @param array|object|null $arguments The arguments for the method. 133 | * @param int $timeout The request timeout. 134 | * @param bool $multipart Pass true to use 'multipart/form-data', false to use 'application/json'. 135 | * 136 | * @return TelegramResponse The response from the Telegram API or null on RequestException. 137 | * 138 | * @throws TelegramException If an error occurs during the request. 139 | */ 140 | protected function sendRequest(string $method, array|object|null $arguments = null, $timeout = 10, bool $multipart = false): TelegramResponse 141 | { 142 | $telegramUrl = $this->apiURL . 'bot' . $this->token . "/$method"; 143 | $client = new GuzzleClient(['timeout' => $timeout]); 144 | 145 | try { 146 | $options = []; 147 | 148 | if (!empty($arguments)) { 149 | $options[$multipart ? 'multipart' : 'json'] = $arguments; 150 | } 151 | 152 | $response = $client->post($telegramUrl, $options); 153 | 154 | $body = json_decode($response->getBody()->getContents()); 155 | 156 | return new TelegramResponse($body, $response->getStatusCode(), null); 157 | 158 | } catch (RequestException $e) { 159 | $response = $e->getResponse(); 160 | 161 | throw new TelegramException( 162 | $e->getMessage(), 163 | $response ? $response->getStatusCode() : 400, 164 | $response ? json_decode($response->getBody()->getContents()) : null 165 | ); 166 | } 167 | } 168 | 169 | /** 170 | * Retrieves updates from the Telegram API. 171 | * 172 | * @param int|null $offset The updates offset, only in UPDATES_FROM_WEBHOOK mode. 173 | * 174 | * @return Update|null The retrieved updates, null on NO_UPDATES mode. 175 | */ 176 | public function updates(?int $offset = null): ?Update 177 | { 178 | if ($this->updatesMethod === Update::UPDATES_FROM_GET_UPDATES) { 179 | return new Update($this->getUpdates([ 180 | "offset" => isset($offset) ? $offset + 1 : null 181 | ])->body, $this->updatesMethod); 182 | } 183 | 184 | if ($this->updatesMethod === Update::UPDATES_FROM_WEBHOOK) { 185 | return new Update(json_decode(file_get_contents("php://input")), $this->updatesMethod); 186 | } 187 | 188 | return null; 189 | } 190 | 191 | /** 192 | * Sets how to reply to the Telegram API. 193 | * @param bool $enablePayload Set to true to send a payload insted of another request, false to send a request. 194 | * @return void 195 | */ 196 | public function asPayload(bool $enablePayload = true): void 197 | { 198 | $this->payload = $enablePayload; 199 | 200 | if($enablePayload) { 201 | if($this->updatesMethod !== Update::UPDATES_FROM_WEBHOOK || !function_exists('fastcgi_finish_request')) { 202 | throw new \LogicException("Can't send payload on response if php-fpm isn't enabled"); 203 | } 204 | } 205 | } 206 | 207 | /** 208 | * Magic method for dynamically calling API methods. 209 | * 210 | * @param string $method The API method. 211 | * @param array $arguments The arguments for the method. 212 | * 213 | * @return TelegramResponse|null The response from sendRequest() or null if the API call was sent as a payload. 214 | */ 215 | public function __call($method, $arguments): mixed 216 | { 217 | if (method_exists(self::class, $method)) { 218 | return self::$method(...$arguments); 219 | } 220 | 221 | if(!$this->payload) { 222 | return $this->sendRequest($method, ...$arguments); 223 | } 224 | 225 | $this->replyAsPayload($method, ...$arguments); 226 | return null; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/Telegram/TelegramResponse.php: -------------------------------------------------------------------------------- 1 | body = $body; 30 | $this->statusCode = $statusCode; 31 | $this->error = $error; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Telegram/Update.php: -------------------------------------------------------------------------------- 1 | method = $updatesMethod; 41 | 42 | $this->data = $data; 43 | $this->lastUpdateID = isset($data->result[0]) ? $data->result[array_key_last($data->result)]->update_id ?? null : null; 44 | 45 | foreach ($data ?? [] as $key => $value) { 46 | $this->$key = $value; 47 | } 48 | } 49 | 50 | /** 51 | * Checks if the provided updates method is valid. 52 | * 53 | * @param int $method The updates method. 54 | * 55 | * @throws InvalidUpdateException If the provided updates method is invalid. 56 | */ 57 | public static function validateUpdateMethod(int $method): void 58 | { 59 | if (!in_array($method, [self::UPDATES_FROM_GET_UPDATES, self::UPDATES_FROM_WEBHOOK])) { 60 | throw new InvalidUpdateException("The provided updates method is invalid"); 61 | } 62 | } 63 | 64 | /** 65 | * Checks if the update is from Telegram. 66 | * 67 | * @param string|null $secretToken The secret token for additional security. 68 | * 69 | * @return bool True if the update is from Telegram; otherwise, false. 70 | * 71 | * @throws InvalidUpdateException If updates are requested without using a webhook. 72 | */ 73 | public function isFromTelegram(?string $secretToken = null): bool 74 | { 75 | if ($this->method === self::UPDATES_FROM_GET_UPDATES) { 76 | throw new InvalidUpdateException("You won't receive updates from Telegram if you don't use a webhook"); 77 | } 78 | 79 | if (isset($secretToken)) { 80 | return $secretToken === ($_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? null); 81 | } 82 | 83 | trigger_error("It is highly recommended to set up a secret token.", E_USER_WARNING); 84 | 85 | return strpos($_SERVER['HTTP_USER_AGENT'] ?? "", 'TelegramBot') === false; 86 | } 87 | 88 | /** 89 | * Get the last update ID. 90 | * 91 | * @return int|null The last update ID. 92 | */ 93 | public function getLastUpdateId(): ?int 94 | { 95 | return $this->lastUpdateID; 96 | } 97 | 98 | /** 99 | * Get the message from the update. 100 | * 101 | * @return object|null The message object if present; otherwise, null. 102 | */ 103 | public function getMessage(): ?object 104 | { 105 | if(!isset($this->customs['message'])) { 106 | $this->customs['message'] = $this->data->message ?? 107 | $this->data->edited_message ?? 108 | $this->data->channel_post ?? 109 | $this->data->edited_channel_post ?? 110 | $this->data->callback_query->message ?? 111 | null; 112 | } 113 | 114 | return $this->customs['message']; 115 | } 116 | 117 | /** 118 | * Get the chat from the update. 119 | * 120 | * @return object|null The chat object if present; otherwise, null. 121 | */ 122 | public function getChat(): ?object 123 | { 124 | if(!isset($this->customs['chat'])) { 125 | $this->customs['chat'] = $this->getMessage()->chat ?? 126 | $this->data->callback_query->message->chat ?? 127 | $this->data->my_chat_member->chat ?? 128 | $this->data->chat_member->chat ?? 129 | $this->data->chat_join_request->chat ?? 130 | null; 131 | } 132 | 133 | return $this->customs['chat']; 134 | } 135 | 136 | /** 137 | * Get the user from the update. 138 | * 139 | * @return object|null The user object if present; otherwise, null. 140 | */ 141 | public function getUser(): ?object 142 | { 143 | if(!isset($this->customs['user'])) { 144 | $this->customs['user'] = $this->data->callback_query->from ?? 145 | $this->getMessage()->from ?? 146 | $this->getMessage()->sender_chat ?? 147 | $this->data->inline_query->from ?? 148 | $this->data->chosen_inline_result->from ?? 149 | $this->data->callback_query->from ?? 150 | $this->data->shipping_query->from ?? 151 | $this->data->poll_answer->user ?? 152 | $this->data->chat_member->from ?? 153 | $this->data->chat_join_request->from ?? 154 | null; 155 | } 156 | 157 | return $this->customs['user']; 158 | } 159 | 160 | /** 161 | * Returns the trigger of the update. 162 | * A textual message in the chat. 163 | * i.e. A command, a caption, a callback data, ... 164 | * 165 | * @return string|null A string rappresenting the trigger. 166 | */ 167 | public function getTrigger(): ?string 168 | { 169 | if(!isset($this->customs['trigger'])) { 170 | $message = $this->data->message ?? 171 | $this->data->edited_message ?? 172 | $this->data->channel_post ?? 173 | $this->data->edited_channel_post ?? 174 | null; 175 | 176 | $this->customs['trigger'] = $message->text ?? 177 | $message->caption ?? 178 | $this->data->inline_query->query ?? 179 | $this->data->callback_query->data ?? 180 | null; 181 | } 182 | 183 | return $this->customs['trigger']; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | isValidToken(false))->toBeFalse(); 9 | }); 10 | 11 | it("returns false on invalid token", function () { 12 | $bot = new Bot("123:abc"); 13 | expect($bot->isValidToken(true))->toBeFalse(); 14 | }); 15 | 16 | it("throws an exception on invalid token", function () { 17 | $this->expectException(TelegramException::class); 18 | 19 | $bot = new Bot("an invalid bot token"); 20 | $bot->getMe(); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/Unit/Telegram/UpdatesTest.php: -------------------------------------------------------------------------------- 1 | true, 8 | "result" => [ 9 | (object)["update_id" => 1], 10 | (object)["update_id" => 2], 11 | (object)["update_id" => 3], 12 | ], 13 | ]; 14 | 15 | $updates = new Update($data, Update::UPDATES_FROM_WEBHOOK); 16 | 17 | expect($updates->getLastUpdateId())->toBe(3); 18 | }); 19 | 20 | it("sets lastUpdateID to null when data does not contain result", function () { 21 | $data = (object)[ 22 | "ok" => true, 23 | "result" => [ 24 | 25 | ] 26 | ]; 27 | 28 | $updates = new Update($data, Update::UPDATES_FROM_WEBHOOK); 29 | 30 | expect($updates->getLastUpdateId())->toBeNull(); 31 | }); 32 | --------------------------------------------------------------------------------