From 013d56d4892a22db8f675337767d39814e357ec6 Mon Sep 17 00:00:00 2001 From: idetox Date: Wed, 4 Nov 2020 10:40:36 +0100 Subject: [PATCH] [Notifier] Add notifier for Microsoft Teams --- .../FrameworkExtension.php | 2 + .../Resources/config/notifier_transports.php | 5 + .../Bridge/MicrosoftTeams/.gitattributes | 4 + .../Notifier/Bridge/MicrosoftTeams/.gitignore | 3 + .../Bridge/MicrosoftTeams/CHANGELOG.md | 7 ++ .../Notifier/Bridge/MicrosoftTeams/LICENSE | 19 ++++ .../MicrosoftTeamsTransport.php | 86 +++++++++++++++++ .../MicrosoftTeamsTransportFactory.php | 50 ++++++++++ .../Notifier/Bridge/MicrosoftTeams/README.md | 23 +++++ .../MicrosoftTeamsTransportFactoryTest.php | 43 +++++++++ .../Tests/MicrosoftTeamsTransportTest.php | 94 +++++++++++++++++++ .../Bridge/MicrosoftTeams/composer.json | 34 +++++++ .../Bridge/MicrosoftTeams/phpunit.xml.dist | 31 ++++++ .../Exception/UnsupportedSchemeException.php | 4 + src/Symfony/Component/Notifier/Transport.php | 2 + 15 files changed, 407 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 90a1f02e3a..32c7f7e5cc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -125,6 +125,7 @@ use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; +use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; @@ -2390,6 +2391,7 @@ class FrameworkExtension extends Extension MercureTransportFactory::class => 'notifier.transport_factory.mercure', GitterTransportFactory::class => 'notifier.transport_factory.gitter', ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', + MicrosoftTeamsTransportFactory::class => 'notifier.transport_factory.microsoftteams', ]; $parentPackages = ['symfony/framework-bundle', 'symfony/notifier']; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index e18e0a1753..ca2da3d018 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -28,6 +28,7 @@ use Symfony\Component\Notifier\Bridge\LightSms\LightSmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; +use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; @@ -150,6 +151,10 @@ return static function (ContainerConfigurator $container) { ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.microsoftteams', MicrosoftTeamsTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.gatewayapi', GatewayApiTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitattributes b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.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/MicrosoftTeams/.gitignore b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/CHANGELOG.md new file mode 100644 index 0000000000..1f2b652ac2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 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/MicrosoftTeams/MicrosoftTeamsTransport.php b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransport.php new file mode 100644 index 0000000000..89d161a50b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransport.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\MicrosoftTeams; + +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Edouard Lescot + * @author Oskar Stark + */ +final class MicrosoftTeamsTransport extends AbstractTransport +{ + protected const ENDPOINT = 'outlook.office.com'; + + private $path; + + public function __construct(string $path, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->path = $path; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('microsoftteams://%s%s', $this->getEndpoint(), $this->path); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage; + } + + /** + * @see https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#post-a-message-to-the-webhook-using-curl + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + $path = $message->getRecipientId() ?? $this->path; + $endpoint = sprintf('https://%s%s', $this->getEndpoint(), $path); + $response = $this->client->request('POST', $endpoint, [ + 'json' => [ + 'title' => $message->getSubject(), + ], + ]); + + $requestId = $response->getHeaders(false)['request-id'][0] ?? null; + if (null === $requestId) { + $originalContent = $message->getSubject(); + + throw new TransportException(sprintf('Unable to post the Microsoft Teams message: "%s" (request-id not found).', $originalContent), $response); + } + + if (200 !== $response->getStatusCode()) { + $errorMessage = $response->getContent(false); + $originalContent = $message->getSubject(); + + throw new TransportException(sprintf('Unable to post the Microsoft Teams message: "%s" (%s : "%s").', $originalContent, $requestId, $errorMessage), $response); + } + + $message = new SentMessage($message, (string) $this); + $message->setMessageId($requestId); + + return $message; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransportFactory.php new file mode 100644 index 0000000000..1e4b411e5a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/MicrosoftTeamsTransportFactory.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\MicrosoftTeams; + +use Symfony\Component\Notifier\Exception\IncompleteDsnException; +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 Edouard Lescot + * @author Oskar Stark + */ +final class MicrosoftTeamsTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('microsoftteams' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'microsoftteams', $this->getSupportedSchemes()); + } + + $path = $dsn->getPath(); + + if (null === $path) { + throw new IncompleteDsnException('Path is not set.', $dsn->getOriginalDsn()); + } + + $host = $dsn->getHost(); + $port = $dsn->getPort(); + + return (new MicrosoftTeamsTransport($path, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['microsoftteams']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/README.md b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/README.md new file mode 100644 index 0000000000..39918efe4a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/README.md @@ -0,0 +1,23 @@ +Microsoft Teams Notifier +======================== + +Provides [Microsoft Teams](https://www.microsoft.com/en/microsoft-365/microsoft-teams/free) integration +through Incoming Webhook for Symfony Notifier. + +DSN example +----------- + +``` +MICROSOFT_TEAMS_DSN=microsoftteams://default/PATH +``` + +where: + - `PATH` has the following format: `webhook/{uuid}@{uuid}/IncomingWebhook/{id}/{uuid}` + +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/MicrosoftTeams/Tests/MicrosoftTeamsTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportFactoryTest.php new file mode 100644 index 0000000000..cbf6c48e40 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportFactoryTest.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\Bridge\MicrosoftTeams\Tests; + +use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +final class MicrosoftTeamsTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): TransportFactoryInterface + { + return new MicrosoftTeamsTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'microsoftteams://host/webhook', + 'microsoftteams://host/webhook', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'microsoftteams://host/webhook']; + yield [false, 'somethingElse://host/webhook']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://host/webhook']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportTest.php b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportTest.php new file mode 100644 index 0000000000..bab4680a4e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/Tests/MicrosoftTeamsTransportTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\MicrosoftTeams\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransport; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class MicrosoftTeamsTransportTest extends TransportTestCase +{ + /** + * @return MicrosoftTeamsTransport + */ + public function createTransport(?HttpClientInterface $client = null): TransportInterface + { + return (new MicrosoftTeamsTransport('/testPath', $client ?: $this->createMock(HttpClientInterface::class)))->setHost('host.test'); + } + + public function toStringProvider(): iterable + { + yield ['microsoftteams://host.test/testPath', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } + + public function testSendWithErrorResponseThrows() + { + $client = new MockHttpClient(function (string $method, string $url, array $options = []): ResponseInterface { + return new MockResponse('testErrorMessage', ['response_headers' => ['request-id' => ['testRequestId']], 'http_code' => 400]); + }); + + $transport = $this->createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessageMatches('/testErrorMessage/'); + + $transport->send(new ChatMessage('testMessage')); + } + + public function testSendWithErrorRequestIdThrows() + { + $client = new MockHttpClient(new MockResponse()); + + $transport = $this->createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessageMatches('/request-id not found/'); + + $transport->send(new ChatMessage('testMessage')); + } + + public function testSend() + { + $message = 'testMessage'; + + $expectedBody = json_encode(['title' => $message]); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ($expectedBody): ResponseInterface { + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse('1', ['response_headers' => ['request-id' => ['testRequestId']], 'http_code' => 200]); + }); + + $transport = $this->createTransport($client); + + $transport->send(new ChatMessage($message)); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/composer.json b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/composer.json new file mode 100644 index 0000000000..79de23bc26 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/microsoft-teams-notifier", + "type": "symfony-bridge", + "description": "Symfony Microsoft Teams Notifier Bridge", + "keywords": ["chat", "microsoft-teams", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Edouard Lescot", + "email": "edouard.lescot@gmail.com" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.4|^5.2", + "symfony/notifier": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\MicrosoftTeams\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/phpunit.xml.dist new file mode 100644 index 0000000000..5e92ccb908 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/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 da3657263d..64cc063e5a 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -128,6 +128,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\LightSms\LightSmsTransportFactory::class, 'package' => 'symfony/lightsms-notifier', ], + 'microsoftteams' => [ + 'class' => Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class, + 'package' => 'symfony/microsoft-teams-notifier', + ], ]; /** diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index 76feae13a1..4217ffe076 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -23,6 +23,7 @@ use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LightSms\LightSmsTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; +use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransport; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; @@ -76,6 +77,7 @@ class Transport GitterTransportFactory::class, ClickatellTransportFactory::class, LightSmsTransportFactory::class, + MicrosoftTeamsTransport::class, ]; private $factories;