Issues (6)

src/API.php (1 issue)

1
<?php
2
3
4
namespace Sysbot\Telegram;
5
6
use Doctrine\Common\Annotations\AnnotationReader;
7
use Generator;
8
use GuzzleHttp\Client;
9
use GuzzleHttp\Promise\PromiseInterface;
10
use Psr\Http\Message\ResponseInterface;
11
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
12
use Symfony\Component\Serializer\Encoder\JsonEncoder;
13
use Symfony\Component\Serializer\Exception\ExceptionInterface;
14
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata;
15
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
16
use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader;
17
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
18
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
19
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
20
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
21
use Symfony\Component\Serializer\Serializer;
22
use Sysbot\Telegram\Common\NameNormalizer;
23
use Sysbot\Telegram\Constants\ApiClasses;
24
use Sysbot\Telegram\Constants\UpdateTypes;
25
use Sysbot\Telegram\Exceptions\TelegramAPIException;
26
use Sysbot\Telegram\Exceptions\TelegramBadRequestException;
27
use Sysbot\Telegram\Exceptions\TelegramConflictException;
28
use Sysbot\Telegram\Exceptions\TelegramForbiddenException;
29
use Sysbot\Telegram\Exceptions\TelegramNotFoundException;
30
use Sysbot\Telegram\Exceptions\TelegramServerException;
31
use Sysbot\Telegram\Exceptions\TelegramTooManyRequestsException;
32
use Sysbot\Telegram\Exceptions\TelegramUnauthorizedException;
33
use Sysbot\Telegram\Helpers\FileDownloader;
34
use Sysbot\Telegram\Helpers\TypeInstantiator;
35
use Sysbot\Telegram\Types\InputFile;
36
use Sysbot\Telegram\Types\InputMedia;
37
use Sysbot\Telegram\Types\Response;
38
use Sysbot\Telegram\Types\Update;
39
use Sysbot\Telegram\Types\WebhookInfo;
40
41
/**
42
 * Class API
43
 * @package Sysbot\Telegram
44
 */
45
class API
46
{
47
48
    use BaseAPI, FileDownloader, TypeInstantiator;
49
50
    /**
51
     * Official Telegram bot API URL.
52
     */
53
    public const BOT_API_URL = 'https://api.telegram.org/bot';
54
55
    /**
56
     * @var Client
57
     */
58
    private Client $client;
59
    /**
60
     * @var Serializer
61
     */
62
    private Serializer $serializer;
63
    /**
64
     * @var array
65
     */
66
    public array $defaultArgs = [];
67
    /**
68
     * @var array
69
     */
70
    public array $allowedUpdates = [];
71
72
73
    /**
74
     * API constructor.
75
     * @param string $token
76
     * @param string $apiEndpoint
77
     * @param float $timeout
78
     */
79
    public function __construct(
80
        private string $token,
81
        private string $apiEndpoint = self::BOT_API_URL,
82
        private float $timeout = 0
83
    ) {
84
        $apiUri = $this->apiEndpoint . $this->token . '/';
85
        $this->client = new Client(['base_uri' => $apiUri, 'timeout' => $timeout]);
86
        $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
87
        $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory);
88
        $this->serializer = new Serializer(
89
            [
90
                new ObjectNormalizer(
91
                    $classMetadataFactory,
92
                    new CamelCaseToSnakeCaseNameConverter(),
93
                    null,
94
                    new ReflectionExtractor(),
95
                    $discriminator
96
                ),
97
                new ArrayDenormalizer()
98
            ],
99
            [
100
                new JsonEncoder()
101
            ]
102
        );
103
    }
104
105
    /**
106
     * @param array $requests
107
     * @param callable|null $onFulfilled
108
     * @param callable|null $onRejected
109
     * @return PromiseInterface[]
110
     * @throws ExceptionInterface
111
     */
112
    public function sendMultipleRequests(
113
        array $requests,
114
        ?callable $onFulfilled = null,
115
        ?callable $onRejected = null
116
    ): array {
117
        $result = [];
118
        foreach ($requests as $request) {
119
            $result[] = $this->sendRequest($request['method'], $request['args'])
120
                ->then($onFulfilled, $onRejected);
121
        }
122
        return $result;
123
    }
124
125
    /**
126
     * @throws ExceptionInterface
127
     */
128
    private function buildMultipartArgs(array $args): array
