From b1a25ae139ade008a6baa18d2065866e50a63931 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Wed, 20 Mar 2024 19:51:25 +0400 Subject: [PATCH] [Notifier] LOX24 SMS bridge --- .../FrameworkExtension.php | 1 + .../Resources/config/notifier_transports.php | 1 + .../Notifier/Bridge/Lox24/.gitattributes | 4 + .../Notifier/Bridge/Lox24/.gitignore | 3 + .../Notifier/Bridge/Lox24/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Lox24/LICENSE | 19 ++ .../Notifier/Bridge/Lox24/Lox24Options.php | 88 +++++ .../Notifier/Bridge/Lox24/Lox24Transport.php | 190 +++++++++++ .../Bridge/Lox24/Lox24TransportFactory.php | 53 +++ .../Component/Notifier/Bridge/Lox24/README.md | 73 ++++ .../Bridge/Lox24/Tests/Lox24OptionsTest.php | 77 +++++ .../Lox24/Tests/Lox24TransportFactoryTest.php | 58 ++++ .../Bridge/Lox24/Tests/Lox24TransportTest.php | 321 ++++++++++++++++++ .../Tests/Webhook/Lox24RequestParserTest.php | 113 ++++++ .../Component/Notifier/Bridge/Lox24/Type.php | 26 ++ .../Notifier/Bridge/Lox24/VoiceLanguage.php | 22 ++ .../Lox24/Webhook/LOX24RequestParser.php | 60 ++++ .../Notifier/Bridge/Lox24/composer.json | 32 ++ .../Notifier/Bridge/Lox24/phpunit.xml.dist | 31 ++ .../Exception/UnsupportedSchemeException.php | 4 + .../UnsupportedSchemeExceptionTest.php | 2 + src/Symfony/Component/Notifier/Transport.php | 1 + 22 files changed, 1186 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Options.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Transport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Lox24TransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24OptionsTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Webhook/Lox24RequestParserTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Type.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/VoiceLanguage.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/Webhook/LOX24RequestParser.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Lox24/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 911242bc2830a..74a1c71ea6a4e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2788,6 +2788,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\LightSms\LightSmsTransportFactory::class => 'notifier.transport_factory.light-sms', NotifierBridge\LineNotify\LineNotifyTransportFactory::class => 'notifier.transport_factory.line-notify', NotifierBridge\LinkedIn\LinkedInTransportFactory::class => 'notifier.transport_factory.linked-in', + NotifierBridge\Lox24\Lox24TransportFactory::class => 'notifier.transport_factory.lox24', NotifierBridge\Mailjet\MailjetTransportFactory::class => 'notifier.transport_factory.mailjet', NotifierBridge\Mastodon\MastodonTransportFactory::class => 'notifier.transport_factory.mastodon', NotifierBridge\Mattermost\MattermostTransportFactory::class => 'notifier.transport_factory.mattermost', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index cebb923509db7..df9be94ed5e32 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -75,6 +75,7 @@ 'isendpro' => Bridge\Isendpro\IsendproTransportFactory::class, 'kaz-info-teh' => Bridge\KazInfoTeh\KazInfoTehTransportFactory::class, 'light-sms' => Bridge\LightSms\LightSmsTransportFactory::class, + 'lox24' => Bridge\Lox24\Lox24TransportFactory::class, 'mailjet' => Bridge\Mailjet\MailjetTransportFactory::class, 'message-bird' => Bridge\MessageBird\MessageBirdTransportFactory::class, 'message-media' => Bridge\MessageMedia\MessageMediaTransportFactory::class, diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Lox24/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/.gitignore b/src/Symfony/Component/Notifier/Bridge/Lox24/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Lox24/CHANGELOG.md new file mode 100644 index 0000000000000..ab7facf3a8b5c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge \ No newline at end of file diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/LICENSE b/src/Symfony/Component/Notifier/Bridge/Lox24/LICENSE new file mode 100644 index 0000000000000..d69ac7f2be525 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Options.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Options.php new file mode 100644 index 0000000000000..c45f0fc7721eb --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Options.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24; + +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +/** + * @author Andrei Lebedev + */ +final class Lox24Options implements MessageOptionsInterface +{ + public function __construct( + private array $options = [], + ) { + } + + public function toArray(): array + { + return $this->options; + } + + public function getRecipientId(): ?string + { + return null; + } + + /** + * DateTime object of SMS the delivery time. + * If Null or not set, the message will be sent immediately. + */ + public function deliveryAt(?\DateTimeInterface $deliveryAt): self + { + $this->options['delivery_at'] = $deliveryAt ? $deliveryAt->getTimestamp() : 0; + + return $this; + } + + /** + * The language of the voice message. + * If set 'auto', the automatic language detection by message text will be used. + */ + public function voiceLanguage(VoiceLanguage $language): self + { + if (VoiceLanguage::Auto === $language) { + unset($this->options['voice_lang']); + } else { + $this->options['voice_lang'] = $language->value; + } + + return $this; + } + + /** + * If True deletes the message from the LOX24 database after delivery. + */ + public function deleteTextAfterSending(bool $deleteText): self + { + $this->options['delete_text'] = $deleteText; + + return $this; + } + + public function type(Type $type): self + { + $this->options['type'] = $type->value; + + return $this; + } + + /** + * String which will be sent back to your endpoint. It can be usable to pass your system message id. + */ + public function callbackData(?string $data): self + { + $this->options['callback_data'] = $data; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Transport.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Transport.php new file mode 100644 index 0000000000000..31c71c8d9c6a6 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24Transport.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Andrei Lebedev + */ +final class Lox24Transport extends AbstractTransport +{ + protected const HOST = 'api.lox24.eu'; + + public function __construct( + private readonly string $user, + #[\SensitiveParameter] private readonly string $token, + private readonly string $from, + private readonly array $options = [], + ?HttpClientInterface $client = null, + ?EventDispatcherInterface $dispatcher = null, + ) { + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + $params = [ + 'from' => $this->from, + ...$this->options, + ]; + + $query = $params ? '?'.http_build_query($params) : ''; + + return sprintf('lox24://%s%s', $this->getEndpoint(), $query); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage + && (null === $message->getOptions() || $message->getOptions() instanceof Lox24Options); + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$this->supports($message)) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $from = $message->getFrom() ?: $this->from; + + if (!$this->isFromValid($from)) { + throw new InvalidArgumentException(sprintf('The "From" number "%s" is not a valid phone number, shortcode, or alphanumeric sender ID.', $from)); + } + + $body = [ + 'sender_id' => $from, + 'phone' => $message->getPhone(), + 'text' => $message->getSubject(), + ]; + + $options = $message->getOptions()?->toArray() ?? []; + $body = $this->setIsTextDeleted($body, $options); + $body = $this->setCallbackData($body, $options); + $body = $this->setDeliveryAt($body, $options); + $body = $this->setServiceCode($body, $options); + $body = $this->setVoiceLang($body, $options); + + $response = $this->client->request('POST', sprintf('https://%s/sms', $this->getEndpoint()), [ + 'headers' => [ + 'X-LOX24-AUTH-TOKEN' => sprintf('%s:%s', $this->user, $this->token), + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'User-Agent' => 'LOX24 Symfony Notifier', + ], + 'body' => $body, + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote LOX24 server.', $response, 0, $e); + } + + if (201 !== $statusCode) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send the SMS: "%s".', $error['detail']), $response); + } + + $success = $response->toArray(false); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($success['uuid']); + + return $sentMessage; + } + + private function isFromValid(string $from): bool + { + return preg_match('/^[.\-a-zA-Z0-9_ ]{2,11}$/', $from) || preg_match('/^\+[1-9]\d{1,14}$/', $from); + } + + private function setIsTextDeleted(array $body, array $options): array + { + $body['is_text_deleted'] = (bool) ($options['delete_text'] ?? false); + + return $body; + } + + private function setCallbackData(array $body, array $options): array + { + if (!empty($options['callback_data'])) { + $body['callback_data'] = $options['callback_data']; + } + + return $body; + } + + private function setDeliveryAt(array $body, array $options): array + { + $body['delivery_at'] = max((int) ($options['delivery_at'] ?? 0), 0); + + return $body; + } + + private function setServiceCode(array $body, array $options): array + { + $code = $options['type'] ?? Type::Sms->value; + + try { + $type = Type::from((string) $code); + } catch (\ValueError) { + throw new InvalidArgumentException(sprintf('Invalid type: "%s".', $code)); + } + + $body['service_code'] = $type->getServiceCode(); + + return $body; + } + + private function setVoiceLang(array $body, array $options): array + { + $voiceLang = $options['voice_lang'] ?? null; + if ($voiceLang) { + $voiceLang = strtoupper($voiceLang); + try { + $lang = VoiceLanguage::from($voiceLang); + } catch (\ValueError) { + $allowed = implode(', ', array_map(static fn ($case) => $case->value, VoiceLanguage::cases())); + $str = 'The "voice_lang" option "%s" is not a valid language. Allowed languages are: %s.'; + throw new InvalidArgumentException(sprintf($str, $voiceLang, $allowed)); + } + + if (VoiceLanguage::Auto !== $lang) { + $body['voice_lang'] = $lang->value; + } + } + + return $body; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24TransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24TransportFactory.php new file mode 100644 index 0000000000000..dc9c09adc6868 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Lox24TransportFactory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24; + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; + +/** + * @author Andrei Lebedev + */ +final class Lox24TransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): Lox24Transport + { + $scheme = $dsn->getScheme(); + + if (!\in_array($scheme, $this->getSupportedSchemes(), true)) { + throw new UnsupportedSchemeException($dsn, $scheme, $this->getSupportedSchemes()); + } + + $user = $this->getUser($dsn); + $token = $this->getPassword($dsn); + $from = $dsn->getRequiredOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new Lox24Transport($user, $token, $from, $dsn->getOptions(), $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['lox24']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/README.md b/src/Symfony/Component/Notifier/Bridge/Lox24/README.md new file mode 100644 index 0000000000000..8d84187fcf46a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/README.md @@ -0,0 +1,73 @@ +LOX24 SMS Notifier +================== + +Provides [LOX24 SMS Gateway](https://doc.lox24.eu/#tag/sms/operation/api_sms_post_collection) integration for Symfony +Notifier. + +DSN example +----------- + +``` +LOX24_DSN=lox24://USER:TOKEN@default?from=FROM&type=SERVICE_CODE&voice_lang=VOICE_LANGUAGE&delete_text=DELETE_TEXT&callback_data=CALLBACK_DATA +``` + +where: + + - `USER` (required) is LOX24 user ID. + - `TOKEN` (required) is LOX24 API v2 token. + - `FROM` (required) is the sender of the message. + - `TYPE` (optional) type of message: `sms` (by default) or `voice` (voice call). + - `VOICE_LANGUAGE` (optional) if `type` is `voice`, then you can set the language of the voice message. Possible + values: `de`, `en`, `es`, `fr`, `it` or `auto` (by default) per auto-detection. + - `DELETE_TEXT` (optional) delete SMS text from LOX24 database after sending SMS. Allowed values: `1` (true) or `0` ( + false). Default value: `0`. + - `CALLBACK_DATA` (optional) additional data for the callback payload. + +See your account info at https://account.lox24.eu + +Send a Message +-------------- + +```php +use Symfony\Component\Notifier\Message\SmsMessage; + +$sms = new SmsMessage('+1411111111', 'My message'); + +$texter->send($sms); +``` + +Advanced Message options +------------------------ + +```php +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Bridge\Lox24\Lox24Options; + +$sms = new SmsMessage('+1411111111', 'My message'); + +$options = (new Lox24Options()) + // set 'voice' per voice call (text-to-speech) + ->type('voice') + // set the language of the voice message. + // If not set or set 'auto', the automatic language detection by message text will be used + ->voiceLanguage('en') + // Date of the SMS delivery. If null or not set, the message will be sent immediately + ->deliveryAt(new DateTime('2024-03-21 12:17:00')) + // set True to delete the message from the LOX24 database after delivery + ->deleteTextAfterSending(true) + // pass any string to the callback object + ->callbackData('some_data_per_callback'); + +// Add the custom options to the sms message and send the message +$sms->options($options); + +$texter->send($sms); +``` + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24OptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24OptionsTest.php new file mode 100644 index 0000000000000..fbe870f3a1970 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24OptionsTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Lox24\Lox24Options; +use Symfony\Component\Notifier\Bridge\Lox24\Type; +use Symfony\Component\Notifier\Bridge\Lox24\VoiceLanguage; + +/** + * @author Andrei Lebedev + */ +class Lox24OptionsTest extends TestCase +{ + public function testDeliveryAtWithNotNull() + { + $options = (new Lox24Options())->deliveryAt((new DateTimeImmutable())->setTimestamp(123)); + $this->assertSame(123, $options->toArray()['delivery_at']); + } + + public function testDeliveryWithNull() + { + $options = (new Lox24Options())->deliveryAt(null); + $this->assertSame(0, $options->toArray()['delivery_at']); + } + + public function testVoiceLangAuto() + { + $options = (new Lox24Options())->voiceLanguage(VoiceLanguage::Auto); + $this->assertArrayNotHasKey('voice_lang', $options->toArray()); + } + + public function testVoiceLangValid() + { + $options = (new Lox24Options())->voiceLanguage(VoiceLanguage::English); + $this->assertSame('EN', $options->toArray()['voice_lang']); + } + + public function testTextDelete() + { + $options = (new Lox24Options())->deleteTextAfterSending(true); + $this->assertTrue($options->toArray()['delete_text']); + $options->deleteTextAfterSending(false); + $this->assertFalse($options->toArray()['delete_text']); + } + + public function testRecipientId() + { + $options = (new Lox24Options()); + $this->assertNull($options->getRecipientId()); + } + + public function testCallbackData() + { + $options = (new Lox24Options())->callbackData('test'); + $this->assertSame('test', $options->toArray()['callback_data']); + } + + public function testTypeSms() + { + $options = (new Lox24Options())->type(Type::Sms); + $this->assertSame('sms', $options->toArray()['type']); + } + + public function testTypeVoice() + { + $options = (new Lox24Options())->type(Type::Voice); + $this->assertSame('voice', $options->toArray()['type']); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportFactoryTest.php new file mode 100644 index 0000000000000..10c39e8cffb6c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportFactoryTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Notifier\Bridge\Lox24\Lox24TransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +/** + * @author Andrei Lebedev + */ +class Lox24TransportFactoryTest extends TransportFactoryTestCase +{ + public static function supportsProvider(): iterable + { + yield [true, 'lox24://123456:aaaabbbbbbccccccdddddeeee@default?from=0611223344']; + yield [false, 'somethingElse://accountSid:authToken@default?from=0611223344']; + } + + public static function createProvider(): iterable + { + yield [ + 'lox24://api.lox24.eu?from=0611223344', + 'lox24://USERID:TOKEN@default?from=0611223344', + ]; + yield [ + 'lox24://host.test?from=0611223344', + 'lox24://USERID:TOKEN@host.test?from=0611223344', + ]; + } + + public static function missingRequiredOptionProvider(): iterable + { + yield 'missing option: from' => ['lox24://accountSid:authToken@default']; + } + + public function createFactory(): TransportFactoryInterface + { + return new Lox24TransportFactory(); + } + + public static function unsupportedSchemeProvider(): iterable + { + yield 'missing token' => ['invalid://default?from=0611223344']; + } + + public static function incompleteDsnProvider(): iterable + { + yield 'missing token' => ['lox24://default?from=0611223344']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportTest.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportTest.php new file mode 100644 index 0000000000000..3db43acbcdcc3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Lox24TransportTest.php @@ -0,0 +1,321 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Notifier\Bridge\Lox24\Lox24Options; +use Symfony\Component\Notifier\Bridge\Lox24\Lox24Transport; +use Symfony\Component\Notifier\Bridge\Lox24\Type; +use Symfony\Component\Notifier\Bridge\Lox24\VoiceLanguage; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Message\PushMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Andrei Lebedev + */ +class Lox24TransportTest extends TransportTestCase +{ + private const REQUEST_HEADERS = [ + 'X-LOX24-AUTH-TOKEN' => 'user:token', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'User-Agent' => 'LOX24 Symfony Notifier', + ]; + + private const REQUEST_BODY = [ + 'sender_id' => 'testFrom2', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + ]; + + private MockObject|HttpClientInterface $client; + + protected function setUp(): void + { + $this->client = $this->createMock(HttpClientInterface::class); + } + + public static function createTransport(?HttpClientInterface $client = null): Lox24Transport + { + return (new Lox24Transport('user', 'token', 'sender', ['type' => 'voice'], $client ?? new MockHttpClient()))->setHost('host.test'); + } + + public static function toStringProvider(): iterable + { + yield ['lox24://host.test?from=sender&type=voice', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('+33611223344', 'Hello World!')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello World!')]; + yield [new PushMessage('subject', 'content')]; + } + + public function testSupportWithNotSmsMessage() + { + $transport = new Lox24Transport('user', 'token', 'testFrom'); + $message = $this->createMock(MessageInterface::class); + $this->assertFalse($transport->supports($message)); + } + + public function testSupportWithNotLOX24Options() + { + $transport = new Lox24Transport('user', 'token', 'testFrom'); + $message = new SmsMessage('test', 'test'); + $options = $this->createMock(MessageOptionsInterface::class); + $message->options($options); + $this->assertFalse($transport->supports($message)); + } + + public function testSendWithInvalidMessageType() + { + $this->expectException(UnsupportedMessageTypeException::class); + $transport = new Lox24Transport('user', 'token', 'testFrom'); + $message = $this->createMock(MessageInterface::class); + $transport->send($message); + } + + public function testMessageFromNotEmpty() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom2', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + $message = new SmsMessage('+1411111111', 'test text', 'testFrom2'); + $transport->send($message); + } + + public function testMessageFromEmpty() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + $message = new SmsMessage('+1411111111', 'test text'); + $transport->send($message); + } + + public function testMessageFromInvalid() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'From" number "???????" is not a valid phone number, shortcode, or alphanumeric sender ID.' + ); + $transport = new Lox24Transport('user', 'token', '???????', []); + $message = new SmsMessage('+1411111111', 'test text'); + $transport->send($message); + } + + public function testOptionIsTextDeleted() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => true, + 'delivery_at' => 0, + 'service_code' => 'direct', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + + $options = (new Lox24Options())->deleteTextAfterSending(true); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testOptionDeliveryAtGreaterThanZero() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 1000000000, + 'service_code' => 'direct', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + + $options = (new Lox24Options())->deliveryAt((new DateTimeImmutable())->setTimestamp(1000000000)); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testOptionVoiceLanguageSpanish() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'text2speech', + 'voice_lang' => 'ES', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + + $options = (new Lox24Options()) + ->voiceLanguage(VoiceLanguage::Spanish) + ->type(Type::Voice); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testOptionVoiceLanguageAuto() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'text2speech', + ], [], 201, ['uuid' => '123456']); + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + + $options = (new Lox24Options()) + ->voiceLanguage(VoiceLanguage::Auto) + ->type(Type::Voice); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testOptionType() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + ], [], 201, ['uuid' => '123456']); + + $transport = new Lox24Transport('user', 'token', 'testFrom', ['type' => 'voice'], $this->client); + + $options = (new Lox24Options())->type(Type::Sms); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testOptionCallbackData() + { + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + 'callback_data' => 'callback_data', + ], [], 201, ['uuid' => '123456']); + + $transport = new Lox24Transport('user', 'token', 'testFrom', ['type' => 'voice'], $this->client); + + $options = (new Lox24Options())->callbackData('callback_data'); + $message = new SmsMessage('+1411111111', 'test text'); + $message->options($options); + + $transport->send($message); + } + + public function testResponseStatusCodeNotEqual201() + { + $this->expectException(TransportException::class); + $this->expectExceptionMessage( + 'Unable to send the SMS: "service_code: Service\'s code is invalid or unavailable.".' + ); + + $this->assertRequestBody([ + 'sender_id' => 'testFrom', + 'phone' => '+1411111111', + 'text' => 'test text', + 'is_text_deleted' => false, + 'delivery_at' => 0, + 'service_code' => 'direct', + ], + [], + 400, + [ + 'type' => 'https://tools.ietf.org/html/rfc2616#section-10', + 'title' => 'An error occurred', + 'detail' => 'service_code: Service\'s code is invalid or unavailable.', + 'violations' => [ + [ + 'propertyPath' => 'service_code', + 'message' => 'Service\'s code is invalid or unavailable.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], + ], + ); + + $transport = new Lox24Transport('user', 'token', 'testFrom', [], $this->client); + + $message = new SmsMessage('+1411111111', 'test text'); + $transport->send($message); + } + + private function assertRequestBody( + array $bodyOverride = [], + array $headersOverride = [], + int $responseStatus = 200, + array $responseContent = [], + ): void { + $body = array_merge(self::REQUEST_BODY, $bodyOverride); + $headers = array_merge(self::REQUEST_HEADERS, $headersOverride); + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once())->method('getStatusCode')->willReturn($responseStatus); + $response->expects($this->once())->method('toArray')->willReturn($responseContent); + $this->client->expects($this->once()) + ->method('request') + ->with('POST', 'https://api.lox24.eu/sms', [ + 'body' => $body, + 'headers' => $headers, + ])->willReturn($response); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Webhook/Lox24RequestParserTest.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Webhook/Lox24RequestParserTest.php new file mode 100644 index 0000000000000..7b2cc9180466c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Tests/Webhook/Lox24RequestParserTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24\Tests\Webhook; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Notifier\Bridge\Lox24\Webhook\LOX24RequestParser; +use Symfony\Component\RemoteEvent\Event\Sms\SmsEvent; +use Symfony\Component\Webhook\Exception\RejectWebhookException; + +/** + * @author Andrei Lebedev + */ +class Lox24RequestParserTest extends TestCase +{ + private LOX24RequestParser $parser; + + protected function setUp(): void + { + $this->parser = new LOX24RequestParser(); + } + + public function testInvalidNotificationName() + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Notification name is not \'sms.delivery\''); + $request = $this->getRequest(['name' => 'invalid_name', 'data' => ['status_code' => 100]]); + + $this->parser->parse($request, ''); + } + + public function testMissingMsgId() + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Payload is malformed.'); + $request = $this->getRequest(['name' => 'sms.delivery', 'data' => ['status_code' => 100]]); + + $this->parser->parse($request, ''); + } + + public function testMissingMsgStatusCode() + { + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Payload is malformed.'); + $request = $this->getRequest(['name' => 'sms.delivery', 'data' => ['id' => '123']]); + + $this->parser->parse($request, ''); + } + + public function testStatusCode100() + { + $payload = [ + 'name' => 'sms.delivery', + 'data' => [ + 'id' => '123', + 'status_code' => 100, + ], + ]; + $request = $this->getRequest($payload); + + $event = $this->parser->parse($request, ''); + $this->assertSame('123', $event->getId()); + $this->assertSame(SmsEvent::DELIVERED, $event->getName()); + $this->assertSame($payload, $event->getPayload()); + } + + public function testStatusCode0() + { + $request = $this->getRequest( + [ + 'name' => 'sms.delivery', + 'data' => [ + 'id' => '123', + 'status_code' => 0, + ], + ] + ); + + $event = $this->parser->parse($request, ''); + $this->assertNull($event); + } + + public function testStatusCodeUnknown() + { + $payload = [ + 'name' => 'sms.delivery', + 'data' => [ + 'id' => '123', + 'status_code' => 410, + ], + ]; + $request = $this->getRequest($payload); + + $event = $this->parser->parse($request, ''); + $this->assertSame('123', $event->getId()); + $this->assertSame(SmsEvent::FAILED, $event->getName()); + $this->assertSame($payload, $event->getPayload()); + } + + private function getRequest(array $data): Request + { + return Request::create('/test', 'POST', $data); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Type.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Type.php new file mode 100644 index 0000000000000..6760252001e2e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Type.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24; + +enum Type: string +{ + case Sms = 'sms'; + case Voice = 'voice'; + + public function getServiceCode(): string + { + return match ($this) { + self::Sms => 'direct', + self::Voice => 'text2speech', + }; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/VoiceLanguage.php b/src/Symfony/Component/Notifier/Bridge/Lox24/VoiceLanguage.php new file mode 100644 index 0000000000000..4f970b040519f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/VoiceLanguage.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24; + +enum VoiceLanguage: string +{ + case German = 'DE'; + case English = 'EN'; + case Spanish = 'ES'; + case French = 'FR'; + case Italian = 'IT'; + case Auto = 'auto'; +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/Webhook/LOX24RequestParser.php b/src/Symfony/Component/Notifier/Bridge/Lox24/Webhook/LOX24RequestParser.php new file mode 100644 index 0000000000000..4dda4c6c55c5f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/Webhook/LOX24RequestParser.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Lox24\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\RemoteEvent\Event\Sms\SmsEvent; +use Symfony\Component\Webhook\Client\AbstractRequestParser; +use Symfony\Component\Webhook\Exception\RejectWebhookException; + +/** + * @author Andrei Lebedev + * + * @see https://doc.lox24.eu/#section/Introduction/Notifications + */ +final class LOX24RequestParser extends AbstractRequestParser +{ + protected function getRequestMatcher(): RequestMatcherInterface + { + return new MethodRequestMatcher('POST'); + } + + /** + * @throws RejectWebhookException + */ + protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?SmsEvent + { + $payload = $request->request->all() ?? []; + $name = $payload['name'] ?? null; + $data = $payload['data'] ?? []; + + if ('sms.delivery' !== $name) { + throw new RejectWebhookException(400, 'Notification name is not \'sms.delivery\''); + } + + if (!isset($data['id'], $data['status_code'])) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + $code = $data['status_code']; + + if (0 === $code) { + return null; + } + + $name = 100 === $code ? SmsEvent::DELIVERED : SmsEvent::FAILED; + + return new SmsEvent($name, $data['id'], $payload); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/composer.json b/src/Symfony/Component/Notifier/Bridge/Lox24/composer.json new file mode 100644 index 0000000000000..721c33799f70a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/lox24-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony LOX24 Notifier Bridge", + "keywords": [ + "sms", + "lox24", + "notifier" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "require": { + "php": ">=8.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.1" + }, + "require-dev": { + "symfony/webhook": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Notifier\\Bridge\\Lox24\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "scripts": { + "php-cs-fixer": "php-cs-fixer --config=./.php_cs" + }, + "minimum-stability": "dev" +} \ No newline at end of file diff --git a/src/Symfony/Component/Notifier/Bridge/Lox24/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Lox24/phpunit.xml.dist new file mode 100644 index 0000000000000..28fd85cff3d4c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Lox24/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index e5e51a8bb64ec..a9930d452a9e2 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -132,6 +132,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\LinkedIn\LinkedInTransportFactory::class, 'package' => 'symfony/linked-in-notifier', ], + 'lox24' => [ + 'class' => Bridge\Lox24\Lox24TransportFactory::class, + 'package' => 'symfony/lox24-notifier', + ], 'mailjet' => [ 'class' => Bridge\Mailjet\MailjetTransportFactory::class, 'package' => 'symfony/mailjet-notifier', diff --git a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php index 643e93f04e206..8b11e62c41d21 100644 --- a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -55,6 +55,7 @@ public static function setUpBeforeClass(): void Bridge\LightSms\LightSmsTransportFactory::class => false, Bridge\LineNotify\LineNotifyTransportFactory::class => false, Bridge\LinkedIn\LinkedInTransportFactory::class => false, + Bridge\Lox24\Lox24TransportFactory::class => false, Bridge\Mailjet\MailjetTransportFactory::class => false, Bridge\Mastodon\MastodonTransportFactory::class => false, Bridge\Mattermost\MattermostTransportFactory::class => false, @@ -143,6 +144,7 @@ public static function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \ yield ['lightsms', 'symfony/light-sms-notifier']; yield ['linenotify', 'symfony/line-notify-notifier']; yield ['linkedin', 'symfony/linked-in-notifier']; + yield ['lox24', 'symfony/lox24-notifier']; yield ['mailjet', 'symfony/mailjet-notifier']; yield ['mastodon', 'symfony/mastodon-notifier']; yield ['mattermost', 'symfony/mattermost-notifier']; diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index 0bdf947ba481f..0ed0c465747f6 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -57,6 +57,7 @@ final class Transport Bridge\LightSms\LightSmsTransportFactory::class, Bridge\LineNotify\LineNotifyTransportFactory::class, Bridge\LinkedIn\LinkedInTransportFactory::class, + Bridge\Lox24\Lox24TransportFactory::class, Bridge\Mailjet\MailjetTransportFactory::class, Bridge\Mastodon\MastodonTransportFactory::class, Bridge\Mattermost\MattermostTransportFactory::class,