From 5675c95a6293e5cb7ad36a64c4b77ad3f68bfa2f Mon Sep 17 00:00:00 2001 From: Alan ZARLI Date: Mon, 18 Dec 2023 14:48:00 +0100 Subject: [PATCH] [Notifier] Add Smsbox notifier bridge --- .../FrameworkExtension.php | 1 + .../Resources/config/notifier_transports.php | 4 + .../Notifier/Bridge/Smsbox/.gitattributes | 4 + .../Notifier/Bridge/Smsbox/.gitignore | 3 + .../Notifier/Bridge/Smsbox/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Smsbox/LICENSE | 19 + .../Notifier/Bridge/Smsbox/README.md | 50 +++ .../Notifier/Bridge/Smsbox/SmsboxOptions.php | 388 ++++++++++++++++++ .../Bridge/Smsbox/SmsboxTransport.php | 160 ++++++++ .../Bridge/Smsbox/SmsboxTransportFactory.php | 51 +++ .../Bridge/Smsbox/Tests/SmsboxOptionsTest.php | 77 ++++ .../Tests/SmsboxTransportFactoryTest.php | 53 +++ .../Smsbox/Tests/SmsboxTransportTest.php | 243 +++++++++++ .../Notifier/Bridge/Smsbox/composer.json | 40 ++ .../Notifier/Bridge/Smsbox/phpunit.xml.dist | 31 ++ .../Exception/UnsupportedSchemeException.php | 4 + .../UnsupportedSchemeExceptionTest.php | 1 + src/Symfony/Component/Notifier/Transport.php | 1 + 18 files changed, 1137 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxOptions.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxOptionsTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Smsbox/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 17330540df606..f94430eeb0cfb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2750,6 +2750,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\Sms77\Sms77TransportFactory::class => 'notifier.transport_factory.sms77', NotifierBridge\Smsapi\SmsapiTransportFactory::class => 'notifier.transport_factory.smsapi', NotifierBridge\SmsBiuras\SmsBiurasTransportFactory::class => 'notifier.transport_factory.sms-biuras', + NotifierBridge\Smsbox\SmsboxTransportFactory::class => 'notifier.transport_factory.smsbox', NotifierBridge\Smsc\SmscTransportFactory::class => 'notifier.transport_factory.smsc', NotifierBridge\SmsFactor\SmsFactorTransportFactory::class => 'notifier.transport_factory.sms-factor', NotifierBridge\Smsmode\SmsmodeTransportFactory::class => 'notifier.transport_factory.smsmode', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index fd2952c50a9cd..a4a2574a2378e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -188,6 +188,10 @@ ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + ->set('notifier.transport_factory.smsbox', Bridge\Smsbox\SmsboxTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') + ->set('notifier.transport_factory.smsc', Bridge\Smsc\SmscTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Smsbox/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/.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/Smsbox/.gitignore b/src/Symfony/Component/Notifier/Bridge/Smsbox/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Smsbox/CHANGELOG.md new file mode 100644 index 0000000000000..ab7facf3a8b5c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/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/Smsbox/LICENSE b/src/Symfony/Component/Notifier/Bridge/Smsbox/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-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. diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/README.md b/src/Symfony/Component/Notifier/Bridge/Smsbox/README.md new file mode 100644 index 0000000000000..7f9b36691c754 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/README.md @@ -0,0 +1,50 @@ +SMSBOX Notifier +--------------- + +Provides [SMSBOX](https://www.smsbox.net/en/) integration for Symfony Notifier. + +DSN example +----------- + +``` +SMSBOX_DSN=smsbox://APIKEY@default?mode=MODE&strategy=STRATEGY&sender=SENDER +``` + +where: + +- `APIKEY` is your SMSBOX api key +- `MODE` is the sending mode +- `STRATEGY` is the type of your message +- `SENDER` is the sender name + +## You can add numerous options to a message + +With a SMSBOX Message, you can use the SmsboxOptions class and use the setters to add [message options](https://www.smsbox.net/en/tools-development#developer-space) + +```php +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Bridge\Smsbox\SmsboxOptions; + +$sms = new SmsMessage('+33123456789', 'Your %1% message %2%'); +$options = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_EXPERT) + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_NOT_MARKETING_GROUP) + ->sender('Your sender') + ->date('DD/MM/YYYY') + ->hour('HH:MM') + ->coding(SmsboxOptions::MESSAGE_CODING_UNICODE) + ->charset(SmsboxOptions::MESSAGE_CHARSET_UTF8) + ->udh(SmsboxOptions::MESSAGE_UDH_DISABLED_CONCAT) + ->callback(true) + ->allowVocal(true) + ->maxParts(2) + ->validity(100) + ->daysMinMax(min: SmsboxOptions::MESSAGE_DAYS_TUESDAY, max: SmsboxOptions::MESSAGE_DAYS_FRIDAY) + ->hoursMinMax(min: 8, max: 10) + ->variable(['variable1', 'variable2']) + ->dateTime(new \DateTime()) + ->destIso('FR'); + +$sms->options($options); +$texter->send($sms); +``` \ No newline at end of file diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxOptions.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxOptions.php new file mode 100644 index 0000000000000..d95c7b801a915 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxOptions.php @@ -0,0 +1,388 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsbox; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +/** + * @author Alan Zarli + * @author Farid Touil + */ +final class SmsboxOptions implements MessageOptionsInterface +{ + public const MESSAGE_MODE_STANDARD = 'Standard'; + public const MESSAGE_MODE_EXPERT = 'Expert'; + public const MESSAGE_MODE_RESPONSE = 'Reponse'; + + public const MESSAGE_STRATEGY_PRIVATE = 1; + public const MESSAGE_STRATEGY_NOTIFICATION = 2; + public const MESSAGE_STRATEGY_NOT_MARKETING_GROUP = 3; + public const MESSAGE_STRATEGY_MARKETING = 4; + + public const MESSAGE_CODING_DEFAULT = 'default'; + public const MESSAGE_CODING_UNICODE = 'unicode'; + public const MESSAGE_CODING_AUTO = 'auto'; + + public const MESSAGE_CHARSET_ISO_1 = 'iso-8859-1'; + public const MESSAGE_CHARSET_ISO_15 = 'iso-8859-15'; + public const MESSAGE_CHARSET_UTF8 = 'utf-8'; + + public const MESSAGE_DAYS_MONDAY = 1; + public const MESSAGE_DAYS_TUESDAY = 2; + public const MESSAGE_DAYS_WEDNESDAY = 3; + public const MESSAGE_DAYS_THURSDAY = 4; + public const MESSAGE_DAYS_FRIDAY = 5; + public const MESSAGE_DAYS_SATURDAY = 6; + public const MESSAGE_DAYS_SUNDAY = 7; + + public const MESSAGE_UDH_6_OCTETS = 1; + public const MESSAGE_UDH_7_OCTETS = 2; + public const MESSAGE_UDH_DISABLED_CONCAT = 0; + + private array $options; + + public function __construct(array $options = []) + { + $this->options = []; + } + + /** + * @return null + */ + public function getRecipientId(): ?string + { + return null; + } + + public function mode(string $mode) + { + $this->options['mode'] = self::validateMode($mode); + + return $this; + } + + public function strategy(int $strategy) + { + $this->options['strategy'] = self::validateStrategy($strategy); + + return $this; + } + + public function date(string $date) + { + $this->options['date'] = self::validateDate($date); + + return $this; + } + + public function hour(string $hour) + { + $this->options['heure'] = self::validateHour($hour); + + return $this; + } + + /** + * This method mustn't be set along with date and hour methods. + */ + public function dateTime(\DateTime $dateTime) + { + $this->options['dateTime'] = self::validateDateTime($dateTime); + + return $this; + } + + /** + * This method wait an ISO 3166-1 alpha. + */ + public function destIso(string $isoCode) + { + $this->options['dest_iso'] = self::validateDestIso($isoCode); + + return $this; + } + + /** + * This method will automatically set personnalise = 1 (according to SMSBOX documentation). + */ + public function variable(array $variable) + { + $this->options['variable'] = $variable; + + return $this; + } + + public function coding(string $coding) + { + $this->options['coding'] = self::validateCoding($coding); + + return $this; + } + + public function charset(string $charset) + { + $this->options['charset'] = self::validateCharset($charset); + + return $this; + } + + public function udh(int $udh) + { + $this->options['udh'] = self::validateUdh($udh); + + return $this; + } + + /** + * The true value = 1 in SMSBOX documentation. + */ + public function callback(bool $callback) + { + $this->options['callback'] = $callback; + + return $this; + } + + /** + * The true value = 1 in SMSBOX documentation. + */ + public function allowVocal(bool $allowVocal) + { + $this->options['allow_vocal'] = $allowVocal; + + return $this; + } + + public function maxParts(int $maxParts) + { + $this->options['max_parts'] = self::validateMaxParts($maxParts); + + return $this; + } + + public function validity(int $validity) + { + $this->options['validity'] = self::validateValidity($validity); + + return $this; + } + + public function daysMinMax(int $min, int $max) + { + $this->options['daysMinMax'] = self::validateDays($min, $max); + + return $this; + } + + public function hoursMinMax(int $min, int $max) + { + $this->options['hoursMinMax'] = self::validateHours($min, $max); + + return $this; + } + + public function sender(string $sender) + { + $this->options['sender'] = $sender; + + return $this; + } + + public function toArray(): array + { + return $this->options; + } + + public static function validateMode(string $mode): string + { + $supportedModes = [ + self::MESSAGE_MODE_STANDARD, + self::MESSAGE_MODE_EXPERT, + self::MESSAGE_MODE_RESPONSE, + ]; + + if (!\in_array($mode, $supportedModes, true)) { + throw new InvalidArgumentException(sprintf('The message mode "%s" is not supported; supported message modes are: "%s".', $mode, implode('", "', $supportedModes))); + } + + return $mode; + } + + public static function validateStrategy(int $strategy): int + { + $supportedStrategies = [ + self::MESSAGE_STRATEGY_PRIVATE, + self::MESSAGE_STRATEGY_NOTIFICATION, + self::MESSAGE_STRATEGY_NOT_MARKETING_GROUP, + self::MESSAGE_STRATEGY_MARKETING, + ]; + if (!\in_array($strategy, $supportedStrategies, true)) { + throw new InvalidArgumentException(sprintf('The message strategy "%s" is not supported; supported strategies types are: "%s".', $strategy, implode('", "', $supportedStrategies))); + } + + return $strategy; + } + + public static function validateDate(string $date): string + { + $dateTimeObj = \DateTime::createFromFormat('d/m/Y', $date); + $now = new \DateTime(); + $tz = new \DateTimeZone('Europe/Paris'); + $dateTimeObj->setTimezone($tz); + $now->setTimezone($tz); + + if (!$dateTimeObj || $dateTimeObj->format('Y-m-d') <= (new \DateTime())->format('Y-m-d')) { + throw new InvalidArgumentException('The date must be in DD/MM/YYYY format and greater than the current date.'); + } + + return $date; + } + + public static function validateDateTime(\DateTime $dateTime) + { + \Locale::setDefault('fr'); + $now = new \DateTime(); + $tz = new \DateTimeZone('Europe/Paris'); + $now->setTimezone($tz); + $dateTime->setTimezone($tz); + + if ($now > $dateTime || $dateTime > $now->modify('+2 Year')) { + throw new InvalidArgumentException('dateTime must be greater to the actual date and limited to 2 years in the future.'); + } + + return $dateTime; + } + + public static function validateDestIso(string $isoCode) + { + if (!preg_match('/^[a-z]{2}$/i', $isoCode)) { + throw new InvalidArgumentException('destIso must be the ISO 3166-1 alpha 2 on two uppercase characters.'); + } + + return $isoCode; + } + + public static function validateHour(string $hour): string + { + $dateTimeObjhour = \DateTime::createFromFormat('H:i', $hour); + + if (!$dateTimeObjhour || $dateTimeObjhour->format('H:i') != $hour) { + throw new InvalidArgumentException('Hour must be in HH:MM format and valid.'); + } + + return $hour; + } + + public static function validateCoding(string $coding): string + { + $supportedCodings = [ + self::MESSAGE_CODING_DEFAULT, + self::MESSAGE_CODING_UNICODE, + self::MESSAGE_CODING_AUTO, + ]; + + if (!\in_array($coding, $supportedCodings, true)) { + throw new InvalidArgumentException(sprintf('The message coding : "%s" is not supported; supported codings types are: "%s".', $coding, implode('", "', $supportedCodings))); + } + + return $coding; + } + + public static function validateCharset(string $charset): string + { + $supportedCharsets = [ + self::MESSAGE_CHARSET_ISO_1, + self::MESSAGE_CHARSET_ISO_15, + self::MESSAGE_CHARSET_UTF8, + ]; + + if (!\in_array($charset, $supportedCharsets, true)) { + throw new InvalidArgumentException(sprintf('The message charset : "%s" is not supported; supported charsets types are: "%s".', $charset, implode('", "', $supportedCharsets))); + } + + return $charset; + } + + public static function validateUdh(int $udh): int + { + $supportedUdhs = [ + self::MESSAGE_UDH_6_OCTETS, + self::MESSAGE_UDH_7_OCTETS, + self::MESSAGE_UDH_DISABLED_CONCAT, + ]; + + if (!\in_array($udh, $supportedUdhs, true)) { + throw new InvalidArgumentException(sprintf('The message charset : "%s" is not supported; supported charsets types are: "%s".', $udh, implode('", "', $supportedUdhs))); + } + + return $udh; + } + + public static function validateMaxParts(int $maxParts): int + { + if ($maxParts < 1 || $maxParts > 8) { + throw new InvalidArgumentException(sprintf('The message max_parts : "%s" is not supported; supported max_parts values are integers between 1 and 8.', $maxParts)); + } + + return $maxParts; + } + + public static function validateValidity(int $validity): int + { + if ($validity < 5 || $validity > 1440) { + throw new InvalidArgumentException(sprintf('The message validity : "%s" is not supported; supported validity values are integers between 5 and 1440.', $validity)); + } + + return $validity; + } + + public static function validateDays(int $min, int $max): array + { + $supportedDays = [ + self::MESSAGE_DAYS_MONDAY, + self::MESSAGE_DAYS_TUESDAY, + self::MESSAGE_DAYS_WEDNESDAY, + self::MESSAGE_DAYS_THURSDAY, + self::MESSAGE_DAYS_FRIDAY, + self::MESSAGE_DAYS_SATURDAY, + self::MESSAGE_DAYS_SUNDAY, + ]; + + if (!\in_array($min, $supportedDays, true)) { + throw new InvalidArgumentException(sprintf('The message min : "%s" is not supported; supported charsets types are: "%s".', $min, implode('", "', $supportedDays))); + } + + if (!\in_array($max, $supportedDays, true)) { + throw new InvalidArgumentException(sprintf('The message max : "%s" is not supported; supported charsets types are: "%s".', $max, implode('", "', $supportedDays))); + } + + if ($min > $max) { + throw new InvalidArgumentException(sprintf('The message max must be greater than min.', $min)); + } + + return [$min, $max]; + } + + public static function validateHours(int $min, int $max): array + { + if ($min < 0 || $min > $max) { + throw new InvalidArgumentException(sprintf('The message min : "%s" is not supported; supported min values are integers between 0 and 23.', $min)); + } + + if ($max > 23) { + throw new InvalidArgumentException(sprintf('The message max : "%s" is not supported; supported min values are integers between 0 and 23.', $max)); + } + + return [$min, $max]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransport.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransport.php new file mode 100644 index 0000000000000..899acaddd328c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransport.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsbox; + +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\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Alan Zarli + * @author Farid Touil + */ +final class SmsboxTransport extends AbstractTransport +{ + protected const HOST = 'api.smsbox.pro'; + + private string $apiKey; + private ?string $mode; + private ?int $strategy; + private ?string $sender; + + public function __construct(#[\SensitiveParameter] string $apiKey, string $mode, int $strategy, ?string $sender, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->apiKey = $apiKey; + $this->mode = $mode; + $this->strategy = $strategy; + $this->sender = $sender; + + SmsboxOptions::validateMode($this->mode); + SmsboxOptions::validateStrategy($this->strategy); + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + if (SmsboxOptions::MESSAGE_MODE_EXPERT === $this->mode) { + return sprintf('smsbox://%s?mode=%s&strategy=%s&sender=%s', $this->getEndpoint(), $this->mode, $this->strategy, $this->sender); + } + + return sprintf('smsbox://%s?mode=%s&strategy=%s', $this->getEndpoint(), $this->mode, $this->strategy); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage && (null === $message->getOptions() || $message->getOptions() instanceof SmsboxOptions); + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof SmsMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, SmsMessage::class, $message); + } + + $phoneCleaned = preg_replace('/[^0-9+]+/', '', $message->getPhone()); + if (!preg_match("/^(\+|)[0-9]{7,14}$/", $phoneCleaned)) { + throw new InvalidArgumentException('Invalid phone number.'); + } + + $options = $message->getOptions()?->toArray() ?? []; + $options['dest'] = $phoneCleaned; + $options['msg'] = $message->getSubject(); + $options['id'] = 1; + $options['usage'] = 'symfony'; + $options['mode'] ??= $this->mode; + $options['strategy'] ??= $this->strategy; + + if (SmsboxOptions::MESSAGE_MODE_EXPERT === $options['mode']) { + $options['origine'] = $options['sender'] ?? $this->sender; + } + unset($options['sender']); + + if (isset($options['daysMinMax'])) { + $options['day_min'] = $options['daysMinMax'][0]; + $options['day_max'] = $options['daysMinMax'][1]; + unset($options['daysMinMax']); + } + + if (isset($options['hoursMinMax'])) { + $options['hour_min'] = $options['hoursMinMax'][0]; + $options['hour_max'] = $options['hoursMinMax'][1]; + unset($options['hoursMinMax']); + } + + if (isset($options['dateTime'])) { + if (isset($options['heure']) || isset($options['date'])) { + throw new InvalidArgumentException("You mustn't set the dateTime method along with date or hour methods."); + } + + $options['date'] = $options['dateTime']->format('d/m/Y'); + $options['heure'] = $options['dateTime']->format('H:i'); + unset($options['dateTime']); + } + + if (isset($options['variable'])) { + preg_match_all('%([0-9]+)%', $options['msg'], $matches); + $occurenceValMsg = $matches[0]; + $occurenceValMsgMax = max($occurenceValMsg); + + if ($occurenceValMsgMax != \count($options['variable'])) { + throw new InvalidArgumentException('You must have the same amount of index in your array as you have variable.'); + } + + $t = str_replace([',', ';'], ['%d44%', '%d59%'], $options['variable']); + $variableStr = implode(';', $t); + $options['dest'] .= ';'.$variableStr; + $options['personnalise'] = 1; + unset($options['variable']); + } + + $response = $this->client->request('POST', sprintf('https://%s/1.1/api.php', $this->getEndpoint()), [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Authorization' => 'App '.$this->apiKey, + ], + 'body' => $options, + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote Smsbox server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + $error = $response->getContent(false); + throw new TransportException(sprintf('Unable to send the SMS: "%s" (%s).', $error['description'], $error['code']), $response); + } + + $body = $response->getContent(false); + if (!preg_match('/^OK .*/', $body)) { + throw new TransportException(sprintf('Unable to send the SMS: "%s" (%s).', $body, 400), $response); + } + + if (!preg_match('/^OK (\d+)/', $body, $reference)) { + throw new TransportException(sprintf('Unable to send the SMS: "%s" (%s).', $body, 400), $response); + } + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($reference[1]); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransportFactory.php new file mode 100644 index 0000000000000..9a1f386581a86 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/SmsboxTransportFactory.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsbox; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; + +/** + * @author Alan Zarli + * @author Farid Touil + */ +final class SmsboxTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): SmsboxTransport + { + $scheme = $dsn->getScheme(); + + if ('smsbox' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'smsbox', $this->getSupportedSchemes()); + } + + $apiKey = $this->getUser($dsn); + $mode = $dsn->getRequiredOption('mode'); + $strategy = $dsn->getRequiredOption('strategy'); + $sender = $dsn->getOption('sender'); + + if (SmsboxOptions::MESSAGE_MODE_EXPERT === $mode) { + $sender = $dsn->getRequiredOption('sender'); + } + + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new SmsboxTransport($apiKey, $mode, $strategy, $sender, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['smsbox']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxOptionsTest.php new file mode 100644 index 0000000000000..353dba391efe5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxOptionsTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsbox\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Smsbox\SmsboxOptions; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; + +class SmsboxOptionsTest extends TestCase +{ + public function testSmsboxOptions() + { + $smsboxOptions = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_EXPERT) + ->sender('SENDER') + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_MARKETING) + ->charset(SmsboxOptions::MESSAGE_CHARSET_UTF8) + ->udh(SmsboxOptions::MESSAGE_UDH_DISABLED_CONCAT) + ->maxParts(2) + ->validity(100) + ->destIso('FR'); + + self::assertSame([ + 'mode' => 'Expert', + 'sender' => 'SENDER', + 'strategy' => 4, + 'charset' => 'utf-8', + 'udh' => 0, + 'max_parts' => 2, + 'validity' => 100, + 'dest_iso' => 'FR', + ], $smsboxOptions->toArray()); + } + + public function testSmsboxOptionsInvalidMode() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The message mode "XXXXXX" is not supported; supported message modes are: "Standard", "Expert", "Reponse"'); + + $smsboxOptions = (new SmsboxOptions()) + ->mode('XXXXXX') + ->sender('SENDER') + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_MARKETING); + } + + public function testSmsboxOptionsInvalidStrategy() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The message strategy "10" is not supported; supported strategies types are: "1", "2", "3", "4"'); + + $smsboxOptions = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_STANDARD) + ->sender('SENDER') + ->strategy(10); + } + + public function testSmsboxOptionsInvalidDestIso() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('destIso must be the ISO 3166-1 alpha 2 on two uppercase characters.'); + + $smsboxOptions = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_EXPERT) + ->sender('SENDER') + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_MARKETING) + ->destIso('X1'); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportFactoryTest.php new file mode 100644 index 0000000000000..5e2596e0fffe0 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportFactoryTest.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\Smsbox\Tests; + +use Symfony\Component\Notifier\Bridge\Smsbox\SmsboxTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; + +final class SmsboxTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): SmsboxTransportFactory + { + return new SmsboxTransportFactory(); + } + + public static function createProvider(): iterable + { + yield ['smsbox://host.test?mode=Standard&strategy=4', 'smsbox://APIKEY@host.test?mode=Standard&strategy=4']; + yield ['smsbox://host.test?mode=Expert&strategy=4&sender=SENDER', 'smsbox://APIKEY@host.test?mode=Expert&strategy=4&sender=SENDER']; + } + + public static function incompleteDsnProvider(): iterable + { + yield ['smsbox://APIKEY@host.test?strategy=4&sender=SENDER']; + yield ['smsbox://APIKEY@host.test?mode=Standard&sender=SENDER']; + } + + public static function supportsProvider(): iterable + { + yield [true, 'smsbox://APIKEY@host.test?mode=MODE&strategy=STRATEGY&sender=SENDER']; + yield [false, 'somethingElse://APIKEY@host.test?mode=MODE&strategy=STRATEGY&sender=SENDER']; + } + + public static function missingRequiredOptionProvider(): iterable + { + yield ['smsbox://apiKey@host.test?strategy=4']; + yield ['smsbox://apiKey@host.test?mode=Standard']; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://APIKEY@host.test?mode=MODE&strategy=STRATEGY&sender=SENDER']; + yield ['somethingElse://APIKEY@host.test']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportTest.php new file mode 100644 index 0000000000000..0ab5c763d72ed --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/Tests/SmsboxTransportTest.php @@ -0,0 +1,243 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Smsbox\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Notifier\Bridge\Smsbox\SmsboxOptions; +use Symfony\Component\Notifier\Bridge\Smsbox\SmsboxTransport; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Tests\Transport\DummyMessage; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class SmsboxTransportTest extends TransportTestCase +{ + public static function createTransport(HttpClientInterface $client = null): SmsboxTransport + { + return new SmsboxTransport('apikey', 'Standard', 4, null, $client ?? new MockHttpClient()); + } + + public static function toStringProvider(): iterable + { + yield ['smsbox://api.smsbox.pro?mode=Standard&strategy=4', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new SmsMessage('+33612345678', 'Hello!')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + yield [new DummyMessage()]; + } + + public function testBasicQuerySucceded() + { + $message = new SmsMessage('+33612345678', 'Hello!'); + $response = $this->createMock(ResponseInterface::class); + $response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $response->expects(self::once()) + ->method('getContent') + ->willReturn('OK 12345678'); + + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + self::assertSame('POST', $method); + self::assertSame('https://api.smsbox.pro/1.1/api.php', $url); + self::assertSame('dest=%2B33612345678&msg=Hello%21&id=1&usage=symfony&mode=Standard&strategy=4', $request['body']); + + return $response; + }); + + $transport = $this->createTransport($client); + $sentMessage = $transport->send($message); + + self::assertSame('12345678', $sentMessage->getMessageId()); + } + + public function testBasicQueryFailed() + { + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to send the SMS: "ERROR 02" (400).'); + + $message = new SmsMessage('+33612345678', 'Hello!'); + $response = $this->createMock(ResponseInterface::class); + $response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $response->expects(self::once()) + ->method('getContent') + ->willReturn('ERROR 02'); + + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + self::assertSame('POST', $method); + self::assertSame('https://api.smsbox.pro/1.1/api.php', $url); + self::assertSame('dest=%2B33612345678&msg=Hello%21&id=1&usage=symfony&mode=Standard&strategy=4', $request['body']); + + return $response; + }); + + $transport = $this->createTransport($client); + $transport->send($message); + } + + public function testQuerySuccededWithOptions() + { + $message = new SmsMessage('+33612345678', 'Hello!'); + $response = $this->createMock(ResponseInterface::class); + $response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $response->expects(self::once()) + ->method('getContent') + ->willReturn('OK 12345678'); + + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + self::assertSame('POST', $method); + self::assertSame('https://api.smsbox.pro/1.1/api.php', $url); + self::assertSame('max_parts=5&coding=unicode&callback=1&dest=%2B33612345678&msg=Hello%21&id=1&usage=symfony&mode=Standard&strategy=4&day_min=1&day_max=3', $request['body']); + + return $response; + }); + + $transport = $this->createTransport($client); + $options = (new SmsboxOptions()) + ->maxParts(5) + ->coding(SmsboxOptions::MESSAGE_CODING_UNICODE) + ->daysMinMax(1, 3) + ->callback(true); + + $message->options($options); + $sentMessage = $transport->send($message); + + self::assertSame('12345678', $sentMessage->getMessageId()); + } + + public function testQueryDateTime() + { + $message = new SmsMessage('+33612345678', 'Hello!'); + $response = $this->createMock(ResponseInterface::class); + $response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $response->expects(self::once()) + ->method('getContent') + ->willReturn('OK 12345678'); + + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + self::assertSame('POST', $method); + self::assertSame('https://api.smsbox.pro/1.1/api.php', $url); + self::assertSame('dest=%2B33612345678&msg=Hello%21&id=1&usage=symfony&mode=Standard&strategy=4&date=05%2F12%2F2025&heure=19%3A00', $request['body']); + + return $response; + }); + + $dateTime = \DateTime::createFromFormat('d/m/Y H:i', '05/12/2025 18:00', new \DateTimeZone('UTC')); + + $transport = $this->createTransport($client); + + $options = (new SmsboxOptions()) + ->dateTime($dateTime); + + $message->options($options); + $sentMessage = $transport->send($message); + + self::assertSame('12345678', $sentMessage->getMessageId()); + } + + public function testQueryVariable() + { + $message = new SmsMessage('0612345678', 'Hello %1% %2%'); + $response = $this->createMock(ResponseInterface::class); + $response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $response->expects(self::once()) + ->method('getContent') + ->willReturn('OK 12345678'); + + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + self::assertSame('POST', $method); + self::assertSame('https://api.smsbox.pro/1.1/api.php', $url); + self::assertSame('dest=0612345678%3Btye%25d44%25%25d44%25t%3Be%25d59%25%25d44%25fe&msg=Hello+%251%25+%252%25&id=1&usage=symfony&mode=Standard&strategy=4&personnalise=1', $request['body']); + + return $response; + }); + + $transport = $this->createTransport($client); + + $options = (new SmsboxOptions()) + ->variable(['tye,,t', 'e;,fe']); + + $message->options($options); + $sentMessage = $transport->send($message); + + self::assertSame('12345678', $sentMessage->getMessageId()); + } + + public function testSmsboxOptionsInvalidDateTimeAndDate() + { + $response = $this->createMock(ResponseInterface::class); + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + return $response; + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("You mustn't set the dateTime method along with date or hour methods"); + $dateTime = \DateTime::createFromFormat('d/m/Y H:i', '01/11/2024 18:00', new \DateTimeZone('UTC')); + $message = new SmsMessage('+33612345678', 'Hello'); + + $smsboxOptions = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_EXPERT) + ->sender('SENDER') + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_MARKETING) + ->dateTime($dateTime) + ->date('01/01/2024'); + + $transport = $this->createTransport($client); + + $message->options($smsboxOptions); + $transport->send($message); + } + + public function testSmsboxInvalidPhoneNumber() + { + $response = $this->createMock(ResponseInterface::class); + $client = new MockHttpClient(function (string $method, string $url, $request) use ($response): ResponseInterface { + return $response; + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid phone number'); + $message = new SmsMessage('+336123456789000000', 'Hello'); + + $smsboxOptions = (new SmsboxOptions()) + ->mode(SmsboxOptions::MESSAGE_MODE_EXPERT) + ->sender('SENDER') + ->strategy(SmsboxOptions::MESSAGE_STRATEGY_MARKETING); + $transport = $this->createTransport($client); + + $message->options($smsboxOptions); + $transport->send($message); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/composer.json b/src/Symfony/Component/Notifier/Bridge/Smsbox/composer.json new file mode 100644 index 0000000000000..fedc96515d011 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/composer.json @@ -0,0 +1,40 @@ +{ + "name": "symfony/smsbox-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony Smsbox Notifier Bridge", + "keywords": ["sms", "Smsbox", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Alan Zarli", + "email": "azarli@smsbox.fr" + }, + { + "name": "Farid Touil", + "email": "ftouil@smsbox.fr" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.1" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Notifier\\Bridge\\Smsbox\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Smsbox/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Smsbox/phpunit.xml.dist new file mode 100644 index 0000000000000..ecacbe3789c67 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Smsbox/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 c296b41730776..cef748d006108 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -240,6 +240,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\SmsBiuras\SmsBiurasTransportFactory::class, 'package' => 'symfony/sms-biuras-notifier', ], + 'smsbox' => [ + 'class' => Bridge\Smsbox\SmsboxTransportFactory::class, + 'package' => 'symfony/smsbox-notifier', + ], 'smsc' => [ 'class' => Bridge\Smsc\SmscTransportFactory::class, 'package' => 'symfony/smsc-notifier', diff --git a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php index c81fe985baba1..99236f6feecae 100644 --- a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -82,6 +82,7 @@ public static function setUpBeforeClass(): void Bridge\Sms77\Sms77TransportFactory::class => false, Bridge\Smsapi\SmsapiTransportFactory::class => false, Bridge\SmsBiuras\SmsBiurasTransportFactory::class => false, + Bridge\Smsbox\SmsboxTransportFactory::class => false, Bridge\Smsc\SmscTransportFactory::class => false, Bridge\SmsFactor\SmsFactorTransportFactory::class => false, Bridge\Smsmode\SmsmodeTransportFactory::class => false, diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index 0489703db2cd0..e7d238de86461 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -83,6 +83,7 @@ final class Transport Bridge\Sms77\Sms77TransportFactory::class, Bridge\Smsapi\SmsapiTransportFactory::class, Bridge\SmsBiuras\SmsBiurasTransportFactory::class, + Bridge\Smsbox\SmsboxTransportFactory::class, Bridge\Smsc\SmscTransportFactory::class, Bridge\SmsFactor\SmsFactorTransportFactory::class, Bridge\Smsmode\SmsmodeTransportFactory::class,