129
    {
130
        $result = [];
131
        $cleanInputMedia = function () {
132
            unset($this->inputMedia);
133
            unset($this->multipart);
134
        };
135
        foreach ($args as $index => $arg) {
136
            if ('media' == $index and is_array($arg)) {
137
                /** @var InputMedia $inputMedia */
138
                foreach ($arg as $inputMedia) {
139
                    if ($inputMedia->isMultipart()) {
140
                        $result[] = [
141
                            'name' => $inputMedia->media,
142
                            'contents' => (string)$inputMedia->getInputMedia(),
143
                            'filename' => $inputMedia->getFilename()
144
                        ];
145
                        $inputMedia->media = 'attach://' . $inputMedia->media;
146
                        $cleanInputMedia->call($inputMedia);
147
                    }
148
                }
149
            }
150
            if (($arg instanceof InputMedia) and $arg->isMultipart()) {
151
                $result[] = [
152
                    'name' => $arg->media,
153
                    'contents' => (string)$arg->getInputMedia(),
154
                    'filename' => $arg->getFilename()
155
                ];
156
                $arg->media = 'attach://' . $arg->media;
157
                $cleanInputMedia->call($arg);
158
            }
159
            $result[] = [
160
                'name' => $index,
161
                'contents' => $arg
162
            ];
163
        }
164
        $result = $this->serializer->normalize(
165
            $result,
166
            null,
167
            [AbstractObjectNormalizer::SKIP_NULL_VALUES => true]
168
        );
169
        foreach ($result as $index => $item) {
170
            if (is_array($item['contents'])) {
171
                $item['contents'] = $this->serializer->serialize(
172
                    $item['contents'],
173
                    'json',
174
                    [AbstractObjectNormalizer::SKIP_NULL_VALUES => true]
175
                );
176
                $result[$index] = $item;
177
            }
178
        }
179
        return $result;
180
    }
181
182
    /**
183
     * @param string $method
184
     * @param array $args
185
     * @return PromiseInterface
186
     * @throws ExceptionInterface
187
     */
188
    public function sendRequest(string $method, array $args): PromiseInterface
189
    {
190
        if (!empty($this->defaultArgs)) {
191
            $args = array_merge($this->defaultArgs, $args);
192
        }
193
        $useMultipart = false;
194
        foreach ($args as $index => $arg) {
195
            // I don't know why, but the Serializer sometimes freaks out when trying to convert camelCase to snake_case
196
            // So, I'm doing it myself
197
            $normalizedIndex = NameNormalizer::camelCaseToSnakeCase($index);
198
            unset($args[$index]);
199
            $args[$normalizedIndex] = $arg;
200
            if (($arg instanceof InputFile or $arg instanceof InputMedia) and $arg->isMultipart()) {
201
                $useMultipart = true;
202
            }
203
            if ('media' == $index and is_array($arg)) {
204
                /** @var InputMedia $media */
205
                foreach ($arg as $media) {
206
                    if ($media->isMultipart()) {
207
                        $useMultipart = true;
208
                    }
209
                }
210
            }
211
        }
212
        $promise = null;
213
        if ($useMultipart) {
214
            $args = $this->buildMultipartArgs($args);
215
            $promise = $this->client->postAsync(
216
                $method,
217
                [
218
                    'multipart' => $args,
219
                    'headers' => ['Connection' => 'Keep-Alive', 'Keep-Alive' => '120'],
220
                    'http_errors' => false
221
                ]
222
            );
223
        }
224
        $promise ??= $this->client->postAsync(
225
            $method,
226
            [
227
                'body' => $this->serializer->serialize(
228
                    (object)$args,
229
                    'json',
230
                    [AbstractObjectNormalizer::SKIP_NULL_VALUES => true]
231
                ),
232
                'headers' => [
233
                    'Connection' => 'Keep-Alive',
234
                    'Keep-Alive' => '120',
235
                    'Content-Type' => 'application/json'
236
                ],
237
                'http_errors' => false
238
            ]
239
        );
240
        $api = $this;
241
        return $promise->then(
242
            function (ResponseInterface $webResponse) use ($api, $method) {
243
                $data = json_decode($webResponse->getBody());
244
                $response = (new Response($api, $method))
245
                    ->setOk($data->ok)
246
                    ->setResult($data->result ?? null)
247
                    ->setErrorCode($data->error_code ?? null)
248
                    ->setDescription($data->description ?? null)
249
                    ->setParameters($data->parameters ?? null);
250
                if (!$response->ok) {
251
                    match ($response->errorCode) {
252
                        400 => throw new TelegramBadRequestException($response->description),
253
                        401 => throw new TelegramUnauthorizedException('Token invalid or expired'),
254
                        403 => throw new TelegramForbiddenException($response->description),
255
                        404 => throw new TelegramNotFoundException($response->description),
256
                        409 => throw new TelegramConflictException($response->description),
257
                        429 => throw new TelegramTooManyRequestsException($response->description),
258
                        500, 502, 504 => throw new TelegramServerException(
259
                            'Bot API is encountering some issues, try again later: ' . ($response->description ?? 'Unknown.')
260
                        ),
261
                        default => throw new TelegramAPIException(
262
                            sprintf(
263
                                'Telegram API returned an error (%s): %s',
264
                                $response->errorCode ?? 'Unknown',
265
                                $response->description ?? 'Unknown'
266
                            )
267
                        )
268
                    };
269
                }
270
                return $response->result;
271
            }
272
        );
273
    }
274
275
    /**
276
     * @param string $method
277
     * @param array $args
278
     * @return mixed
279
     * @throws ExceptionInterface
280
     */
281
    public function sendBlockingRequest(string $method, array $args): mixed
282
    {
283
        return $this->sendRequest($method, $args)->wait();
284
    }
285
286
    /**
287
     * @return Serializer
288
     */
289
    public function getSerializer(): Serializer
290
    {
291
        return $this->serializer;
292
    }
293
294
    /**
295
     * @return Generator
296
     */
297
    public function iterUpdates(): Generator
298
    {
299
        $timeout = 0 == $this->timeout ? 30 : (int)$this->timeout;
300
        $offset = 0;
301
        $args = [
302
            AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
303
            AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true
304
        ];
305
        $args += ApiClasses::buildArgs($this);
306
        $data = file_get_contents('php://input');
307
        if (!empty($data) and is_string($data)) {
308
            yield $this->serializer->deserialize(
309
                $data,
310
                Update::class,
311
                'json',
312
                $args
313
            );
314
            return;
315
        }
316
        while (true) {
317
            $updates = $this->getUpdates(
318
                offset:         $offset,
319
                timeout:        $timeout,
320
                allowedUpdates: $this->allowedUpdates
321
            )->wait();
322
            /** @var Update $update */
323
            foreach ($updates as $update) {
324
                yield $update;
325
            }
326
            $offset = empty($update->updateId) ? $offset : $update->updateId + 1;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $update does not seem to be defined for all execution paths leading up to this point.
Loading history...
327
        }
328
    }
329
330
    /**
331
     * @return PromiseInterface
332
     */
333
    public function dropUpdates(): PromiseInterface
334
    {
335
        /** @var WebhookInfo $webhookInfo */
336
        $webhookInfo = $this->getWebhookInfo()->wait();
337
        if (!empty($webhookInfo->url)) {
338
            return $this->setWebhook(
339
                url:                $webhookInfo->url,
340
                ipAddress:          $webhookInfo->ipAddress,
341
                maxConnections:     $webhookInfo->maxConnections,
342
                allowedUpdates:     $webhookInfo->allowedUpdates,
343
                dropPendingUpdates: true
344
            );
345
        }
346
        return $this->getUpdates(offset: -1);
347
    }
348
349
    /**
350
     * Note: 8191 automatically enables all available types (chat_member included)
351
     *
352
     * @param int $allowedUpdates
353
     * @return $this
354
     */
355
    public function setAllowedUpdates(int $allowedUpdates = 8191): self
356
    {
357
        $types = UpdateTypes::getAllowedTypes($allowedUpdates);
358
        /** @var WebhookInfo $webhookInfo */
359
        $webhookInfo = $this->getWebhookInfo()->wait();
360
        if (!empty($webhookInfo->url)) {
361
            $this->setWebhook(
362
                url:            $webhookInfo->url,
363
                ipAddress:      $webhookInfo->ipAddress,
364
                maxConnections: $webhookInfo->maxConnections,
365
                allowedUpdates: $types,
366
            )->wait();
367
        }
368
        $this->allowedUpdates = $types;
369
        return $this;
370
    }
371
372
}
373