From e223f8e7982900b90a077f421d2f10162b058933 Mon Sep 17 00:00:00 2001 From: Yuriy Vilks Date: Fri, 22 Sep 2023 03:50:13 +0300 Subject: [PATCH] [Notifier] [Telegram] Extend options for `location`, `document`, `audio`, `video`, `venue`, `photo`, `animation`, `sticker` & `contact` --- .../Notifier/Bridge/Telegram/CHANGELOG.md | 6 + .../Notifier/Bridge/Telegram/README.md | 180 +++- .../Bridge/Telegram/TelegramOptions.php | 158 ++++ .../Bridge/Telegram/TelegramTransport.php | 88 +- .../Telegram/Tests/TelegramTransportTest.php | 869 +++++++++++++++++- .../Bridge/Telegram/Tests/fixtures.png | Bin 0 -> 70 bytes .../MultipleExclusiveOptionsUsedException.php | 32 + 7 files changed, 1305 insertions(+), 28 deletions(-) create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/Tests/fixtures.png create mode 100644 src/Symfony/Component/Notifier/Exception/MultipleExclusiveOptionsUsedException.php diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md index 760d2bb44036a..749784f093a7e 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.4 +--- + + * Add support for `sendLocation`, `sendAudio`, `sendDocument`, `sendVideo`, `sendAnimation`, `sendVenue`, `sendContact` and `sendSticker` API methods + * Add support for sending local files + 6.3 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/README.md b/src/Symfony/Component/Notifier/Bridge/Telegram/README.md index 333b536c454a2..f2bf849a66e2b 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/README.md @@ -47,15 +47,109 @@ $chatMessage->options($telegramOptions); $chatter->send($chatMessage); ``` -Adding Photo to a Message +Adding files to a Message ------------------------- With a Telegram message, you can use the `TelegramOptions` class to add [message options](https://core.telegram.org/bots/api). +> :warning: **WARNING** +In one message you can send only one file + +[Telegram supports 3 ways](https://core.telegram.org/bots/api#sending-files) for passing files: + + * You can send files by passing public http url to option: + * Photo + ```php + $telegramOptions = (new TelegramOptions()) + ->photo('https://localhost/photo.mp4'); + ``` + * Video + ```php + $telegramOptions = (new TelegramOptions()) + ->video('https://localhost/video.mp4'); + ``` + * Animation + ```php + $telegramOptions = (new TelegramOptions()) + ->animation('https://localhost/animation.gif'); + ``` + * Audio + ```php + $telegramOptions = (new TelegramOptions()) + ->audio('https://localhost/audio.ogg'); + ``` + * Document + ```php + $telegramOptions = (new TelegramOptions()) + ->document('https://localhost/document.odt'); + ``` + * Sticker + ```php + $telegramOptions = (new TelegramOptions()) + ->sticker('https://localhost/sticker.webp', '🤖'); + ``` + * You can send files by passing local path to option, in this case file will be sent via multipart/form-data: + * Photo + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadPhoto('files/photo.png'); + ``` + * Video + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadVideo('files/video.mp4'); + ``` + * Animation + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadAnimation('files/animation.gif'); + ``` + * Audio + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadAudio('files/audio.ogg'); + ``` + * Document + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadDocument('files/document.odt'); + ``` + * Sticker + ```php + $telegramOptions = (new TelegramOptions()) + ->uploadSticker('files/sticker.webp', '🤖'); + ``` + * You can send files by passing file_id to option: + * Photo + ```php + $telegramOptions = (new TelegramOptions()) + ->photo('ABCDEF'); + ``` + * Video + ```php + $telegramOptions = (new TelegramOptions()) + ->video('ABCDEF'); + ``` + * Animation + ```php + $telegramOptions = (new TelegramOptions()) + ->animation('ABCDEF'); + ``` + * Audio + ```php + $telegramOptions = (new TelegramOptions()) + ->audio('ABCDEF'); + ``` + * Document + ```php + $telegramOptions = (new TelegramOptions()) + ->document('ABCDEF'); + ``` + * Sticker - *Can't be sent using file_id* + +Full example: ```php -use Symfony\Component\Notifier\Bridge\Telegram\Reply\Markup\Button\InlineKeyboardButton; -use Symfony\Component\Notifier\Bridge\Telegram\Reply\Markup\InlineKeyboardMarkup; use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions; use Symfony\Component\Notifier\Message\ChatMessage; @@ -76,6 +170,86 @@ $chatMessage->options($telegramOptions); $chatter->send($chatMessage); ``` +Adding Location to a Message +---------------------------- + +With a Telegram message, you can use the `TelegramOptions` class to add +[message options](https://core.telegram.org/bots/api). + +```php +use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions; +use Symfony\Component\Notifier\Message\ChatMessage; + +$chatMessage = new ChatMessage(''); + +// Create Telegram options +$telegramOptions = (new TelegramOptions()) + ->chatId('@symfonynotifierdev') + ->parseMode('MarkdownV2') + ->location(48.8566, 2.3522); + +// Add the custom options to the chat message and send the message +$chatMessage->options($telegramOptions); + +$chatter->send($chatMessage); +``` + +Adding Venue to a Message +---------------------------- + +With a Telegram message, you can use the `TelegramOptions` class to add +[message options](https://core.telegram.org/bots/api). + +```php +use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions; +use Symfony\Component\Notifier\Message\ChatMessage; + +$chatMessage = new ChatMessage(''); + +// Create Telegram options +$telegramOptions = (new TelegramOptions()) + ->chatId('@symfonynotifierdev') + ->parseMode('MarkdownV2') + ->venue(48.8566, 2.3522, 'Center of Paris', 'France, Paris'); + +// Add the custom options to the chat message and send the message +$chatMessage->options($telegramOptions); + +$chatter->send($chatMessage); +``` + +Adding Contact to a Message +---------------------------- + +With a Telegram message, you can use the `TelegramOptions` class to add +[message options](https://core.telegram.org/bots/api). + +```php +use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions; +use Symfony\Component\Notifier\Message\ChatMessage; + +$chatMessage = new ChatMessage(''); + +$vCard = 'BEGIN:VCARD +VERSION:3.0 +N:Doe;John;;; +FN:John Doe +EMAIL;type=INTERNET;type=WORK;type=pref:johnDoe@example.org +TEL;type=WORK;type=pref:+330186657200 +END:VCARD'; + +// Create Telegram options +$telegramOptions = (new TelegramOptions()) + ->chatId('@symfonynotifierdev') + ->parseMode('MarkdownV2') + ->contact('+330186657200', 'John', 'Doe', $vCard); + +// Add the custom options to the chat message and send the message +$chatMessage->options($telegramOptions); + +$chatter->send($chatMessage); +``` + Updating Messages ----------------- diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php index f794620de4a00..86601172ef946 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramOptions.php @@ -112,6 +112,16 @@ public function photo(string $url): static return $this; } + /** + * @return $this + */ + public function uploadPhoto(string $path): static + { + $this->options['upload']['photo'] = $path; + + return $this; + } + /** * @return $this */ @@ -156,4 +166,152 @@ public function answerCallbackQuery(string $callbackQueryId, bool $showAlert = f return $this; } + + /** + * @return $this + */ + public function location(float $latitude, float $longitude): static + { + $this->options['location'] = ['latitude' => $latitude, 'longitude' => $longitude]; + + return $this; + } + + /** + * @return $this + */ + public function venue(float $latitude, float $longitude, string $title, string $address): static + { + $this->options['venue'] = [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'title' => $title, + 'address' => $address, + ]; + + return $this; + } + + /** + * @return $this + */ + public function document(string $url): static + { + $this->options['document'] = $url; + + return $this; + } + + /** + * @return $this + */ + public function uploadDocument(string $path): static + { + $this->options['upload']['document'] = $path; + + return $this; + } + + /** + * @return $this + */ + public function video(string $url): static + { + $this->options['video'] = $url; + + return $this; + } + + /** + * @return $this + */ + public function uploadVideo(string $path): static + { + $this->options['upload']['video'] = $path; + + return $this; + } + + /** + * @return $this + */ + public function audio(string $url): static + { + $this->options['audio'] = $url; + + return $this; + } + + /** + * @return $this + */ + public function uploadAudio(string $path): static + { + $this->options['upload']['audio'] = $path; + + return $this; + } + + /** + * @return $this + */ + public function animation(string $url): static + { + $this->options['animation'] = $url; + + return $this; + } + + /** + * @return $this + */ + public function uploadAnimation(string $path): static + { + $this->options['upload']['animation'] = $path; + + return $this; + } + + /** + * @return $this + */ + public function sticker(string $url, string $emoji = null): static + { + $this->options['sticker'] = $url; + $this->options['emoji'] = $emoji; + + return $this; + } + + /** + * @return $this + */ + public function uploadSticker(string $path, string $emoji = null): static + { + $this->options['upload']['sticker'] = $path; + $this->options['emoji'] = $emoji; + + return $this; + } + + /** + * @return $this + */ + public function contact(string $phoneNumber, string $firstName, string $lastName = null, string $vCard = null): static + { + $this->options['contact'] = [ + 'phone_number' => $phoneNumber, + 'first_name' => $firstName, + ]; + + if (null !== $lastName) { + $this->options['contact']['last_name'] = $lastName; + } + + if (null !== $vCard) { + $this->options['contact']['vcard'] = $vCard; + } + + return $this; + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php index af7a31b38f559..96e6cc86581aa 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Notifier\Bridge\Telegram; +use Symfony\Component\Notifier\Exception\MultipleExclusiveOptionsUsedException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; @@ -35,6 +36,20 @@ final class TelegramTransport extends AbstractTransport private string $token; private ?string $chatChannel; + public const EXCLUSIVE_OPTIONS = [ + 'message_id', + 'callback_query_id', + 'photo', + 'location', + 'audio', + 'document', + 'video', + 'animation', + 'venue', + 'contact', + 'sticker', + ]; + public function __construct(#[\SensitiveParameter] string $token, string $channel = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) { $this->token = $token; @@ -68,23 +83,35 @@ protected function doSend(MessageInterface $message): SentMessage } $options = $message->getOptions()?->toArray() ?? []; + $optionsContainer = 'json'; $options['chat_id'] ??= $message->getRecipientId() ?: $this->chatChannel; - $options['text'] = $message->getSubject(); + $text = $message->getSubject(); if (!isset($options['parse_mode']) || TelegramOptions::PARSE_MODE_MARKDOWN_V2 === $options['parse_mode']) { $options['parse_mode'] = TelegramOptions::PARSE_MODE_MARKDOWN_V2; - $options['text'] = preg_replace('/([_*\[\]()~`>#+\-=|{}.!\\\\])/', '\\\\$1', $message->getSubject()); + $text = preg_replace('/([_*\[\]()~`>#+\-=|{}.!\\\\])/', '\\\\$1', $text); + } + + if (isset($options['upload'])) { + foreach ($options['upload'] as $option => $path) { + $options[$option] = fopen($path, 'r'); + } + $optionsContainer = 'body'; + unset($options['upload']); } - if (isset($options['photo'])) { - $options['caption'] = $options['text']; - unset($options['text']); + $messageOption = $this->getTextOption($options); + if (null !== $messageOption) { + $options[$messageOption] = $text; } + $method = $this->getPath($options); + $this->ensureExclusiveOptionsNotDuplicated($options); + $options = $this->expandOptions($options, 'contact', 'location', 'venue'); - $endpoint = sprintf('https://%s/bot%s/%s', $this->getEndpoint(), $this->token, $this->getPath($options)); + $endpoint = sprintf('https://%s/bot%s/%s', $this->getEndpoint(), $this->token, $method); $response = $this->client->request('POST', $endpoint, [ - 'json' => array_filter($options), + $optionsContainer => array_filter($options), ]); try { @@ -115,6 +142,14 @@ private function getPath(array $options): string isset($options['message_id']) => 'editMessageText', isset($options['callback_query_id']) => 'answerCallbackQuery', isset($options['photo']) => 'sendPhoto', + isset($options['location']) => 'sendLocation', + isset($options['audio']) => 'sendAudio', + isset($options['document']) => 'sendDocument', + isset($options['video']) => 'sendVideo', + isset($options['animation']) => 'sendAnimation', + isset($options['venue']) => 'sendVenue', + isset($options['contact']) => 'sendContact', + isset($options['sticker']) => 'sendSticker', default => 'sendMessage', }; } @@ -127,4 +162,43 @@ private function getAction(array $options): string default => 'post', }; } + + private function getTextOption(array $options): ?string + { + return match (true) { + isset($options['photo']) => 'caption', + isset($options['audio']) => 'caption', + isset($options['document']) => 'caption', + isset($options['video']) => 'caption', + isset($options['animation']) => 'caption', + isset($options['sticker']) => null, + isset($options['location']) => null, + isset($options['venue']) => null, + isset($options['contact']) => null, + default => 'text', + }; + } + + private function expandOptions(array $options, string ...$optionsForExpand): array + { + foreach ($optionsForExpand as $optionForExpand) { + if (isset($options[$optionForExpand])) { + if (\is_array($options[$optionForExpand])) { + $options = array_merge($options, $options[$optionForExpand]); + } + unset($options[$optionForExpand]); + } + } + + return $options; + } + + private function ensureExclusiveOptionsNotDuplicated(array $options): void + { + $usedOptions = array_keys($options); + $usedExclusiveOptions = array_intersect($usedOptions, self::EXCLUSIVE_OPTIONS); + if (\count($usedExclusiveOptions) > 1) { + throw new MultipleExclusiveOptionsUsedException($usedExclusiveOptions, self::EXCLUSIVE_OPTIONS); + } + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/TelegramTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/TelegramTransportTest.php index 0ea7ed6e95175..497c0c957f5cb 100644 --- a/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/TelegramTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/TelegramTransportTest.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions; use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransport; +use Symfony\Component\Notifier\Exception\MultipleExclusiveOptionsUsedException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Message\ChatMessage; use Symfony\Component\Notifier\Message\SmsMessage; @@ -129,7 +130,7 @@ public function testSendWithOptions() $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { $this->assertStringEndsWith('/sendMessage', $url); - $this->assertSame($expectedBody, json_decode($options['body'], true)); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); return $response; }); @@ -263,7 +264,7 @@ public function testSendWithChannelOverride() ]; $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { - $this->assertSame($expectedBody, json_decode($options['body'], true)); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); return $response; }); @@ -321,7 +322,7 @@ public function testSendWithMarkdownShouldEscapeSpecialCharacters() ]; $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { - $this->assertSame($expectedBody, json_decode($options['body'], true)); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); return $response; }); @@ -331,8 +332,176 @@ public function testSendWithMarkdownShouldEscapeSpecialCharacters() $transport->send(new ChatMessage('I contain special characters _ * [ ] ( ) ~ ` > # + - = | { } . ! \\ to send.')); } - public function testSendPhotoWithOptions() + public static function sendFileByHttpUrlProvider(): array { + return [ + 'photo' => [ + 'messageOptions' => (new TelegramOptions())->photo('https://localhost/photo.png')->hasSpoiler(true), + 'endpoint' => 'sendPhoto', + 'expectedBody' => [ + 'photo' => 'https://localhost/photo.png', + 'has_spoiler' => true, + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->video('https://localhost/video.mp4'), + 'endpoint' => 'sendVideo', + 'expectedBody' => [ + 'video' => 'https://localhost/video.mp4', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->animation('https://localhost/animation.gif'), + 'endpoint' => 'sendAnimation', + 'expectedBody' => [ + 'animation' => 'https://localhost/animation.gif', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->audio('https://localhost/audio.ogg'), + 'endpoint' => 'sendAudio', + 'expectedBody' => [ + 'audio' => 'https://localhost/audio.ogg', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->document('https://localhost/document.odt'), + 'endpoint' => 'sendDocument', + 'expectedBody' => [ + 'document' => 'https://localhost/document.odt', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->sticker('https://localhost/sticker.webp', '🤖'), + 'endpoint' => 'sendSticker', + 'expectedBody' => [ + 'sticker' => 'https://localhost/sticker.webp', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'emoji' => '🤖', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->sticker('https://localhost/sticker.webp'), + 'endpoint' => 'sendSticker', + 'expectedBody' => [ + 'sticker' => 'https://localhost/sticker.webp', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + ], + 'responseContent' => <<createMock(ResponseInterface::class); $response->expects($this->exactly(2)) ->method('getStatusCode') @@ -357,6 +526,44 @@ public function testSendPhotoWithOptions() "type": "private" }, "date": 1459958199, + $responseContent + } + } +JSON; + + $response->expects($this->once()) + ->method('getContent') + ->willReturn($content) + ; + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody, $endpoint): ResponseInterface { + $this->assertStringEndsWith($endpoint, $url); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); + + return $response; + }); + + $transport = self::createTransport($client, 'testChannel'); + $sentMessage = $transport->send(new ChatMessage('testMessage', $messageOptions)); + + $this->assertEquals(1, $sentMessage->getMessageId()); + $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); + } + + public static function sendFileByFileIdProvider(): array + { + return [ + 'photo' => [ + 'messageOptions' => (new TelegramOptions())->photo('ABCDEF')->hasSpoiler(true), + 'endpoint' => 'sendPhoto', + 'expectedBody' => [ + 'photo' => 'ABCDEF', + 'has_spoiler' => true, + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->video('ABCDEF'), + 'endpoint' => 'sendVideo', + 'expectedBody' => [ + 'video' => 'ABCDEF', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->animation('ABCDEF'), + 'endpoint' => 'sendAnimation', + 'expectedBody' => [ + 'animation' => 'ABCDEF', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->audio('ABCDEF'), + 'endpoint' => 'sendAudio', + 'expectedBody' => [ + 'audio' => 'ABCDEF', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->document('ABCDEF'), + 'endpoint' => 'sendDocument', + 'expectedBody' => [ + 'document' => 'ABCDEF', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'caption' => 'testMessage', + ], + 'responseContent' => <<createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $content = <<expects($this->once()) + ->method('getContent') + ->willReturn($content) + ; + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody, $endpoint): ResponseInterface { + $this->assertStringEndsWith($endpoint, $url); + $this->assertSame($expectedBody, json_decode($options['body'], true)); + + return $response; + }); + + $transport = self::createTransport($client, 'testChannel'); + $sentMessage = $transport->send(new ChatMessage('testMessage', $messageOptions)); + + $this->assertEquals(1, $sentMessage->getMessageId()); + $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); + } + + private const FIXTURE_FILE = __DIR__.'/fixtures.png'; + + public static function sendFileByUploadProvider(): array + { + return [ + 'photo' => [ + 'messageOptions' => (new TelegramOptions())->uploadPhoto(self::FIXTURE_FILE)->hasSpoiler(true), + 'endpoint' => 'sendPhoto', + 'fileOption' => 'photo', + 'expectedBody' => [ + 'has_spoiler' => true, + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'photo' => self::FIXTURE_FILE, + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadVideo(self::FIXTURE_FILE), + 'endpoint' => 'sendVideo', + 'fileOption' => 'video', + 'expectedBody' => [ + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'video' => self::FIXTURE_FILE, + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadAnimation(self::FIXTURE_FILE), + 'endpoint' => 'sendAnimation', + 'fileOption' => 'animation', + 'expectedBody' => [ + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'animation' => self::FIXTURE_FILE, + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadAudio(self::FIXTURE_FILE), + 'endpoint' => 'sendAudio', + 'fileOption' => 'audio', + 'expectedBody' => [ + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'audio' => self::FIXTURE_FILE, + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadDocument(self::FIXTURE_FILE), + 'endpoint' => 'sendDocument', + 'fileOption' => 'document', + 'expectedBody' => [ + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'document' => self::FIXTURE_FILE, + 'caption' => 'testMessage', + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadSticker(self::FIXTURE_FILE, '🤖'), + 'endpoint' => 'sendSticker', + 'fileOption' => 'sticker', + 'expectedBody' => [ + 'emoji' => '🤖', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'sticker' => self::FIXTURE_FILE, + ], + 'responseContent' => << [ + 'messageOptions' => (new TelegramOptions())->uploadSticker(self::FIXTURE_FILE), + 'endpoint' => 'sendSticker', + 'fileOption' => 'sticker', + 'expectedBody' => [ + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + 'sticker' => self::FIXTURE_FILE, + ], + 'responseContent' => <<createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $content = <<expects($this->once()) + ->method('getContent') + ->willReturn($content) + ; + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedParameters, $fileOption, $endpoint): ResponseInterface { + $this->assertStringEndsWith($endpoint, $url); + $this->assertSame(1, preg_match('/^Content-Type: multipart\/form-data; boundary=(?.+)$/', $options['normalized_headers']['content-type'][0], $matches)); + + $expectedBody = ''; + foreach ($expectedParameters as $key => $value) { + if (\is_bool($value)) { + if (!$value) { + continue; + } + $value = 1; + } + if ($key === $fileOption) { + $expectedBody .= <<assertSame($expectedBody, $body); + + return $response; + }); + + $transport = self::createTransport($client, 'testChannel'); + $sentMessage = $transport->send(new ChatMessage('testMessage', $messageOptions)); + + $this->assertEquals(1, $sentMessage->getMessageId()); + $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); + } + + public function testSendLocationWithOptions() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $content = << 'https://image.ur.l/', - 'has_spoiler' => true, + 'latitude' => 48.8566, + 'longitude' => 2.3522, 'chat_id' => 'testChannel', 'parse_mode' => 'MarkdownV2', - 'caption' => 'testMessage', ]; $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { - $this->assertStringEndsWith('/sendPhoto', $url); - $this->assertSame($expectedBody, json_decode($options['body'], true)); + $this->assertStringEndsWith('/sendLocation', $url); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); return $response; }); @@ -402,13 +1043,205 @@ public function testSendPhotoWithOptions() $messageOptions = new TelegramOptions(); $messageOptions - ->photo('https://image.ur.l/') - ->hasSpoiler(true) + ->location(48.8566, 2.3522) ; - $sentMessage = $transport->send(new ChatMessage('testMessage', $messageOptions)); + $sentMessage = $transport->send(new ChatMessage('', $messageOptions)); $this->assertEquals(1, $sentMessage->getMessageId()); $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); } + + public function testSendVenueWithOptions() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $content = <<expects($this->once()) + ->method('getContent') + ->willReturn($content) + ; + + $expectedBody = [ + 'latitude' => 48.8566, + 'longitude' => 2.3522, + 'title' => 'Center of Paris', + 'address' => 'France, Paris', + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + ]; + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { + $this->assertStringEndsWith('/sendVenue', $url); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); + + return $response; + }); + + $transport = self::createTransport($client, 'testChannel'); + + $messageOptions = new TelegramOptions(); + $messageOptions + ->venue(48.8566, 2.3522, 'Center of Paris', 'France, Paris') + ; + + $sentMessage = $transport->send(new ChatMessage('', $messageOptions)); + + $this->assertEquals(1, $sentMessage->getMessageId()); + $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); + } + + public function testSendContactWithOptions() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $vCard = <<expects($this->once()) + ->method('getContent') + ->willReturn($content) + ; + + $expectedBody = [ + 'phone_number' => '+330186657200', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'vcard' => $vCard, + 'chat_id' => 'testChannel', + 'parse_mode' => 'MarkdownV2', + ]; + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response, $expectedBody): ResponseInterface { + $this->assertStringEndsWith('/sendContact', $url); + $this->assertEqualsCanonicalizing($expectedBody, json_decode($options['body'], true)); + + return $response; + }); + + $transport = self::createTransport($client, 'testChannel'); + + $messageOptions = new TelegramOptions(); + $messageOptions + ->contact('+330186657200', 'John', 'Doe', $vCard) + ; + + $sentMessage = $transport->send(new ChatMessage('', $messageOptions)); + + $this->assertEquals(1, $sentMessage->getMessageId()); + $this->assertEquals('telegram://api.telegram.org?channel=testChannel', $sentMessage->getTransport()); + } + + public static function exclusiveOptionsDataProvider(): array + { + return [ + 'edit' => [(new TelegramOptions())->edit(1)->video('')], + 'answerCallbackQuery' => [(new TelegramOptions())->answerCallbackQuery('')->video('')], + 'photo' => [(new TelegramOptions())->photo('')->video('')], + 'location' => [(new TelegramOptions())->location(48.8566, 2.3522)->video('')], + 'audio' => [(new TelegramOptions())->audio('')->video('')], + 'document' => [(new TelegramOptions())->document('')->video('')], + 'video' => [(new TelegramOptions())->video('')->animation('')], + 'animation' => [(new TelegramOptions())->animation('')->video('')], + 'venue' => [(new TelegramOptions())->venue(48.8566, 2.3522, '', '')->video('')], + 'contact' => [(new TelegramOptions())->contact('', '')->video('')], + 'sticker' => [(new TelegramOptions())->sticker('')->video('')], + 'uploadPhoto' => [(new TelegramOptions())->uploadPhoto(self::FIXTURE_FILE)->video('')], + 'uploadAudio' => [(new TelegramOptions())->uploadAudio(self::FIXTURE_FILE)->video('')], + 'uploadDocument' => [(new TelegramOptions())->uploadDocument(self::FIXTURE_FILE)->video('')], + 'uploadVideo' => [(new TelegramOptions())->uploadVideo(self::FIXTURE_FILE)->animation('')], + 'uploadAnimation' => [(new TelegramOptions())->uploadAnimation(self::FIXTURE_FILE)->video('')], + 'uploadSticker' => [(new TelegramOptions())->uploadSticker(self::FIXTURE_FILE)->video('')], + ]; + } + + /** + * @dataProvider exclusiveOptionsDataProvider + */ + public function testUsingMultipleExclusiveOptionsWillProvideExceptions(TelegramOptions $messageOptions) + { + $client = new MockHttpClient(function (string $method, string $url, array $options = []): ResponseInterface { + self::fail('Telegram API should not be called'); + }); + $transport = self::createTransport($client, 'testChannel'); + + $this->expectException(MultipleExclusiveOptionsUsedException::class); + $sentMessage = $transport->send(new ChatMessage('', $messageOptions)); + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/fixtures.png b/src/Symfony/Component/Notifier/Bridge/Telegram/Tests/fixtures.png new file mode 100644 index 0000000000000000000000000000000000000000..08cd6f2bfd1b53ec5a4db72bed55f40907e8bdfa GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8JuI3K{zz}&{z5M@%E Q4U}N;boFyt=akR{0J + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +/** + * @author Yuriy Vilks + */ +class MultipleExclusiveOptionsUsedException extends InvalidArgumentException +{ + /** + * @param string[] $usedExclusiveOptions + * @param string[]|null $exclusiveOptions + */ + public function __construct(array $usedExclusiveOptions, array $exclusiveOptions = null, \Throwable $previous = null) + { + $message = sprintf('Multiple exclusive options have been used "%s".', implode('", "', $usedExclusiveOptions)); + if (null !== $exclusiveOptions) { + $message .= sprintf(' Only one of %s can be used.', implode('", "', $exclusiveOptions)); + } + + parent::__construct($message, 0, $previous); + } +}