From 084e9bcdc476a90857244e8167ea0314230ddbb3 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 23 Aug 2019 22:48:48 +0200 Subject: [PATCH] [Notifier] added the component --- composer.json | 1 + .../Monolog/Handler/NotifierHandler.php | 75 +++++++ .../DependencyInjection/Configuration.php | 48 +++++ .../FrameworkExtension.php | 66 ++++++ .../Resources/config/notifier.xml | 92 +++++++++ .../Resources/config/notifier_transports.xml | 33 +++ .../DependencyInjection/ConfigurationTest.php | 8 + src/Symfony/Component/Notifier/.gitattributes | 2 + src/Symfony/Component/Notifier/.gitignore | 3 + .../Notifier/Bridge/Nexmo/.gitattributes | 2 + .../Notifier/Bridge/Nexmo/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Nexmo/LICENSE | 19 ++ .../Notifier/Bridge/Nexmo/NexmoTransport.php | 77 +++++++ .../Bridge/Nexmo/NexmoTransportFactory.php | 46 +++++ .../Component/Notifier/Bridge/Nexmo/README.md | 12 ++ .../Notifier/Bridge/Nexmo/composer.json | 35 ++++ .../Notifier/Bridge/Nexmo/phpunit.xml.dist | 31 +++ .../Notifier/Bridge/Slack/.gitattributes | 2 + .../Bridge/Slack/Block/AbstractSlackBlock.php | 25 +++ .../Slack/Block/AbstractSlackBlockElement.php | 25 +++ .../Block/SlackBlockElementInterface.php | 20 ++ .../Slack/Block/SlackBlockInterface.php | 20 ++ .../Bridge/Slack/Block/SlackDividerBlock.php | 23 +++ .../Bridge/Slack/Block/SlackImageBlock.php | 27 +++ .../Slack/Block/SlackImageBlockElement.php | 27 +++ .../Bridge/Slack/Block/SlackSectionBlock.php | 46 +++++ .../Notifier/Bridge/Slack/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Slack/LICENSE | 19 ++ .../Component/Notifier/Bridge/Slack/README.md | 12 ++ .../Notifier/Bridge/Slack/SlackOptions.php | 176 ++++++++++++++++ .../Notifier/Bridge/Slack/SlackTransport.php | 90 ++++++++ .../Bridge/Slack/SlackTransportFactory.php | 45 ++++ .../Notifier/Bridge/Slack/composer.json | 35 ++++ .../Notifier/Bridge/Slack/phpunit.xml.dist | 31 +++ .../Notifier/Bridge/Telegram/.gitattributes | 2 + .../Notifier/Bridge/Telegram/CHANGELOG.md | 7 + .../Notifier/Bridge/Telegram/LICENSE | 19 ++ .../Notifier/Bridge/Telegram/README.md | 12 ++ .../Bridge/Telegram/TelegramTransport.php | 86 ++++++++ .../Telegram/TelegramTransportFactory.php | 45 ++++ .../Notifier/Bridge/Telegram/composer.json | 35 ++++ .../Notifier/Bridge/Telegram/phpunit.xml.dist | 31 +++ .../Notifier/Bridge/Twilio/.gitattributes | 2 + .../Notifier/Bridge/Twilio/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Twilio/LICENSE | 19 ++ .../Notifier/Bridge/Twilio/README.md | 12 ++ .../Bridge/Twilio/TwilioTransport.php | 76 +++++++ .../Bridge/Twilio/TwilioTransportFactory.php | 46 +++++ .../Notifier/Bridge/Twilio/composer.json | 35 ++++ .../Notifier/Bridge/Twilio/phpunit.xml.dist | 31 +++ src/Symfony/Component/Notifier/CHANGELOG.md | 7 + .../Notifier/Channel/BrowserChannel.php | 50 +++++ .../Notifier/Channel/ChannelInterface.php | 29 +++ .../Notifier/Channel/ChannelPolicy.php | 38 ++++ .../Channel/ChannelPolicyInterface.php | 25 +++ .../Notifier/Channel/ChatChannel.php | 64 ++++++ .../Notifier/Channel/EmailChannel.php | 85 ++++++++ .../Component/Notifier/Channel/SmsChannel.php | 62 ++++++ src/Symfony/Component/Notifier/Chatter.php | 63 ++++++ .../Component/Notifier/ChatterInterface.php | 23 +++ .../NotificationDataCollector.php | 62 ++++++ .../Component/Notifier/Event/MessageEvent.php | 42 ++++ .../Notifier/Event/NotificationEvents.php | 69 +++++++ .../NotificationLoggerListener.php | 57 ++++++ .../SendFailedMessageToNotifierListener.php | 58 ++++++ .../Notifier/Exception/ExceptionInterface.php | 23 +++ .../Exception/IncompleteDsnException.php | 21 ++ .../Exception/InvalidArgumentException.php | 21 ++ .../Notifier/Exception/LogicException.php | 21 ++ .../Notifier/Exception/RuntimeException.php | 21 ++ .../Notifier/Exception/TransportException.php | 43 ++++ .../Exception/TransportExceptionInterface.php | 22 ++ .../Exception/UnsupportedSchemeException.php | 63 ++++++ src/Symfony/Component/Notifier/LICENSE | 19 ++ .../Notifier/Message/ChatMessage.php | 97 +++++++++ .../Notifier/Message/EmailMessage.php | 115 +++++++++++ .../Notifier/Message/MessageInterface.php | 28 +++ .../Message/MessageOptionsInterface.php | 24 +++ .../Component/Notifier/Message/SmsMessage.php | 100 +++++++++ .../Notifier/Messenger/MessageHandler.php | 35 ++++ .../ChatNotificationInterface.php | 25 +++ .../EmailNotificationInterface.php | 25 +++ .../Notifier/Notification/Notification.php | 193 ++++++++++++++++++ .../Notification/SmsNotificationInterface.php | 25 +++ src/Symfony/Component/Notifier/Notifier.php | 109 ++++++++++ .../Component/Notifier/NotifierInterface.php | 25 +++ src/Symfony/Component/Notifier/README.md | 18 ++ .../Notifier/Receiver/AdminReceiver.php | 44 ++++ .../Notifier/Receiver/NoReceiver.php | 25 +++ .../Component/Notifier/Receiver/Receiver.php | 42 ++++ .../Receiver/SmsReceiverInterface.php | 27 +++ src/Symfony/Component/Notifier/Texter.php | 63 ++++++ .../Component/Notifier/TexterInterface.php | 23 +++ src/Symfony/Component/Notifier/Transport.php | 129 ++++++++++++ .../Notifier/Transport/AbstractTransport.php | 91 +++++++++ .../Transport/AbstractTransportFactory.php | 63 ++++++ .../Notifier/Transport/AllTransport.php | 62 ++++++ .../Component/Notifier/Transport/Dsn.php | 91 +++++++++ .../Notifier/Transport/FailoverTransport.php | 40 ++++ .../Notifier/Transport/NullTransport.php | 36 ++++ .../Transport/NullTransportFactory.php | 36 ++++ .../Transport/RoundRobinTransport.php | 128 ++++++++++++ .../Transport/TransportFactoryInterface.php | 31 +++ .../Notifier/Transport/TransportInterface.php | 32 +++ .../Notifier/Transport/Transports.php | 71 +++++++ src/Symfony/Component/Notifier/composer.json | 33 +++ .../Component/Notifier/phpunit.xml.dist | 31 +++ 107 files changed, 4557 insertions(+) create mode 100644 src/Symfony/Bridge/Monolog/Handler/NotifierHandler.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.xml create mode 100644 src/Symfony/Component/Notifier/.gitattributes create mode 100644 src/Symfony/Component/Notifier/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Nexmo/phpunit.xml.dist create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlock.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlockElement.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockElementInterface.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockInterface.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackDividerBlock.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlock.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlockElement.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackSectionBlock.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Slack/phpunit.xml.dist create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Telegram/phpunit.xml.dist create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Twilio/phpunit.xml.dist create mode 100644 src/Symfony/Component/Notifier/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Channel/BrowserChannel.php create mode 100644 src/Symfony/Component/Notifier/Channel/ChannelInterface.php create mode 100644 src/Symfony/Component/Notifier/Channel/ChannelPolicy.php create mode 100644 src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php create mode 100644 src/Symfony/Component/Notifier/Channel/ChatChannel.php create mode 100644 src/Symfony/Component/Notifier/Channel/EmailChannel.php create mode 100644 src/Symfony/Component/Notifier/Channel/SmsChannel.php create mode 100644 src/Symfony/Component/Notifier/Chatter.php create mode 100644 src/Symfony/Component/Notifier/ChatterInterface.php create mode 100644 src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php create mode 100644 src/Symfony/Component/Notifier/Event/MessageEvent.php create mode 100644 src/Symfony/Component/Notifier/Event/NotificationEvents.php create mode 100644 src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php create mode 100644 src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php create mode 100644 src/Symfony/Component/Notifier/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php create mode 100644 src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/Notifier/Exception/LogicException.php create mode 100644 src/Symfony/Component/Notifier/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/Notifier/Exception/TransportException.php create mode 100644 src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php create mode 100644 src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php create mode 100644 src/Symfony/Component/Notifier/LICENSE create mode 100644 src/Symfony/Component/Notifier/Message/ChatMessage.php create mode 100644 src/Symfony/Component/Notifier/Message/EmailMessage.php create mode 100644 src/Symfony/Component/Notifier/Message/MessageInterface.php create mode 100644 src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php create mode 100644 src/Symfony/Component/Notifier/Message/SmsMessage.php create mode 100644 src/Symfony/Component/Notifier/Messenger/MessageHandler.php create mode 100644 src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php create mode 100644 src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php create mode 100644 src/Symfony/Component/Notifier/Notification/Notification.php create mode 100644 src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php create mode 100644 src/Symfony/Component/Notifier/Notifier.php create mode 100644 src/Symfony/Component/Notifier/NotifierInterface.php create mode 100644 src/Symfony/Component/Notifier/README.md create mode 100644 src/Symfony/Component/Notifier/Receiver/AdminReceiver.php create mode 100644 src/Symfony/Component/Notifier/Receiver/NoReceiver.php create mode 100644 src/Symfony/Component/Notifier/Receiver/Receiver.php create mode 100644 src/Symfony/Component/Notifier/Receiver/SmsReceiverInterface.php create mode 100644 src/Symfony/Component/Notifier/Texter.php create mode 100644 src/Symfony/Component/Notifier/TexterInterface.php create mode 100644 src/Symfony/Component/Notifier/Transport.php create mode 100644 src/Symfony/Component/Notifier/Transport/AbstractTransport.php create mode 100644 src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Transport/AllTransport.php create mode 100644 src/Symfony/Component/Notifier/Transport/Dsn.php create mode 100644 src/Symfony/Component/Notifier/Transport/FailoverTransport.php create mode 100644 src/Symfony/Component/Notifier/Transport/NullTransport.php create mode 100644 src/Symfony/Component/Notifier/Transport/NullTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php create mode 100644 src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php create mode 100644 src/Symfony/Component/Notifier/Transport/TransportInterface.php create mode 100644 src/Symfony/Component/Notifier/Transport/Transports.php create mode 100644 src/Symfony/Component/Notifier/composer.json create mode 100644 src/Symfony/Component/Notifier/phpunit.xml.dist diff --git a/composer.json b/composer.json index 571a9bd1d79fc..821c279c1b4aa 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "symfony/messenger": "self.version", "symfony/mime": "self.version", "symfony/monolog-bridge": "self.version", + "symfony/notifier": "self.version", "symfony/options-resolver": "self.version", "symfony/postmark-mailer": "self.version", "symfony/process": "self.version", diff --git a/src/Symfony/Bridge/Monolog/Handler/NotifierHandler.php b/src/Symfony/Bridge/Monolog/Handler/NotifierHandler.php new file mode 100644 index 0000000000000..2ecbefce52b56 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Handler/NotifierHandler.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Handler; + +use Monolog\Handler\AbstractHandler; +use Monolog\Logger; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Notifier; +use Symfony\Component\Notifier\NotifierInterface; + +/** + * Uses Notifier as a log handler. + * + * @author Fabien Potencier + */ +class NotifierHandler extends AbstractHandler +{ + private $notifier; + + public function __construct(NotifierInterface $notifier, int $level = Logger::ERROR, bool $bubble = true) + { + $this->notifier = $notifier; + + parent::__construct($level < Logger::ERROR ? Logger::ERROR : $level, $bubble); + } + + public function handle(array $record): bool + { + if (!$this->isHandling($record)) { + return false; + } + + $this->notify([$record]); + + return !$this->bubble; + } + + public function handleBatch(array $records): void + { + if ($records = array_filter($records, [$this, 'isHandling'])) { + $this->notify($records); + } + } + + private function notify(array $records): void + { + $record = max(array_column($records, 'level')); + if (($record['context']['exception'] ?? null) instanceof \Throwable) { + $notification = Notification::fromThrowable($record['context']['exception']); + } else { + $notification = new Notification($record['message']); + } + + $notification->importanceFromLogLevelName(Logger::getLevelName($record['level'])); + + try { + $this->notifier->send($notification, ...Notifier::getAdminReceivers()); + } catch (\Throwable $e) { + $message = $e->getMessage(); + while ($e = $e->getPrevious()) { + $message .= ' > '.$e->getMessage(); + } + error_log($message); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index e91026b149e80..cbb4de507cb6d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -27,6 +27,7 @@ use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Notifier; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; @@ -114,6 +115,7 @@ public function getConfigTreeBuilder() $this->addRobotsIndexSection($rootNode); $this->addHttpClientSection($rootNode); $this->addMailerSection($rootNode); + $this->addNotifierSection($rootNode); return $treeBuilder; } @@ -1475,4 +1477,50 @@ private function addMailerSection(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addNotifierSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('notifier') + ->info('Notifier configuration') + ->{!class_exists(FullStack::class) && class_exists(Notifier::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->fixXmlConfig('chatter_transport') + ->children() + ->arrayNode('chatter_transports') + ->useAttributeAsKey('name') + ->prototype('scalar')->end() + ->end() + ->end() + ->fixXmlConfig('texter_transport') + ->children() + ->arrayNode('texter_transports') + ->useAttributeAsKey('name') + ->prototype('scalar')->end() + ->end() + ->end() + ->children() + ->arrayNode('channel_policy') + ->useAttributeAsKey('name') + ->prototype('array') + ->beforeNormalization()->ifString()->then(function (string $v) { return [$v]; })->end() + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->fixXmlConfig('admin_receiver') + ->children() + ->arrayNode('admin_receivers') + ->prototype('array') + ->children() + ->scalarNode('email')->cannotBeEmpty()->end() + ->scalarNode('phone')->defaultNull()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c32c80d61c14f..421581d9dd003 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -89,6 +89,12 @@ use Symfony\Component\Messenger\Transport\TransportInterface; use Symfony\Component\Mime\MimeTypeGuesserInterface; use Symfony\Component\Mime\MimeTypes; +use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; +use Symfony\Component\Notifier\Bridge\Slack\SlackTransportFactory; +use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransportFactory; +use Symfony\Component\Notifier\Bridge\Twilio\TwilioTransportFactory; +use Symfony\Component\Notifier\Notifier; +use Symfony\Component\Notifier\Receiver\AdminReceiver; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; @@ -297,6 +303,10 @@ public function load(array $configs, ContainerBuilder $container) $this->registerMailerConfiguration($config['mailer'], $container, $loader); } + if ($this->isConfigEnabled($container, $config['notifier'])) { + $this->registerNotifierConfiguration($config['notifier'], $container, $loader); + } + $propertyInfoEnabled = $this->isConfigEnabled($container, $config['property_info']); $this->registerValidationConfiguration($config['validation'], $container, $loader, $propertyInfoEnabled); $this->registerEsiConfiguration($config['esi'], $container, $loader); @@ -1848,6 +1858,62 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co $envelopeListener->setArgument(1, $recipients); } + private function registerNotifierConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!class_exists(Notifier::class)) { + throw new LogicException('Notifier support cannot be enabled as the component is not installed. Try running "composer require symfony/notifier".'); + } + + $loader->load('notifier.xml'); + $loader->load('notifier_transports.xml'); + + if ($config['chatter_transports']) { + $container->getDefinition('chatter.transports')->setArgument(0, $config['chatter_transports']); + } else { + $container->removeDefinition('chatter'); + } + if ($config['texter_transports']) { + $container->getDefinition('texter.transports')->setArgument(0, $config['texter_transports']); + } else { + $container->removeDefinition('texter'); + } + + if ($this->mailerConfigEnabled) { + $sender = $container->getDefinition('mailer.envelope_listener')->getArgument(0); + $container->getDefinition('notifier.email_channel')->setArgument(1, $sender); + } else { + $container->removeDefinition('notifier.email_channel'); + } + + if (!$this->messengerConfigEnabled) { + $container->removeDefinition('notifier.failed_message_listener'); + } + + $container->getDefinition('notifier.channel_policy')->setArgument(0, $config['channel_policy']); + + $classToServices = [ + SlackTransportFactory::class => 'notifier.transport_factory.slack', + TelegramTransportFactory::class => 'notifier.transport_factory.telegram', + NexmoTransportFactory::class => 'notifier.transport_factory.nexmo', + TwilioTransportFactory::class => 'notifier.transport_factory.twilio', + ]; + + foreach ($classToServices as $class => $service) { + if (!class_exists($class)) { + $container->removeDefinition($service); + } + } + + if (isset($config['admin_receivers'])) { + $notifier = $container->getDefinition('notifier'); + foreach ($config['admin_receivers'] as $i => $receiver) { + $id = 'notifier.admin_receiver.'.$i; + $container->setDefinition($id, new Definition(AdminReceiver::class, [$receiver['email'], $receiver['phone']])); + $notifier->addMethodCall('addAdminReceiver', [new Reference($id)]); + } + } + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier.xml new file mode 100644 index 0000000000000..53229d600d255 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.xml new file mode 100644 index 0000000000000..c4d9cf892adca --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 07695069f8cf1..593603cbe21d9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Notifier; class ConfigurationTest extends TestCase { @@ -411,6 +412,13 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'transports' => [], 'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class), ], + 'notifier' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(Notifier::class), + 'chatter_transports' => [], + 'texter_transports' => [], + 'channel_policy' => [], + 'admin_receivers' => [], + ], 'error_controller' => 'error_controller', ]; } diff --git a/src/Symfony/Component/Notifier/.gitattributes b/src/Symfony/Component/Notifier/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/Notifier/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/Notifier/.gitignore b/src/Symfony/Component/Notifier/.gitignore new file mode 100644 index 0000000000000..5414c2c655e72 --- /dev/null +++ b/src/Symfony/Component/Notifier/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Nexmo/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md new file mode 100644 index 0000000000000..10f7e1ea8506e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.0.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/LICENSE b/src/Symfony/Component/Notifier/Bridge/Nexmo/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 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/Nexmo/NexmoTransport.php b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.php new file mode 100644 index 0000000000000..45e2ccefec04d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransport.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\Nexmo; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class NexmoTransport extends AbstractTransport +{ + protected const HOST = 'rest.nexmo.com'; + + private $apiKey; + private $apiSecret; + private $from; + + public function __construct(string $apiKey, string $apiSecret, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->apiKey = $apiKey; + $this->apiSecret = $apiSecret; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('nexmo://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): void + { + if (!$message instanceof SmsMessage) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, \get_class($message))); + } + + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/sms/json', [ + 'body' => [ + 'from' => $this->from, + 'to' => $message->getPhone(), + 'text' => $message->getSubject(), + 'api_key' => $this->apiKey, + 'api_secret' => $this->apiSecret, + ], + ]); + + $result = $response->toArray(false); + foreach ($result['messages'] as $message) { + if ($message['status'] ?? 0 > 0) { + throw new TransportException(sprintf('Unable to send the SMS: %s (%s).', $message['error-text'], $message['status']), $response); + } + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php new file mode 100644 index 0000000000000..21f2e080bd8e4 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/NexmoTransportFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Nexmo; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class NexmoTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + $apiKey = $this->getUser($dsn); + $apiSecret = $this->getPassword($dsn); + $from = $dsn->getOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('nexmo' === $scheme) { + return (new NexmoTransport($apiKey, $apiSecret, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + throw new UnsupportedSchemeException($dsn, 'nexmo', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['nexmo']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/README.md b/src/Symfony/Component/Notifier/Bridge/Nexmo/README.md new file mode 100644 index 0000000000000..091c7c73bd972 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/README.md @@ -0,0 +1,12 @@ +Nexmo Notifier +============== + +Provides Nexmo integration for Symfony Notifier. + +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/Nexmo/composer.json b/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json new file mode 100644 index 0000000000000..1356691a3b910 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/nexmo-notifier", + "type": "symfony-bridge", + "description": "Symfony Nexmo Notifier Bridge", + "keywords": ["sms", "nexmo", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Twilio\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Nexmo/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Nexmo/phpunit.xml.dist new file mode 100644 index 0000000000000..83cdbf8e8ca30 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Nexmo/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Slack/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlock.php new file mode 100644 index 0000000000000..8794162b1205d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlock.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +abstract class AbstractSlackBlock implements SlackBlockInterface +{ + protected $options = []; + + public function toArray(): array + { + return $this->options; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlockElement.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlockElement.php new file mode 100644 index 0000000000000..66895ce26e22d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/AbstractSlackBlockElement.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +abstract class AbstractSlackBlockElement implements SlackBlockElementInterface +{ + protected $options = []; + + public function toArray(): array + { + return $this->options; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockElementInterface.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockElementInterface.php new file mode 100644 index 0000000000000..bc806e8f7df06 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockElementInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +interface SlackBlockElementInterface +{ + public function toArray(): array; +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockInterface.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockInterface.php new file mode 100644 index 0000000000000..4c28dc682f603 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackBlockInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +interface SlackBlockInterface +{ + public function toArray(): array; +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackDividerBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackDividerBlock.php new file mode 100644 index 0000000000000..0579b36d34d74 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackDividerBlock.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +final class SlackDividerBlock extends AbstractSlackBlock +{ + public function __construct() + { + $this->options = ['type' => 'divider']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlock.php new file mode 100644 index 0000000000000..8465805e65da3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlock.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +final class SlackImageBlock extends AbstractSlackBlock +{ + public function __construct(string $url, string $text) + { + $this->options = [ + 'type' => 'image', + 'image_url' => $url, + 'alt_text' => $text, + ]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlockElement.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlockElement.php new file mode 100644 index 0000000000000..be63948de0599 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackImageBlockElement.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +final class SlackImageBlockElement extends AbstractSlackBlockElement +{ + public function __construct(string $url, string $text) + { + $this->options = [ + 'type' => 'image', + 'image_url' => $url, + 'alt_text' => $text, + ]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackSectionBlock.php b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackSectionBlock.php new file mode 100644 index 0000000000000..4bda10f90527d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/Block/SlackSectionBlock.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack\Block; + +/** + * @author Fabien Potencier + */ +final class SlackSectionBlock extends AbstractSlackBlock +{ + public function __construct() + { + $this->options['type'] = 'section'; + } + + /** + * @return $this + */ + public function text(string $text, bool $markdown = true): self + { + $this->options['text'] = [ + 'type' => $markdown ? 'mrkdwn' : 'plain_text', + 'text' => $text, + ]; + + return $this; + } + + /** + * @return $this + */ + public function accessory(SlackBlockElementInterface $element): self + { + $this->options['accessory'] = $element->toArray(); + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md new file mode 100644 index 0000000000000..10f7e1ea8506e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.0.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE b/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 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/Slack/README.md b/src/Symfony/Component/Notifier/Bridge/Slack/README.md new file mode 100644 index 0000000000000..00c66a4c222fa --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/README.md @@ -0,0 +1,12 @@ +Slack Notifier +============== + +Provides Slack integration for Symfony Notifier. + +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/Slack/SlackOptions.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php new file mode 100644 index 0000000000000..f26da95567b7d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackOptions.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack; + +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackBlockInterface; +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock; +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Notification\Notification; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class SlackOptions implements MessageOptionsInterface +{ + private $options = []; + + public function __construct(array $options = []) + { + $this->options = $options; + } + + public static function fromNotification(Notification $notification): self + { + $options = new self(); + $options->iconEmoji($notification->getEmoji()); + $options->block((new SlackSectionBlock())->text($notification->getSubject())); + if ($notification->getContent()) { + $options->block((new SlackSectionBlock())->text($notification->getContent())); + } + if ($exception = $notification->getExceptionAsString()) { + $options->block(new SlackDividerBlock()); + $options->block((new SlackSectionBlock())->text($exception)); + } + + return $options; + } + + public function toArray(): array + { + $options = $this->options; + if (isset($options['blocks'])) { + $options['blocks'] = json_encode($options['blocks']); + } + unset($options['recipient_id']); + + return $options; + } + + public function getRecipientId(): ?string + { + return $this->options['recipient_id'] ?? null; + } + + /** + * @return $this + */ + public function channel(string $channel): self + { + $this->options['channel'] = $channel; + $this->options['recipient_id'] = $channel; + + return $this; + } + + /** + * @return $this + */ + public function asUser(bool $bool): self + { + $this->options['as_user'] = $bool; + + return $this; + } + + /** + * @return $this + */ + public function block(SlackBlockInterface $block): self + { + $this->options['blocks'][] = $block->toArray(); + + return $this; + } + + /** + * @return $this + */ + public function iconEmoji(string $emoji): self + { + $this->options['icon_emoji'] = $emoji; + + return $this; + } + + /** + * @return $this + */ + public function iconUrl(string $url): self + { + $this->options['icon_url'] = $url; + + return $this; + } + + /** + * @return $this + */ + public function linkNames(bool $bool): self + { + $this->options['link_names'] = $bool; + + return $this; + } + + /** + * @return $this + */ + public function mrkdwn(bool $bool): self + { + $this->options['mrkdwn'] = $bool; + + return $this; + } + + /** + * @return $this + */ + public function parse(string $parse): self + { + $this->options['parse'] = $parse; + + return $this; + } + + /** + * @return $this + */ + public function unfurlLinks(bool $bool): self + { + $this->options['unfurl_links'] = $bool; + + return $this; + } + + /** + * @return $this + */ + public function unfurlMedia(bool $bool): self + { + $this->options['unfurl_media'] = $bool; + + return $this; + } + + /** + * @return $this + */ + public function username(string $username): self + { + $this->options['username'] = $username; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php new file mode 100644 index 0000000000000..566ee7a25f608 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransport.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @internal + * + * @experimental in 5.0 + */ +final class SlackTransport extends AbstractTransport +{ + protected const HOST = 'slack.com'; + + private $accessToken; + private $chatChannel; + + public function __construct(string $accessToken, string $chatChannel = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->accessToken = $accessToken; + $this->chatChannel = $chatChannel; + $this->client = $client; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('slack://%s?channel=%s', $this->getEndpoint(), $this->chatChannel); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof SlackOptions); + } + + /** + * @see https://api.slack.com/methods/chat.postMessage + */ + protected function doSend(MessageInterface $message): void + { + if (!$message instanceof ChatMessage) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, \get_class($message))); + } + if ($message->getOptions() && !$message->getOptions() instanceof SlackOptions) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, SlackOptions::class)); + } + + if (!($opts = $message->getOptions()) && $notification = $message->getNotification()) { + $opts = SlackOptions::fromNotification($notification); + } + + $options = $opts ? $opts->toArray() : []; + $options['token'] = $this->accessToken; + if (!isset($options['channel'])) { + $options['channel'] = $message->getRecipientId() ?: $this->chatChannel; + } + $options['text'] = $message->getSubject(); + $response = $this->client->request('POST', 'https://'.$this->getEndpoint().'/api/chat.postMessage', [ + 'body' => array_filter($options), + ]); + + if (200 !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to post the Slack message: %s.', $response->getContent(false)), $response); + } + + $result = $response->toArray(false); + if (!$result['ok']) { + throw new TransportException(sprintf('Unable to post the Slack message: %s.', $result['error']), $response); + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php new file mode 100644 index 0000000000000..6e12c0b218a63 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/SlackTransportFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Slack; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class SlackTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + $accessToken = $this->getUser($dsn); + $channel = $dsn->getOption('channel'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('slack' === $scheme) { + return (new SlackTransport($accessToken, $channel, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + throw new UnsupportedSchemeException($dsn, 'slack', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['slack']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/composer.json b/src/Symfony/Component/Notifier/Bridge/Slack/composer.json new file mode 100644 index 0000000000000..0c60259d1e772 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/slack-notifier", + "type": "symfony-bridge", + "description": "Symfony Slack Notifier Bridge", + "keywords": ["slack", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Slack\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Slack/phpunit.xml.dist new file mode 100644 index 0000000000000..0bc2fb6e69614 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Slack/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Telegram/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md new file mode 100644 index 0000000000000..10f7e1ea8506e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.0.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE b/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 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/Telegram/README.md b/src/Symfony/Component/Notifier/Bridge/Telegram/README.md new file mode 100644 index 0000000000000..7f9b6c9c2da67 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/README.md @@ -0,0 +1,12 @@ +Telegram Notifier +================= + +Provides Telegram integration for Symfony Notifier. + +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/Telegram/TelegramTransport.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php new file mode 100644 index 0000000000000..eb6f2116e7fbb --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransport.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Telegram; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * TelegramTransport. + * + * To get the chat id, send a message in Telegram with the user you want + * and then curl 'https://api.telegram.org/bot%token%/getUpdates' | json_pp + * + * @author Fabien Potencier + * + * @internal + * + * @experimental in 5.0 + */ +final class TelegramTransport extends AbstractTransport +{ + protected const HOST = 'api.telegram.org'; + + private $token; + private $chatChannel; + + public function __construct(string $token, string $chatChannel, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->token = $token; + $this->chatChannel = $chatChannel; + $this->client = $client; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('telegram://%s?channel=%s', $this->getEndpoint(), $this->chatChannel); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage; + } + + /** + * @see https://core.telegram.org/bots/api + */ + protected function doSend(MessageInterface $message): void + { + if (!$message instanceof ChatMessage) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, \get_class($message))); + } + + $endpoint = sprintf('https://%s/bot%s/sendMessage', $this->getEndpoint(), $this->token); + $options = ($opts = $message->getOptions()) ? $opts->toArray() : []; + if (!isset($options['chat_id'])) { + $options['chat_id'] = $message->getRecipientId() ?: $this->chatChannel; + } + $options['text'] = $message->getSubject(); + $options['parse_mode'] = 'Markdown'; + $response = $this->client->request('POST', $endpoint, [ + 'json' => array_filter($options), + ]); + + if (200 !== $response->getStatusCode()) { + $result = $response->toArray(false); + + throw new TransportException(sprintf('Unable to post the Telegram message: %s (%s).', $result['description'], $result['error_code']), $response); + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php new file mode 100644 index 0000000000000..f7ade3c555bd0 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/TelegramTransportFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Telegram; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class TelegramTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + $token = $this->getUser($dsn); + $channel = $dsn->getOption('channel'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('telegram' === $scheme) { + return (new TelegramTransport($token, $channel, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + throw new UnsupportedSchemeException($dsn, 'telegram', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['telegram']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json b/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json new file mode 100644 index 0000000000000..b04f3d28182a2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/telegram-notifier", + "type": "symfony-bridge", + "description": "Symfony Telegram Notifier Bridge", + "keywords": ["telegram", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Telegram\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Telegram/phpunit.xml.dist new file mode 100644 index 0000000000000..17d18ccbb68c7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Telegram/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Twilio/.gitattributes new file mode 100644 index 0000000000000..aa02dc6518d99 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md new file mode 100644 index 0000000000000..10f7e1ea8506e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.0.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE b/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 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/Twilio/README.md b/src/Symfony/Component/Notifier/Bridge/Twilio/README.md new file mode 100644 index 0000000000000..d86bef51b9f4c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/README.md @@ -0,0 +1,12 @@ +Twilio Notifier +=============== + +Provides Twilio integration for Symfony Notifier. + +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/Twilio/TwilioTransport.php b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php new file mode 100644 index 0000000000000..79131282aca1a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransport.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Twilio; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class TwilioTransport extends AbstractTransport +{ + protected const HOST = 'api.twilio.com'; + + private $accountSid; + private $authToken; + private $from; + + public function __construct(string $accountSid, string $authToken, string $from, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->accountSid = $accountSid; + $this->authToken = $authToken; + $this->from = $from; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('twilio://%s?from=%s', $this->getEndpoint(), $this->from); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof SmsMessage; + } + + protected function doSend(MessageInterface $message): void + { + if (!$message instanceof SmsMessage) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, SmsMessage::class, \get_class($message))); + } + + $endpoint = sprintf('https://%s/2010-04-01/Accounts/%s/Messages.json', $this->getEndpoint(), $this->accountSid); + $response = $this->client->request('POST', $endpoint, [ + 'auth_basic' => $this->accountSid.':'.$this->authToken, + 'body' => [ + 'From' => $this->from, + 'To' => $message->getPhone(), + 'Body' => $message->getSubject(), + ], + ]); + + if (201 !== $response->getStatusCode()) { + $error = $response->toArray(false); + + throw new TransportException(sprintf('Unable to send the SMS: %s (see %s).', $error['message'], $error['more_info']), $response); + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php new file mode 100644 index 0000000000000..8353eec7e6810 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/TwilioTransportFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Twilio; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class TwilioTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + $accountSid = $this->getUser($dsn); + $authToken = $this->getPassword($dsn); + $from = $dsn->getOption('from'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('twilio' === $scheme) { + return (new TwilioTransport($accountSid, $authToken, $from, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + throw new UnsupportedSchemeException($dsn, 'twilio', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['twilio']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json b/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json new file mode 100644 index 0000000000000..9eed152551b28 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/twilio-notifier", + "type": "symfony-bridge", + "description": "Symfony Twilio Notifier Bridge", + "keywords": ["sms", "twilio", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Twilio\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Twilio/phpunit.xml.dist new file mode 100644 index 0000000000000..befd94d20307f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Twilio/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/CHANGELOG.md b/src/Symfony/Component/Notifier/CHANGELOG.md new file mode 100644 index 0000000000000..e2dde962bceda --- /dev/null +++ b/src/Symfony/Component/Notifier/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.0.0 +----- + + * Introduced the component as experimental diff --git a/src/Symfony/Component/Notifier/Channel/BrowserChannel.php b/src/Symfony/Component/Notifier/Channel/BrowserChannel.php new file mode 100644 index 0000000000000..09778a9b1b4cb --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/BrowserChannel.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class BrowserChannel implements ChannelInterface +{ + private $stack; + + public function __construct(RequestStack $stack) + { + $this->stack = $stack; + } + + public function notify(Notification $notification, Receiver $receiver, string $transportName = null, MessageBusInterface $bus = null): void + { + if (null === $request = $this->stack->getCurrentRequest()) { + return; + } + + $message = $notification->getSubject(); + if ($notification->getEmoji()) { + $message = $notification->getEmoji().' '.$message; + } + $request->getSession()->getFlashBag()->add('notification', $message); + } + + public function supports(Notification $notification, Receiver $receiver): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Notifier/Channel/ChannelInterface.php b/src/Symfony/Component/Notifier/Channel/ChannelInterface.php new file mode 100644 index 0000000000000..38533564249fb --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/ChannelInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface ChannelInterface +{ + public function notify(Notification $notification, Receiver $receiver, string $transportName = null, MessageBusInterface $bus = null): void; + + public function supports(Notification $notification, Receiver $receiver): bool; +} diff --git a/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php b/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php new file mode 100644 index 0000000000000..fe8c48ab9fbf1 --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/ChannelPolicy.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class ChannelPolicy implements ChannelPolicyInterface +{ + private $policy; + + public function __construct(array $policy) + { + $this->policy = $policy; + } + + public function getChannels(string $importance): array + { + if (!isset($this->policy[$importance])) { + throw new InvalidArgumentException(sprintf('Importance "%s" does not exist.', $importance)); + } + + return $this->policy[$importance]; + } +} diff --git a/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php b/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php new file mode 100644 index 0000000000000..3fb7bd0cfa0e6 --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/ChannelPolicyInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface ChannelPolicyInterface +{ + /** + * @return string[] + */ + public function getChannels(string $importance): array; +} diff --git a/src/Symfony/Component/Notifier/Channel/ChatChannel.php b/src/Symfony/Component/Notifier/Channel/ChatChannel.php new file mode 100644 index 0000000000000..aba6b820876a8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/ChatChannel.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Notification\ChatNotificationInterface; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class ChatChannel implements ChannelInterface +{ + private $transport; + + public function __construct(TransportInterface $transport) + { + $this->transport = $transport; + } + + public function notify(Notification $notification, Receiver $receiver, string $transportName = null, MessageBusInterface $bus = null): void + { + if (null === $transportName) { + throw new LogicException('A Chat notification must have a transport defined.'); + } + + $message = null; + if ($notification instanceof ChatNotificationInterface) { + $message = $notification->asChatMessage($receiver, $transportName); + } + + if (null === $message) { + $message = ChatMessage::fromNotification($notification, $receiver, $transportName); + } + + $message->transport($transportName); + + if (null === $bus) { + $this->transport->send($message); + } else { + $bus->dispatch($message); + } + } + + public function supports(Notification $notification, Receiver $receiver): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Notifier/Channel/EmailChannel.php b/src/Symfony/Component/Notifier/Channel/EmailChannel.php new file mode 100644 index 0000000000000..402582333fdca --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/EmailChannel.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Messenger\SendEmailMessage; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Mime\Email; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Message\EmailMessage; +use Symfony\Component\Notifier\Notification\EmailNotificationInterface; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class EmailChannel implements ChannelInterface +{ + private $transport; + private $from; + private $envelope; + + public function __construct(TransportInterface $transport, string $from = null, Envelope $envelope = null) + { + $this->transport = $transport; + $this->from = $from ?: ($envelope ? $envelope->getSender() : null); + $this->envelope = $envelope; + } + + public function notify(Notification $notification, Receiver $receiver, string $transportName = null, MessageBusInterface $bus = null): void + { + $message = null; + if ($notification instanceof EmailNotificationInterface) { + $message = $notification->asEmailMessage($receiver, $transportName); + } + + $message = $message ?: EmailMessage::fromNotification($notification, $receiver, $transportName); + $email = $message->getMessage(); + if ($email instanceof Email) { + if (!$email->getFrom()) { + if (null === $this->from) { + throw new LogicException(sprintf('To send the "%s" notification by email, you should either configure a global "from" or set it in the "asEmailMessage()" method.', \get_class($notification))); + } + + $email->from($this->from); + } + + if (!$email->getTo()) { + $email->to($receiver->getEmail()); + } + } + + if (null !== $this->envelope) { + $message->envelope($this->envelope); + } + + if (null !== $transportName) { + $message->transport($transportName); + } + + if (null === $bus) { + $this->transport->send($message->getMessage(), $message->getEnvelope()); + } else { + $bus->dispatch(new SendEmailMessage($message->getMessage(), $message->getEnvelope())); + } + } + + public function supports(Notification $notification, Receiver $receiver): bool + { + return '' !== $receiver->getEmail(); + } +} diff --git a/src/Symfony/Component/Notifier/Channel/SmsChannel.php b/src/Symfony/Component/Notifier/Channel/SmsChannel.php new file mode 100644 index 0000000000000..25ee4771423d1 --- /dev/null +++ b/src/Symfony/Component/Notifier/Channel/SmsChannel.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Channel; + +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Notification\SmsNotificationInterface; +use Symfony\Component\Notifier\Receiver\Receiver; +use Symfony\Component\Notifier\Receiver\SmsReceiverInterface; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class SmsChannel implements ChannelInterface +{ + private $transport; + + public function __construct(TransportInterface $transport) + { + $this->transport = $transport; + } + + public function notify(Notification $notification, Receiver $receiver, string $transportName = null, MessageBusInterface $bus = null): void + { + $message = null; + if ($notification instanceof SmsNotificationInterface) { + $message = $notification->asSmsMessage($receiver, $transportName); + } + + if (null === $message) { + $message = SmsMessage::fromNotification($notification, $receiver, $transportName); + } + + if (null !== $transportName) { + $message->transport($transportName); + } + + if (null === $bus) { + $this->transport->send($message); + } else { + $bus->dispatch($message); + } + } + + public function supports(Notification $notification, Receiver $receiver): bool + { + return $receiver instanceof SmsReceiverInterface && '' !== $receiver->getPhone(); + } +} diff --git a/src/Symfony/Component/Notifier/Chatter.php b/src/Symfony/Component/Notifier/Chatter.php new file mode 100644 index 0000000000000..63d75f25098b9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Chatter.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Event\MessageEvent; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class Chatter implements ChatterInterface +{ + private $transport; + private $bus; + private $dispatcher; + + public function __construct(TransportInterface $transport, MessageBusInterface $bus = null, EventDispatcherInterface $dispatcher = null) + { + $this->transport = $transport; + $this->bus = $bus; + $this->dispatcher = LegacyEventDispatcherProxy::decorate($dispatcher); + } + + public function __toString(): string + { + return 'chat'; + } + + public function supports(MessageInterface $message): bool + { + return $this->transport->supports($message); + } + + public function send(MessageInterface $message): void + { + if (null === $this->bus) { + $this->transport->send($message); + + return; + } + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(new MessageEvent($message, true)); + } + + $this->bus->dispatch($message); + } +} diff --git a/src/Symfony/Component/Notifier/ChatterInterface.php b/src/Symfony/Component/Notifier/ChatterInterface.php new file mode 100644 index 0000000000000..0ac67a1d4266d --- /dev/null +++ b/src/Symfony/Component/Notifier/ChatterInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface ChatterInterface extends TransportInterface +{ +} diff --git a/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php b/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php new file mode 100644 index 0000000000000..70ad0a82c0c94 --- /dev/null +++ b/src/Symfony/Component/Notifier/DataCollector/NotificationDataCollector.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\DataCollector; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\Notifier\Event\NotificationEvents; +use Symfony\Component\Notifier\EventListener\NotificationLoggerListener; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class NotificationDataCollector extends DataCollector +{ + private $logger; + + public function __construct(NotificationLoggerListener $logger) + { + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + $this->data['events'] = $this->logger->getEvents(); + } + + public function getEvents(): NotificationEvents + { + return $this->data['events']; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + $this->data = []; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'notifier'; + } +} diff --git a/src/Symfony/Component/Notifier/Event/MessageEvent.php b/src/Symfony/Component/Notifier/Event/MessageEvent.php new file mode 100644 index 0000000000000..f4ef135cb950a --- /dev/null +++ b/src/Symfony/Component/Notifier/Event/MessageEvent.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Event; + +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class MessageEvent extends Event +{ + private $message; + private $queued; + + public function __construct(MessageInterface $message, bool $queued = false) + { + $this->message = $message; + $this->queued = $queued; + } + + public function getMessage(): MessageInterface + { + return $this->message; + } + + public function isQueued(): bool + { + return $this->queued; + } +} diff --git a/src/Symfony/Component/Notifier/Event/NotificationEvents.php b/src/Symfony/Component/Notifier/Event/NotificationEvents.php new file mode 100644 index 0000000000000..218fc109ad483 --- /dev/null +++ b/src/Symfony/Component/Notifier/Event/NotificationEvents.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Event; + +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class NotificationEvents +{ + private $events = []; + private $transports = []; + + public function add(MessageEvent $event): void + { + $this->events[] = $event; + $this->transports[\get_class($event->getMessage())] = true; + } + + public function getTransports(): array + { + return array_keys($this->transports); + } + + /** + * @return MessageEvent[] + */ + public function getEvents(string $name = null): array + { + if (null === $name) { + return $this->events; + } + + $events = []; + foreach ($this->events as $event) { + if ($name === \get_class($event)) { + $events[] = $event; + } + } + + return $events; + } + + /** + * @return MessageInterface[] + */ + public function getMessages(string $name = null): array + { + $events = $this->getEvents($name); + $messages = []; + foreach ($events as $event) { + $messages[] = $event->getMessage(); + } + + return $messages; + } +} diff --git a/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php b/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php new file mode 100644 index 0000000000000..a4d47c3329840 --- /dev/null +++ b/src/Symfony/Component/Notifier/EventListener/NotificationLoggerListener.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Notifier\Event\MessageEvent; +use Symfony\Component\Notifier\Event\NotificationEvents; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class NotificationLoggerListener implements EventSubscriberInterface, ResetInterface +{ + private $events; + + public function __construct() + { + $this->events = new NotificationEvents(); + } + + /** + * {@inheritdoc} + */ + public function reset() + { + $this->events = new NotificationEvents(); + } + + public function onNotification(MessageEvent $event): void + { + $this->events->add($event); + } + + public function getEvents(): NotificationEvents + { + return $this->events; + } + + public static function getSubscribedEvents() + { + return [ + MessageEvent::class => ['onNotification', -255], + ]; + } +} diff --git a/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php b/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php new file mode 100644 index 0000000000000..a667092c1c853 --- /dev/null +++ b/src/Symfony/Component/Notifier/EventListener/SendFailedMessageToNotifierListener.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; +use Symfony\Component\Messenger\Exception\HandlerFailedException; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Notifier; + +/** + * Sends a rejected message to the notifier. + * + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class SendFailedMessageToNotifierListener implements EventSubscriberInterface +{ + private $notifier; + + public function __construct(Notifier $notifier) + { + $this->notifier = $notifier; + } + + public function onMessageFailed(WorkerMessageFailedEvent $event) + { + if ($event->willRetry()) { + return; + } + + $throwable = $event->getThrowable(); + if ($throwable instanceof HandlerFailedException) { + $throwable = $throwable->getNestedExceptions()[0]; + } + $envelope = $event->getEnvelope(); + $notification = Notification::fromThrowable($throwable)->importance(Notification::IMPORTANCE_HIGH); + $notification->subject(sprintf('A "%s" message has just failed: %s.', \get_class($envelope->getMessage()), $notification->getSubject())); + + $this->notifier->send($notification, ...Notifier::getAdminReceivers()); + } + + public static function getSubscribedEvents() + { + return [ + WorkerMessageFailedEvent::class => 'onMessageFailed', + ]; + } +} diff --git a/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php b/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..f651519f919b9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +/** + * Exception interface for all exceptions thrown by the component. + * + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php b/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php new file mode 100644 index 0000000000000..c503059b5983a --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/IncompleteDsnException.php @@ -0,0 +1,21 @@ + + * + * 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 Fabien Potencier + * + * @experimental in 5.0 + */ +class IncompleteDsnException extends InvalidArgumentException +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php b/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..c6f6db9566176 --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * 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 Fabien Potencier + * + * @experimental in 5.0 + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/LogicException.php b/src/Symfony/Component/Notifier/Exception/LogicException.php new file mode 100644 index 0000000000000..8ca68c43917da --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * 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 Fabien Potencier + * + * @experimental in 5.0 + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/RuntimeException.php b/src/Symfony/Component/Notifier/Exception/RuntimeException.php new file mode 100644 index 0000000000000..e16e76753b1f9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * 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 Fabien Potencier + * + * @experimental in 5.0 + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Notifier/Exception/TransportException.php b/src/Symfony/Component/Notifier/Exception/TransportException.php new file mode 100644 index 0000000000000..21c10fc01226f --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/TransportException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class TransportException extends RuntimeException implements TransportExceptionInterface +{ + private $response; + private $debug = ''; + + public function __construct(string $message, ResponseInterface $response, int $code = 0, \Exception $previous = null) + { + $this->response = $response; + $this->debug .= $response->getInfo('debug') ?? ''; + + parent::__construct($message, $code, $previous); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function getDebug(): string + { + return $this->debug; + } +} diff --git a/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php b/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.php new file mode 100644 index 0000000000000..655c309e6709f --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/TransportExceptionInterface.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\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface TransportExceptionInterface extends ExceptionInterface +{ + public function getDebug(): string; +} diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php new file mode 100644 index 0000000000000..31e5eed341e5e --- /dev/null +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Exception; + +use Symfony\Component\Notifier\Bridge; +use Symfony\Component\Notifier\Transport\Dsn; + +/** + * @author Konstantin Myakshin + * + * @experimental in 5.0 + */ +class UnsupportedSchemeException extends LogicException +{ + private const SCHEME_TO_PACKAGE_MAP = [ + 'slack' => [ + 'class' => Bridge\Slack\SlackTransportFactory::class, + 'package' => 'symfony/slack-notifier', + ], + 'telegram' => [ + 'class' => Bridge\Telegram\TelegramTransportFactory::class, + 'package' => 'symfony/telegram-notifier', + ], + 'nexmo' => [ + 'class' => Bridge\Nexmo\NexmoTransportFactory::class, + 'package' => 'symfony/nexmo-notifier', + ], + 'twilio' => [ + 'class' => Bridge\Twilio\TwilioTransportFactory::class, + 'package' => 'symfony/twilio-notifier', + ], + ]; + + public function __construct(Dsn $dsn, string $name = null, array $supported = []) + { + $provider = $dsn->getScheme(); + if (false !== $pos = strpos($provider, '+')) { + $provider = substr($provider, 0, $pos); + } + $package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null; + if ($package && !class_exists($package['class'])) { + parent::__construct(sprintf('Unable to send notification via "%s" as the bridge is not installed; try running "composer require %s".', $provider, $package['package'])); + + return; + } + + $message = sprintf('The "%s" scheme is not supported', $dsn->getScheme()); + if ($name && $supported) { + $message .= sprintf('; supported schemes for notifier "%s" are: "%s"', $name, implode('", "', $supported)); + } + + parent::__construct($message.'.'); + } +} diff --git a/src/Symfony/Component/Notifier/LICENSE b/src/Symfony/Component/Notifier/LICENSE new file mode 100644 index 0000000000000..1a1869751d250 --- /dev/null +++ b/src/Symfony/Component/Notifier/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 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/Message/ChatMessage.php b/src/Symfony/Component/Notifier/Message/ChatMessage.php new file mode 100644 index 0000000000000..a1c43a1e19029 --- /dev/null +++ b/src/Symfony/Component/Notifier/Message/ChatMessage.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Message; + +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class ChatMessage implements MessageInterface +{ + private $transport; + private $subject; + private $options; + private $notification; + + public function __construct(string $subject, MessageOptionsInterface $options = null) + { + $this->subject = $subject; + $this->options = $options; + } + + public static function fromNotification(Notification $notification, Receiver $receiver, string $transport = null): self + { + $message = new self($notification->getSubject()); + $message->notification = $notification; + + return $message; + } + + /** + * @return $this + */ + public function subject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function getRecipientId(): ?string + { + return $this->options ? $this->options->getRecipientId() : null; + } + + /** + * @return $this + */ + public function options(MessageOptionsInterface $options): self + { + $this->options = $options; + + return $this; + } + + public function getOptions(): ?MessageOptionsInterface + { + return $this->options; + } + + /** + * @return $this + */ + public function transport(string $transport): self + { + $this->transport = $transport; + + return $this; + } + + public function getTransport(): ?string + { + return $this->transport; + } + + public function getNotification(): ?Notification + { + return $this->notification; + } +} diff --git a/src/Symfony/Component/Notifier/Message/EmailMessage.php b/src/Symfony/Component/Notifier/Message/EmailMessage.php new file mode 100644 index 0000000000000..0f7d47e269848 --- /dev/null +++ b/src/Symfony/Component/Notifier/Message/EmailMessage.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Message; + +use Symfony\Bridge\Twig\Mime\NotificationEmail; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\RawMessage; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class EmailMessage implements MessageInterface +{ + private $message; + private $envelope; + + public function __construct(RawMessage $message, Envelope $envelope = null) + { + $this->message = $message; + $this->envelope = $envelope; + } + + public static function fromNotification(Notification $notification, Receiver $receiver, string $transport = null): self + { + if (!class_exists(NotificationEmail::class)) { + $email = (new Email()) + ->to($receiver->getEmail()) + ->subject($notification->getSubject()) + ->text($notification->getContent() ?: $notification->getSubject()) + ; + } else { + $email = (new NotificationEmail()) + ->to($receiver->getEmail()) + ->subject($notification->getSubject()) + ->content($notification->getContent() ?: $notification->getSubject()) + ->importance($notification->getImportance()) + ; + + if ($exception = $notification->getException()) { + $email->exception($exception); + } + } + + return new self($email); + } + + public function getMessage(): RawMessage + { + return $this->message; + } + + public function getEnvelope(): ?Envelope + { + return $this->envelope; + } + + /** + * @return $this + */ + public function envelope(Envelope $envelope): self + { + $this->envelope = $envelope; + + return $this; + } + + public function getSubject(): string + { + return ''; + } + + public function getRecipientId(): ?string + { + return null; + } + + public function getOptions(): ?MessageOptionsInterface + { + return null; + } + + /** + * @return $this + */ + public function transport(string $transport): self + { + if (!$this->message instanceof Email) { + throw new LogicException('Cannot set a Transport on a RawMessage instance.'); + } + + $this->message->getHeaders()->addTextHeader('X-Transport', $transport); + + return $this; + } + + public function getTransport(): ?string + { + return $this->message instanceof Email ? $this->message->getHeaders()->getHeaderBody('X-Transport') : null; + } +} diff --git a/src/Symfony/Component/Notifier/Message/MessageInterface.php b/src/Symfony/Component/Notifier/Message/MessageInterface.php new file mode 100644 index 0000000000000..9ea8e9a8d45f4 --- /dev/null +++ b/src/Symfony/Component/Notifier/Message/MessageInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Message; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface MessageInterface +{ + public function getRecipientId(): ?string; + + public function getSubject(): string; + + public function getOptions(): ?MessageOptionsInterface; + + public function getTransport(): ?string; +} diff --git a/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php b/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php new file mode 100644 index 0000000000000..84af831de3ac8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Message/MessageOptionsInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Message; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface MessageOptionsInterface +{ + public function toArray(): array; + + public function getRecipientId(): ?string; +} diff --git a/src/Symfony/Component/Notifier/Message/SmsMessage.php b/src/Symfony/Component/Notifier/Message/SmsMessage.php new file mode 100644 index 0000000000000..583a1ae50f325 --- /dev/null +++ b/src/Symfony/Component/Notifier/Message/SmsMessage.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Message; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Notification\SmsNotificationInterface; +use Symfony\Component\Notifier\Receiver\Receiver; +use Symfony\Component\Notifier\Receiver\SmsReceiverInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class SmsMessage implements MessageInterface +{ + private $transport; + private $subject; + private $phone; + + public function __construct(string $phone, string $subject) + { + $this->subject = $subject; + $this->phone = $phone; + } + + public static function fromNotification(Notification $notification, Receiver $receiver, string $transport = null): self + { + if (!$receiver instanceof SmsReceiverInterface) { + throw new LogicException(sprintf('To send a SMS message, "%s" should implement "%s" or the receiver should implement "%s".', get_class($notification), SmsNotificationInterface::class, SmsReceiverInterface::class)); + } + + return new self($receiver->getPhone(), $notification->getSubject()); + } + + /** + * @return $this + */ + public function phone(string $phone): self + { + $this->phone = $phone; + + return $this; + } + + public function getPhone(): string + { + return $this->phone; + } + + public function getRecipientId(): ?string + { + return $this->phone; + } + + /** + * @return $this + */ + public function subject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + /** + * @return $this + */ + public function transport(string $transport): self + { + $this->transport = $transport; + + return $this; + } + + public function getTransport(): ?string + { + return $this->transport; + } + + public function getOptions(): ?MessageOptionsInterface + { + return null; + } +} diff --git a/src/Symfony/Component/Notifier/Messenger/MessageHandler.php b/src/Symfony/Component/Notifier/Messenger/MessageHandler.php new file mode 100644 index 0000000000000..9ffbe2bd73825 --- /dev/null +++ b/src/Symfony/Component/Notifier/Messenger/MessageHandler.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Messenger; + +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class MessageHandler +{ + private $transport; + + public function __construct(TransportInterface $transport) + { + $this->transport = $transport; + } + + public function __invoke(MessageInterface $message) + { + $this->transport->send($message); + } +} diff --git a/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php new file mode 100644 index 0000000000000..a6791ec1b3a7a --- /dev/null +++ b/src/Symfony/Component/Notifier/Notification/ChatNotificationInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Notification; + +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface ChatNotificationInterface +{ + public function asChatMessage(Receiver $receiver, string $transport = null): ?ChatMessage; +} diff --git a/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php new file mode 100644 index 0000000000000..2686895d1c2a2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Notification/EmailNotificationInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Notification; + +use Symfony\Component\Notifier\Message\EmailMessage; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface EmailNotificationInterface +{ + public function asEmailMessage(Receiver $receiver, string $transport = null): ?EmailMessage; +} diff --git a/src/Symfony/Component/Notifier/Notification/Notification.php b/src/Symfony/Component/Notifier/Notification/Notification.php new file mode 100644 index 0000000000000..1b3ea2555ae0c --- /dev/null +++ b/src/Symfony/Component/Notifier/Notification/Notification.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Notification; + +use Psr\Log\LogLevel; +use Symfony\Component\ErrorRenderer\Exception\FlattenException; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class Notification +{ + private const LEVELS = [ + LogLevel::DEBUG => 100, + LogLevel::INFO => 200, + LogLevel::NOTICE => 250, + LogLevel::WARNING => 300, + LogLevel::ERROR => 400, + LogLevel::CRITICAL => 500, + LogLevel::ALERT => 550, + LogLevel::EMERGENCY => 600, + ]; + + public const IMPORTANCE_URGENT = 'urgent'; + public const IMPORTANCE_HIGH = 'high'; + public const IMPORTANCE_MEDIUM = 'medium'; + public const IMPORTANCE_LOW = 'low'; + + private $channels; + private $subject; + private $content = ''; + private $emoji = ''; + private $exception; + private $importance = self::IMPORTANCE_HIGH; + + public function __construct(string $subject = '', array $channels = []) + { + $this->subject = $subject; + $this->channels = $channels; + } + + public static function fromThrowable(\Throwable $exception, array $channels = []): self + { + $parts = explode('\\', \get_class($exception)); + + $notification = new self(sprintf('%s: %s', array_pop($parts), $exception->getMessage()), $channels); + $notification->exception = $exception; + + return $notification; + } + + /** + * @return $this + */ + public function subject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + /** + * @return $this + */ + public function content(string $content): self + { + $this->content = $content; + + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + /** + * @return $this + */ + public function importance(string $importance): self + { + $this->importance = $importance; + + return $this; + } + + public function getImportance(): string + { + return $this->importance; + } + + /** + * @param string $level A PSR Logger log level name + * + * @return $this + */ + public function importanceFromLogLevelName(string $level): self + { + $level = self::LEVELS[strtolower($level)]; + $this->importance = $level >= 500 ? self::IMPORTANCE_URGENT : ($level >= 400 ? self::IMPORTANCE_HIGH : self::IMPORTANCE_LOW); + + return $this; + } + + /** + * @return $this + */ + public function emoji(string $emoji): self + { + $this->emoji = $emoji; + + return $this; + } + + public function getEmoji(): string + { + if (!$this->emoji && $this->exception) { + switch ($this->importance) { + case self::IMPORTANCE_URGENT: + return '🌩ī¸'; + case self::IMPORTANCE_HIGH: + return '🌧ī¸'; + case self::IMPORTANCE_MEDIUM: + return 'đŸŒĻī¸'; + case self::IMPORTANCE_LOW: + default: + return '⛅'; + } + } + + return $this->emoji; + } + + public function getException(): ?\Throwable + { + return $this->exception; + } + + public function getExceptionAsString(): string + { + if (!$this->exception) { + return ''; + } + + if (class_exists(FlattenException::class)) { + $exception = $this->exception instanceof FlattenException ? $this->exception : FlattenException::createFromThrowable($this->exception); + + return $exception->getAsString(); + } + + $message = \get_class($this->exception); + if ('' !== $this->exception->getMessage()) { + $message .= ': '.$this->exception->getMessage(); + } + + $message .= ' in '.$this->exception->getFile().':'.$this->exception->getLine()."\n"; + $message .= "Stack trace:\n".$this->exception->getTraceAsString()."\n\n"; + + return rtrim($message); + } + + /** + * @return $this + */ + public function channels(array $channels): self + { + $this->channels = $channels; + + return $this; + } + + public function getChannels(Receiver $receiver): array + { + return $this->channels; + } +} diff --git a/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php b/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php new file mode 100644 index 0000000000000..ae00fd08da129 --- /dev/null +++ b/src/Symfony/Component/Notifier/Notification/SmsNotificationInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Notification; + +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface SmsNotificationInterface +{ + public function asSmsMessage(Receiver $receiver, string $transport = null): ?SmsMessage; +} diff --git a/src/Symfony/Component/Notifier/Notifier.php b/src/Symfony/Component/Notifier/Notifier.php new file mode 100644 index 0000000000000..9131194bb068a --- /dev/null +++ b/src/Symfony/Component/Notifier/Notifier.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Channel\ChannelInterface; +use Symfony\Component\Notifier\Channel\ChannelPolicy; +use Symfony\Component\Notifier\Channel\ChannelPolicyInterface; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\AdminReceiver; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class Notifier implements NotifierInterface +{ + private $adminReceivers = []; + private $channels; + private $policy; + private $bus; + + /** + * @param ChannelInterface[]|ContainerInterface $channels + */ + public function __construct($channels, ChannelPolicyInterface $policy = null, MessageBusInterface $bus = null) + { + $this->channels = $channels; + $this->policy = $policy; + $this->bus = $bus; + } + + public function send(Notification $notification, Receiver ...$receivers): void + { + foreach ($receivers as $receiver) { + foreach ($this->getChannels($notification, $receiver) as $channel => $transportName) { + $channel->notify($notification, $receiver, $transportName, $this->bus); + } + } + } + + public function addAdminReceiver(AdminReceiver $receiver): void + { + $this->adminReceivers[] = $receiver; + } + + /** + * @return AdminReceiver[] + */ + public function getAdminReceivers(): array + { + return $this->adminReceivers; + } + + private function getChannels(Notification $notification, Receiver $receiver): iterable + { + $channels = $notification->getChannels($receiver); + if (!$channels) { + $errorPrefix = 'Unable to determine which channels to use to send the "%s" notification'; + $error = 'you should either pass channels in the constructor, override its "getChannels()" method'; + if (null === $this->policy) { + throw new LogicException(sprintf('%s; %s, or configure a "%s".', $errorPrefix, $error, \get_class($notification), ChannelPolicy::class)); + } + if (!$channels = $this->policy->getChannels($notification->getImportance())) { + throw new LogicException(sprintf('%s; the "%s" returns no channels for importance "%s"; %s.', $errorPrefix, ChannelPolicy::class, $notification->getImportance(), $error, \get_class($notification))); + } + } + + foreach ($channels as $channelName) { + $transportName = null; + if (false !== $pos = strpos($channelName, '/')) { + $transportName = substr($channelName, $pos + 1); + $channelName = substr($channelName, 0, $pos); + } + + if (null === $channel = $this->getChannel($channelName)) { + throw new LogicException(sprintf('The "%s" channel does not exist.', $channelName)); + } + + if (!$channel->supports($notification, $receiver)) { + throw new LogicException(sprintf('The "%s" channel is not supported.', $channelName)); + } + + yield $channel => $transportName; + } + } + + private function getChannel(string $name): ?ChannelInterface + { + if ($this->channels instanceof ContainerInterface) { + return $this->channels->has($name) ? $this->channels->get($name) : null; + } + + return $this->channels[$name] ?? null; + } +} diff --git a/src/Symfony/Component/Notifier/NotifierInterface.php b/src/Symfony/Component/Notifier/NotifierInterface.php new file mode 100644 index 0000000000000..4e26ef758749d --- /dev/null +++ b/src/Symfony/Component/Notifier/NotifierInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\Notifier\Notification\Notification; +use Symfony\Component\Notifier\Receiver\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface NotifierInterface +{ + public function send(Notification $notification, Receiver ...$receivers): void; +} diff --git a/src/Symfony/Component/Notifier/README.md b/src/Symfony/Component/Notifier/README.md new file mode 100644 index 0000000000000..3b5fcdc882f69 --- /dev/null +++ b/src/Symfony/Component/Notifier/README.md @@ -0,0 +1,18 @@ +Notifier Component +================== + +The Notifier component sends notifications via one or more channels (email, SMS, ...). + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/notifier.html) + * [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/Receiver/AdminReceiver.php b/src/Symfony/Component/Notifier/Receiver/AdminReceiver.php new file mode 100644 index 0000000000000..3c6abb8f56bfe --- /dev/null +++ b/src/Symfony/Component/Notifier/Receiver/AdminReceiver.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class AdminReceiver extends Receiver implements SmsReceiverInterface +{ + private $phone; + + public function __construct(string $email = '', string $phone = '') + { + parent::__construct($email); + + $this->phone = $phone; + } + + /** + * @return $this + */ + public function phone(string $phone): self + { + $this->phone = $phone; + + return $this; + } + + public function getPhone(): string + { + return $this->phone; + } +} diff --git a/src/Symfony/Component/Notifier/Receiver/NoReceiver.php b/src/Symfony/Component/Notifier/Receiver/NoReceiver.php new file mode 100644 index 0000000000000..341d4c28488e2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Receiver/NoReceiver.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class NoReceiver extends Receiver +{ + public function getEmail(): string + { + return ''; + } +} diff --git a/src/Symfony/Component/Notifier/Receiver/Receiver.php b/src/Symfony/Component/Notifier/Receiver/Receiver.php new file mode 100644 index 0000000000000..a2e7623926ec9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Receiver/Receiver.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class Receiver +{ + private $email; + + public function __construct(string $email = '') + { + $this->email = $email; + } + + /** + * @return $this + */ + public function email(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getEmail(): string + { + return $this->email; + } +} diff --git a/src/Symfony/Component/Notifier/Receiver/SmsReceiverInterface.php b/src/Symfony/Component/Notifier/Receiver/SmsReceiverInterface.php new file mode 100644 index 0000000000000..be6669d327da1 --- /dev/null +++ b/src/Symfony/Component/Notifier/Receiver/SmsReceiverInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Receiver; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface SmsReceiverInterface +{ + /** + * @return $this + */ + public function phone(string $phone): self; + + public function getPhone(): string; +} diff --git a/src/Symfony/Component/Notifier/Texter.php b/src/Symfony/Component/Notifier/Texter.php new file mode 100644 index 0000000000000..957c5b30ce9e4 --- /dev/null +++ b/src/Symfony/Component/Notifier/Texter.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Notifier\Event\MessageEvent; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class Texter implements TexterInterface +{ + private $transport; + private $bus; + private $dispatcher; + + public function __construct(TransportInterface $transport, MessageBusInterface $bus = null, EventDispatcherInterface $dispatcher = null) + { + $this->transport = $transport; + $this->bus = $bus; + $this->dispatcher = LegacyEventDispatcherProxy::decorate($dispatcher); + } + + public function __toString(): string + { + return 'texter'; + } + + public function supports(MessageInterface $message): bool + { + return $this->transport->supports($message); + } + + public function send(MessageInterface $message): void + { + if (null === $this->bus) { + $this->transport->send($message); + + return; + } + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(new MessageEvent($message, true)); + } + + $this->bus->dispatch($message); + } +} diff --git a/src/Symfony/Component/Notifier/TexterInterface.php b/src/Symfony/Component/Notifier/TexterInterface.php new file mode 100644 index 0000000000000..560bb08e29c66 --- /dev/null +++ b/src/Symfony/Component/Notifier/TexterInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface TexterInterface extends TransportInterface +{ +} diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php new file mode 100644 index 0000000000000..c8e6a8ad3949c --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier; + +use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; +use Symfony\Component\Notifier\Bridge\Slack\SlackTransportFactory; +use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransportFactory; +use Symfony\Component\Notifier\Bridge\Twilio\TwilioTransportFactory; +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\FailoverTransport; +use Symfony\Component\Notifier\Transport\NullTransportFactory; +use Symfony\Component\Notifier\Transport\RoundRobinTransport; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Component\Notifier\Transport\Transports; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class Transport +{ + private const FACTORY_CLASSES = [ + SlackTransportFactory::class, + TelegramTransportFactory::class, + NexmoTransportFactory::class, + TwilioTransportFactory::class, + ]; + + private $factories; + + public static function fromDsn(string $dsn, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null): TransportInterface + { + $factory = new self(self::getDefaultFactories($dispatcher, $client)); + + return $factory->fromString($dsn); + } + + public static function fromDsns(array $dsns, EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null): TransportInterface + { + $factory = new self(iterator_to_array(self::getDefaultFactories($dispatcher, $client))); + + return $factory->fromStrings($dsns); + } + + /** + * @param TransportFactoryInterface[] $factories + */ + public function __construct(iterable $factories) + { + $this->factories = $factories; + } + + public function fromStrings(array $dsns): Transports + { + $transports = []; + foreach ($dsns as $name => $dsn) { + $transports[$name] = $this->fromString($dsn); + } + + return new Transports($transports); + } + + public function fromString(string $dsn): TransportInterface + { + $dsns = preg_split('/\s++\|\|\s++/', $dsn); + if (\count($dsns) > 1) { + return new FailoverTransport($this->createFromDsns($dsns)); + } + + $dsns = preg_split('/\s++&&\s++/', $dsn); + if (\count($dsns) > 1) { + return new RoundRobinTransport($this->createFromDsns($dsns)); + } + + return $this->fromDsnObject(Dsn::fromString($dsn)); + } + + public function fromDsnObject(Dsn $dsn): TransportInterface + { + foreach ($this->factories as $factory) { + if ($factory->supports($dsn)) { + return $factory->create($dsn); + } + } + + throw new UnsupportedSchemeException($dsn); + } + + /** + * @return TransportInterface[] + */ + private function createFromDsns(array $dsns): array + { + $transports = []; + foreach ($dsns as $dsn) { + $transports[] = $this->fromDsnObject(Dsn::fromString($dsn)); + } + + return $transports; + } + + /** + * @return TransportFactoryInterface[] + */ + private static function getDefaultFactories(EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null): iterable + { + foreach (self::FACTORY_CLASSES as $factoryClass) { + if (class_exists($factoryClass)) { + yield new $factoryClass($dispatcher, $client); + } + } + + yield new NullTransportFactory($dispatcher, $client); + } +} diff --git a/src/Symfony/Component/Notifier/Transport/AbstractTransport.php b/src/Symfony/Component/Notifier/Transport/AbstractTransport.php new file mode 100644 index 0000000000000..1c22359feb219 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/AbstractTransport.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\Notifier\Event\MessageEvent; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +abstract class AbstractTransport implements TransportInterface +{ + protected const HOST = 'localhost'; + + private $dispatcher; + + protected $client; + protected $host; + protected $port; + + public function __construct(HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->client = $client; + if (null === $client) { + if (!class_exists(HttpClient::class)) { + throw new LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); + } + + $this->client = HttpClient::create(); + } + + $this->dispatcher = LegacyEventDispatcherProxy::decorate($dispatcher); + } + + /** + * @return $this + */ + public function setHost(?string $host): self + { + $this->host = $host; + + return $this; + } + + /** + * @return $this + */ + public function setPort(?int $port): self + { + $this->port = $port; + + return $this; + } + + public function send(MessageInterface $message): void + { + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(new MessageEvent($message)); + } + + $this->doSend($message); + } + + abstract protected function doSend(MessageInterface $message): void; + + protected function getEndpoint(): ?string + { + return ($this->host ?: $this->getDefaultHost()).($this->port ? ':'.$this->port : ''); + } + + protected function getDefaultHost(): string + { + return static::HOST; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php b/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php new file mode 100644 index 0000000000000..be92b3c57883b --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy; +use Symfony\Component\Notifier\Exception\IncompleteDsnException; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Konstantin Myakshin + * + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +abstract class AbstractTransportFactory implements TransportFactoryInterface +{ + protected $dispatcher; + protected $client; + + public function __construct(EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null) + { + $this->dispatcher = LegacyEventDispatcherProxy::decorate($dispatcher); + $this->client = $client; + } + + public function supports(Dsn $dsn): bool + { + return \in_array($dsn->getScheme(), $this->getSupportedSchemes()); + } + + abstract protected function getSupportedSchemes(): array; + + protected function getUser(Dsn $dsn): string + { + $user = $dsn->getUser(); + if (null === $user) { + throw new IncompleteDsnException('User is not set.'); + } + + return $user; + } + + protected function getPassword(Dsn $dsn): string + { + $password = $dsn->getPassword(); + if (null === $password) { + throw new IncompleteDsnException('Password is not set.'); + } + + return $password; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/AllTransport.php b/src/Symfony/Component/Notifier/Transport/AllTransport.php new file mode 100644 index 0000000000000..e1f5ce10b2f62 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/AllTransport.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class AllTransport implements TransportInterface +{ + private $transports = []; + + /** + * @param TransportInterface[] $transports + */ + public function __construct(array $transports) + { + if (!$transports) { + throw new LogicException(__CLASS__.' must have at least one transport configured.'); + } + + $this->transports = $transports; + } + + public function __toString(): string + { + return implode(' all ', array_map(function (TransportInterface $transport) { + return (string) $transport; + }, $this->transports)); + } + + public function supports(MessageInterface $message): bool + { + foreach ($this->transports as $transport) { + if ($transport->supports($message)) { + return true; + } + } + + return false; + } + + public function send(MessageInterface $message): void + { + foreach ($this->transports as $transport) { + $transport->send($message); + } + } +} diff --git a/src/Symfony/Component/Notifier/Transport/Dsn.php b/src/Symfony/Component/Notifier/Transport/Dsn.php new file mode 100644 index 0000000000000..a69bf0a1b7308 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/Dsn.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class Dsn +{ + private $scheme; + private $host; + private $user; + private $password; + private $port; + private $options; + + public function __construct(string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = []) + { + $this->scheme = $scheme; + $this->host = $host; + $this->user = $user; + $this->password = $password; + $this->port = $port; + $this->options = $options; + } + + public static function fromString(string $dsn): self + { + if (false === $parsedDsn = parse_url($dsn)) { + throw new InvalidArgumentException(sprintf('The "%s" notifier DSN is invalid.', $dsn)); + } + + if (!isset($parsedDsn['scheme'])) { + throw new InvalidArgumentException(sprintf('The "%s" notifier DSN must contain a scheme.', $dsn)); + } + + if (!isset($parsedDsn['host'])) { + throw new InvalidArgumentException(sprintf('The "%s" notifier DSN must contain a host (use "default" by default).', $dsn)); + } + + $user = isset($parsedDsn['user']) ? urldecode($parsedDsn['user']) : null; + $password = isset($parsedDsn['pass']) ? urldecode($parsedDsn['pass']) : null; + $port = $parsedDsn['port'] ?? null; + parse_str($parsedDsn['query'] ?? '', $query); + + return new self($parsedDsn['scheme'], $parsedDsn['host'], $user, $password, $port, $query); + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getPort(int $default = null): ?int + { + return $this->port ?? $default; + } + + public function getOption(string $key, $default = null) + { + return $this->options[$key] ?? $default; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/FailoverTransport.php b/src/Symfony/Component/Notifier/Transport/FailoverTransport.php new file mode 100644 index 0000000000000..290276ee6183f --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/FailoverTransport.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * Uses several Transports using a failover algorithm. + * + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class FailoverTransport extends RoundRobinTransport +{ + private $currentTransport; + + protected function getNextTransport(MessageInterface $message): ?TransportInterface + { + if (null === $this->currentTransport || $this->isTransportDead($this->currentTransport)) { + $this->currentTransport = parent::getNextTransport($message); + } + + return $this->currentTransport; + } + + protected function getNameSymbol(): string + { + return '||'; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/NullTransport.php b/src/Symfony/Component/Notifier/Transport/NullTransport.php new file mode 100644 index 0000000000000..658243ae7d539 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/NullTransport.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class NullTransport implements TransportInterface +{ + public function send(MessageInterface $message): void + { + } + + public function __toString(): string + { + return 'null'; + } + + public function supports(MessageInterface $message): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php b/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php new file mode 100644 index 0000000000000..f93ff8c7b0c29 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/NullTransportFactory.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class NullTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + if ('null' === $dsn->getScheme()) { + return new NullTransport(); + } + + throw new UnsupportedSchemeException($dsn, 'null', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['null']; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php b/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php new file mode 100644 index 0000000000000..803f81568e037 --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/RoundRobinTransport.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\RuntimeException; +use Symfony\Component\Notifier\Exception\TransportExceptionInterface; +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * Uses several Transports using a round robin algorithm. + * + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +class RoundRobinTransport implements TransportInterface +{ + private $deadTransports; + private $transports = []; + private $retryPeriod; + private $cursor = 0; + + /** + * @param TransportInterface[] $transports + */ + public function __construct(array $transports, int $retryPeriod = 60) + { + if (!$transports) { + throw new LogicException(sprintf('"%s" must have at least one transport configured.', static::class)); + } + + $this->transports = $transports; + $this->deadTransports = new \SplObjectStorage(); + $this->retryPeriod = $retryPeriod; + // the cursor initial value is randomized so that + // when are not in a daemon, we are still rotating the transports + $this->cursor = rand(0, \count($transports) - 1); + } + + public function __toString(): string + { + return implode(' '.$this->getNameSymbol().' ', array_map('strval', $this->transports)); + } + + public function supports(MessageInterface $message): bool + { + foreach ($this->transports as $transport) { + if ($transport->supports($message)) { + return true; + } + } + + return false; + } + + public function send(MessageInterface $message): void + { + while ($transport = $this->getNextTransport($message)) { + try { + $transport->send($message); + + return; + } catch (TransportExceptionInterface $e) { + $this->deadTransports[$transport] = microtime(true); + } + } + + throw new RuntimeException('All transports failed.'); + } + + /** + * Rotates the transport list around and returns the first instance. + */ + protected function getNextTransport(MessageInterface $message): ?TransportInterface + { + $cursor = $this->cursor; + while (true) { + $transport = $this->transports[$cursor]; + + if (!$transport->supports($message)) { + continue; + } + + if (!$this->isTransportDead($transport)) { + break; + } + + if ((microtime(true) - $this->deadTransports[$transport]) > $this->retryPeriod) { + $this->deadTransports->detach($transport); + + break; + } + + if ($this->cursor === $cursor = $this->moveCursor($cursor)) { + return null; + } + } + + $this->cursor = $this->moveCursor($cursor); + + return $transport; + } + + protected function isTransportDead(TransportInterface $transport): bool + { + return $this->deadTransports->contains($transport); + } + + protected function getNameSymbol(): string + { + return '&&'; + } + + private function moveCursor(int $cursor): int + { + return ++$cursor >= \count($this->transports) ? 0 : $cursor; + } +} diff --git a/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php b/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php new file mode 100644 index 0000000000000..47960325a150d --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/TransportFactoryInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\IncompleteDsnException; +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; + +/** + * @author Konstantin Myakshin + * + * @experimental in 5.0 + */ +interface TransportFactoryInterface +{ + /** + * @throws UnsupportedSchemeException + * @throws IncompleteDsnException + */ + public function create(Dsn $dsn): TransportInterface; + + public function supports(Dsn $dsn): bool; +} diff --git a/src/Symfony/Component/Notifier/Transport/TransportInterface.php b/src/Symfony/Component/Notifier/Transport/TransportInterface.php new file mode 100644 index 0000000000000..b53e6180a39af --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/TransportInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +interface TransportInterface +{ + /** + * @throws TransportException + */ + public function send(MessageInterface $message): void; + + public function supports(MessageInterface $message): bool; + + public function __toString(): string; +} diff --git a/src/Symfony/Component/Notifier/Transport/Transports.php b/src/Symfony/Component/Notifier/Transport/Transports.php new file mode 100644 index 0000000000000..1369d387b20ef --- /dev/null +++ b/src/Symfony/Component/Notifier/Transport/Transports.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Transport; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Message\MessageInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.0 + */ +final class Transports implements TransportInterface +{ + private $transports; + private $default; + + /** + * @param TransportInterface[] $transports + */ + public function __construct(iterable $transports) + { + $this->transports = []; + foreach ($transports as $name => $transport) { + if (null === $this->default) { + $this->default = $transport; + } + $this->transports[$name] = $transport; + } + } + + public function __toString(): string + { + return '['.implode(',', array_keys($this->transports)).']'; + } + + public function supports(MessageInterface $message): bool + { + foreach ($this->transports as $transport) { + if ($transport->supports($message)) { + return true; + } + } + + return false; + } + + public function send(MessageInterface $message): void + { + if (!$transport = $message->getTransport()) { + $this->default->send($message); + + return; + } + + if (!isset($this->transports[$transport])) { + throw new InvalidArgumentException(sprintf('The "%s" transport does not exist.', $transport)); + } + + $this->transports[$transport]->send($message); + } +} diff --git a/src/Symfony/Component/Notifier/composer.json b/src/Symfony/Component/Notifier/composer.json new file mode 100644 index 0000000000000..3077a35657930 --- /dev/null +++ b/src/Symfony/Component/Notifier/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/notifier", + "type": "library", + "description": "A library to notify messages", + "keywords": ["notifier", "notification"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.9" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + } +} diff --git a/src/Symfony/Component/Notifier/phpunit.xml.dist b/src/Symfony/Component/Notifier/phpunit.xml.dist new file mode 100644 index 0000000000000..4caf7a27fac93 --- /dev/null +++ b/src/Symfony/Component/Notifier/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + +