From c0975110933490cca8f4d11a02d30e149bfa065b Mon Sep 17 00:00:00 2001 From: Mickael Perraud Date: Fri, 16 Jun 2023 18:08:23 +0200 Subject: [PATCH] [Notifier] add Ntfy bridge --- .../FrameworkExtension.php | 1 + .../Resources/config/notifier_transports.php | 4 + .../Notifier/Bridge/Ntfy/.gitattributes | 4 + .../Component/Notifier/Bridge/Ntfy/.gitignore | 3 + .../Notifier/Bridge/Ntfy/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Ntfy/LICENSE | 19 ++ .../Notifier/Bridge/Ntfy/NtfyOptions.php | 181 ++++++++++++++++++ .../Notifier/Bridge/Ntfy/NtfyTransport.php | 126 ++++++++++++ .../Bridge/Ntfy/NtfyTransportFactory.php | 56 ++++++ .../Component/Notifier/Bridge/Ntfy/README.md | 29 +++ .../Bridge/Ntfy/Tests/NtfyOptionsTest.php | 60 ++++++ .../Ntfy/Tests/NtfyTransportFactoryTest.php | 50 +++++ .../Bridge/Ntfy/Tests/NtfyTransportTest.php | 113 +++++++++++ .../Notifier/Bridge/Ntfy/composer.json | 30 +++ .../Notifier/Bridge/Ntfy/phpunit.xml.dist | 31 +++ .../Exception/UnsupportedSchemeException.php | 4 + .../UnsupportedSchemeExceptionTest.php | 2 + src/Symfony/Component/Notifier/Transport.php | 1 + 18 files changed, 721 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyOptions.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyOptionsTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Ntfy/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 25bc3cb1c2367..df3ff3feb406f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2753,6 +2753,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class => 'notifier.transport_factory.microsoft-teams', NotifierBridge\Mobyt\MobytTransportFactory::class => 'notifier.transport_factory.mobyt', NotifierBridge\Novu\NovuTransportFactory::class => 'notifier.transport_factory.novu', + NotifierBridge\Ntfy\NtfyTransportFactory::class => 'notifier.transport_factory.ntfy', NotifierBridge\Octopush\OctopushTransportFactory::class => 'notifier.transport_factory.octopush', NotifierBridge\OneSignal\OneSignalTransportFactory::class => 'notifier.transport_factory.one-signal', NotifierBridge\OrangeSms\OrangeSmsTransportFactory::class => 'notifier.transport_factory.orange-sms', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index 5dc43096c0ba6..1bbb06bc3c0fa 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -291,5 +291,9 @@ ->set('notifier.transport_factory.novu', Bridge\Novu\NovuTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + + ->set('notifier.transport_factory.ntfy', Bridge\Ntfy\NtfyTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('texter.transport_factory') ; }; diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitignore b/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Ntfy/CHANGELOG.md new file mode 100644 index 0000000000000..fd3d6c754c3eb --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.4 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/LICENSE b/src/Symfony/Component/Notifier/Bridge/Ntfy/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyOptions.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyOptions.php new file mode 100644 index 0000000000000..03a7dba3400ae --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyOptions.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Ntfy; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Notification\Notification; + +/** + * @author Mickael Perraud + */ +final class NtfyOptions implements MessageOptionsInterface +{ + public const PRIORITY_URGENT = 5; + public const PRIORITY_HIGH = 4; + public const PRIORITY_DEFAULT = 3; + public const PRIORITY_LOW = 2; + public const PRIORITY_MIN = 1; + + public function __construct(private array $options = []) + { + } + + public static function fromNotification(Notification $notification): self + { + $options = new self(); + $options->setTitle($notification->getSubject()); + $options->setMessage($notification->getContent()); + $options->setStringPriority($notification->getImportance()); + $options->addTag($notification->getEmoji()); + + return $options; + } + + public function toArray(): array + { + return $this->options; + } + + public function getRecipientId(): ?string + { + return null; + } + + public function setMessage(string $message): self + { + $this->options['message'] = $message; + + return $this; + } + + public function setTitle(string $title): self + { + $this->options['title'] = $title; + + return $this; + } + + public function setStringPriority(string $priority): self + { + switch ($priority) { + case Notification::IMPORTANCE_URGENT: + return $this->setPriority(self::PRIORITY_URGENT); + case Notification::IMPORTANCE_HIGH: + return $this->setPriority(self::PRIORITY_HIGH); + case Notification::IMPORTANCE_LOW: + return $this->setPriority(self::PRIORITY_LOW); + default: + return $this->setPriority(self::PRIORITY_DEFAULT); + } + } + + public function setPriority(int $priority): self + { + if (\in_array($priority, [ + self::PRIORITY_MIN, self::PRIORITY_LOW, self::PRIORITY_DEFAULT, self::PRIORITY_HIGH, self::PRIORITY_URGENT, + ])) { + $this->options['priority'] = $priority; + } + + return $this; + } + + public function addTag(string $tag): self + { + $this->options['tags'][] = $tag; + + return $this; + } + + public function setTags(array $tags): self + { + $this->options['tags'] = $tags; + + return $this; + } + + public function setDelay(\DateTimeInterface $dateTime): self + { + if ($dateTime > (new \DateTime())) { + $this->options['delay'] = (string) $dateTime->getTimestamp(); + } else { + throw new LogicException('Delayed date must be defined in the future.'); + } + + return $this; + } + + public function setActions(array $actions): self + { + $this->options['actions'] = $actions; + + return $this; + } + + public function addAction(array $action): self + { + $this->options['actions'][] = $action; + + return $this; + } + + public function setClick(string $url): self + { + $this->options['click'] = $url; + + return $this; + } + + public function setAttachment(string $attachment): self + { + $this->options['attach'] = $attachment; + + return $this; + } + + public function setFilename(string $filename): self + { + $this->options['filename'] = $filename; + + return $this; + } + + public function setEmail(string $email): self + { + $this->options['email'] = $email; + + return $this; + } + + public function setCache(bool $enable): self + { + if (!$enable) { + $this->options['cache'] = 'no'; + } else { + unset($this->options['cache']); + } + + return $this; + } + + public function setFirebase(bool $enable): self + { + if (!$enable) { + $this->options['firebase'] = 'no'; + } else { + unset($this->options['firebase']); + } + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransport.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransport.php new file mode 100644 index 0000000000000..d6120c1447fe4 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransport.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Ntfy; + +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\PushMessage; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mickael Perraud + */ +final class NtfyTransport extends AbstractTransport +{ + protected const HOST = 'ntfy.sh'; + private ?string $user = null; + private ?string $password = null; + + public function __construct(private string $topic, private bool $secureHttp = true, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + parent::__construct($client, $dispatcher); + } + + public function getTopic(): string + { + return $this->topic; + } + + public function setPassword(?string $password): self + { + $this->password = $password; + + return $this; + } + + public function setUser(?string $user): self + { + $this->user = $user; + + return $this; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof PushMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, PushMessage::class, $message); + } + + if ($message->getOptions() && !$message->getOptions() instanceof NtfyOptions) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, NtfyOptions::class)); + } + + if (!($opts = $message->getOptions()) && $notification = $message->getNotification()) { + $opts = NtfyOptions::fromNotification($notification); + } + + $options = $opts ? $opts->toArray() : []; + + $options['topic'] = $this->getTopic(); + + if (!isset($options['title'])) { + $options['title'] = $message->getSubject(); + } + if (!isset($options['message'])) { + $options['message'] = $message->getContent(); + } + + $headers = []; + + if (null !== $this->user && null !== $this->password) { + $headers['Authorization'] = 'Basic '.rtrim(base64_encode($this->user.':'.$this->password), '='); + } + + $response = $this->client->request('POST', ($this->secureHttp ? 'https' : 'http').'://'.$this->getEndpoint(), [ + 'headers' => $headers, + 'json' => $options, + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote Ntfy server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + throw new TransportException(sprintf('Unable to send the Ntfy push notification: "%s".', $response->getContent(false)), $response); + } + + $result = $response->toArray(false); + + if (empty($result['id'])) { + throw new TransportException(sprintf('Unable to send the Ntfy push notification: "%s".', $response->getContent(false)), $response); + } + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($result['id']); + + return $sentMessage; + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof PushMessage && + (null === $message->getOptions() || $message->getOptions() instanceof NtfyOptions); + } + + public function __toString(): string + { + return sprintf('ntfy://%s/%s', $this->getEndpoint(), $this->getTopic()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransportFactory.php new file mode 100644 index 0000000000000..b469090f8f0e0 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/NtfyTransportFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Ntfy; + +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 Mickael Perraud + */ +final class NtfyTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + if ('ntfy' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'ntfy', $this->getSupportedSchemes()); + } + + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $topic = substr($dsn->getPath(), 1); + + if (\in_array($dsn->getOption('secureHttp', true), [0, false, 'false', 'off', 'no'])) { + $secureHttp = false; + } else { + $secureHttp = true; + } + + $transport = (new NtfyTransport($topic, $secureHttp))->setHost($host); + if (!empty($port = $dsn->getPort())) { + $transport->setPort($port); + } + + if (!empty($user = $dsn->getUser()) && !empty($password = $dsn->getPassword())) { + $transport->setUser($user); + $transport->setPassword($password); + } + + return $transport; + } + + protected function getSupportedSchemes(): array + { + return ['ntfy']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/README.md b/src/Symfony/Component/Notifier/Bridge/Ntfy/README.md new file mode 100644 index 0000000000000..b9ff6efc46be9 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/README.md @@ -0,0 +1,29 @@ +Ntfy Notifier +================== + +Provides [Ntfy](https://docs.ntfy.sh/) integration for Symfony Notifier. + +DSN example +----------- + +``` +NTFY_DSN=ntfy://[USER:PASSWORD]@default[:PORT]/TOPIC?[secureHttp=[on]] +``` + +where: +- `URL` is the ntfy server which you are using + - if `default` is provided, this will default to the public ntfy server hosted on [ntfy.sh](https://ntfy.sh/). +- `TOPIC` is the topic on this ntfy server. +- `PORT` is an optional specific port. +- `USER`and `PASSWORD` are username and password in case of access control supported by the server + +In case of a non-secure server, you can disable https by setting `secureHttp=off`. For example if you use a local [Ntfy Docker image](https://hub.docker.com/r/binwiederhier/ntfy) during development or testing. + + +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/Ntfy/Tests/NtfyOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyOptionsTest.php new file mode 100644 index 0000000000000..d2b6c4348f174 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyOptionsTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Ntfy\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Ntfy\NtfyOptions; + +/** + * @author Mickael Perraud + */ +class NtfyOptionsTest extends TestCase +{ + public function testNtfyOptions() + { + $delay = (new \DateTime())->add(new \DateInterval('PT1M')); + $ntfyOptions = (new NtfyOptions()) + ->setMessage('test message') + ->setTitle('message title') + ->setPriority(NtfyOptions::PRIORITY_URGENT) + ->setTags(['tag1', 'tag2']) + ->addTag('tag3') + ->setDelay($delay) + ->setActions([['action' => 'view', 'label' => 'View', 'url' => 'https://test.com']]) + ->addAction(['action' => 'http', 'label' => 'Open', 'url' => 'https://test2.com']) + ->setClick('https://test3.com') + ->setAttachment('https://filesrv.lan/space.jpg') + ->setFilename('diskspace.jpg') + ->setEmail('me@mail.com') + ->setCache(false) + ->setFirebase(false) + ; + + $this->assertSame([ + 'message' => 'test message', + 'title' => 'message title', + 'priority' => NtfyOptions::PRIORITY_URGENT, + 'tags' => ['tag1', 'tag2', 'tag3'], + 'delay' => (string) $delay->getTimestamp(), + 'actions' => [ + ['action' => 'view', 'label' => 'View', 'url' => 'https://test.com'], + ['action' => 'http', 'label' => 'Open', 'url' => 'https://test2.com'], + ], + 'click' => 'https://test3.com', + 'attach' => 'https://filesrv.lan/space.jpg', + 'filename' => 'diskspace.jpg', + 'email' => 'me@mail.com', + 'cache' => 'no', + 'firebase' => 'no', + ], $ntfyOptions->toArray()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportFactoryTest.php new file mode 100644 index 0000000000000..d54fdd122f880 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportFactoryTest.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\Bridge\Ntfy\Tests; + +use Symfony\Component\Notifier\Bridge\Ntfy\NtfyTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +/** + * @author Mickael Perraud + */ +final class NtfyTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): TransportFactoryInterface + { + return new NtfyTransportFactory(); + } + + public static function createProvider(): iterable + { + yield [ + 'ntfy://ntfy.sh/test', + 'ntfy://user:password@default/test', + ]; + yield [ + 'ntfy://ntfy.sh:8888/test', + 'ntfy://user:password@default:8888/test?secureHttp=off', + ]; + } + + public static function supportsProvider(): iterable + { + yield [true, 'ntfy://default/test']; + yield [false, 'somethingElse://default/test']; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://default/test']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportTest.php new file mode 100644 index 0000000000000..cb8485750c463 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/Tests/NtfyTransportTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Ntfy\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Notifier\Bridge\Ntfy\NtfyTransport; +use Symfony\Component\Notifier\Message\PushMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Tests\Transport\DummyMessage; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Mickael Perraud + */ +final class NtfyTransportTest extends TransportTestCase +{ + public static function createTransport(HttpClientInterface $client = null): NtfyTransport + { + return new NtfyTransport('test', true, $client ?? new MockHttpClient()); + } + + public static function toStringProvider(): iterable + { + yield ['ntfy://ntfy.sh/test', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new PushMessage('Hello!', 'Symfony Notifier')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0123456789', 'Hello!')]; + yield [new DummyMessage()]; + } + + public function testCanSetCustomHost() + { + $transport = $this->createTransport(); + $transport->setHost($customHost = self::CUSTOM_HOST); + $this->assertSame(sprintf('ntfy://%s/test', $customHost), (string) $transport); + } + + public function testCanSetCustomHostAndPort() + { + $transport = $this->createTransport(); + $transport->setHost($customHost = self::CUSTOM_HOST); + $transport->setPort($customPort = self::CUSTOM_PORT); + $this->assertSame(sprintf('ntfy://%s:%s/test', $customHost, $customPort), (string) $transport); + } + + public function testSend() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode(['id' => '2BYIwRmvBKcv', 'event' => 'message'])); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response): ResponseInterface { + $expectedBody = json_encode(['topic' => 'test', 'title' => 'Hello', 'message' => 'World']); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return $response; + }); + + $transport = $this->createTransport($client); + + $sentMessage = $transport->send(new PushMessage('Hello', 'World')); + + $this->assertSame('2BYIwRmvBKcv', $sentMessage->getMessageId()); + } + + public function testSendWithUserAndPassword() + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode(['id' => '2BYIwRmvBKcv', 'event' => 'message'])); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($response): ResponseInterface { + $expectedBody = json_encode(['topic' => 'test', 'title' => 'Hello', 'message' => 'World']); + $expectedAuthorization = 'Authorization: Basic dGVzdF91c2VyOnRlc3RfcGFzc3dvcmQ'; + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + $this->assertTrue(\in_array($expectedAuthorization, $options['headers'])); + + return $response; + }); + + $transport = $this->createTransport($client)->setUser('test_user')->setPassword('test_password'); + + $sentMessage = $transport->send(new PushMessage('Hello', 'World')); + + $this->assertSame('2BYIwRmvBKcv', $sentMessage->getMessageId()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/composer.json b/src/Symfony/Component/Notifier/Bridge/Ntfy/composer.json new file mode 100644 index 0000000000000..f2cd77e1fa86c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/ntfy-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony Ntyf Notifier Bridge", + "keywords": ["ntfy", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mickael Perraud", + "email": "mikaelkael.fr@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/http-client": "^5.4|^6.0", + "symfony/notifier": "^6.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Ntfy\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Ntfy/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Ntfy/phpunit.xml.dist new file mode 100644 index 0000000000000..73f16d5565523 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Ntfy/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index 7a3413aee69f3..a9082a546026b 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -156,6 +156,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Novu\NovuTransportFactory::class, 'package' => 'symfony/novu-notifier', ], + 'ntfy' => [ + 'class' => Bridge\Ntfy\NtfyTransportFactory::class, + 'package' => 'symfony/ntfy-notifier', + ], 'octopush' => [ 'class' => Bridge\Octopush\OctopushTransportFactory::class, 'package' => 'symfony/octopush-notifier', diff --git a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php index 2edb9ae59f1c1..dc4ab79282216 100644 --- a/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php +++ b/src/Symfony/Component/Notifier/Tests/Exception/UnsupportedSchemeExceptionTest.php @@ -61,6 +61,7 @@ public static function setUpBeforeClass(): void Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class => false, Bridge\Mobyt\MobytTransportFactory::class => false, Bridge\Novu\NovuTransportFactory::class => false, + Bridge\Ntfy\NtfyTransportFactory::class => false, Bridge\Octopush\OctopushTransportFactory::class => false, Bridge\OneSignal\OneSignalTransportFactory::class => false, Bridge\OrangeSms\OrangeSmsTransportFactory::class => false, @@ -140,6 +141,7 @@ public static function messageWhereSchemeIsPartOfSchemeToPackageMapProvider(): \ yield ['microsoftteams', 'symfony/microsoft-teams-notifier']; yield ['mobyt', 'symfony/mobyt-notifier']; yield ['novu', 'symfony/novu-notifier']; + yield ['ntfy', 'symfony/ntfy-notifier']; yield ['octopush', 'symfony/octopush-notifier']; yield ['onesignal', 'symfony/one-signal-notifier']; yield ['ovhcloud', 'symfony/ovh-cloud-notifier']; diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index 0428e2bba3538..7959aaf8c066b 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -63,6 +63,7 @@ final class Transport Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class, Bridge\Mobyt\MobytTransportFactory::class, Bridge\Novu\NovuTransportFactory::class, + Bridge\Ntfy\NtfyTransportFactory::class, Bridge\Octopush\OctopushTransportFactory::class, Bridge\OneSignal\OneSignalTransportFactory::class, Bridge\OrangeSms\OrangeSmsTransportFactory::class,