├── .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 |

3 |
4 |
5 |
6 |
7 |

8 |

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 |
--------------------------------------------------------------------------------