From a9d6f3cf43a74a9674f0884fb772b9d85f16ba7c Mon Sep 17 00:00:00 2001 From: Nyholm Date: Thu, 14 Dec 2023 19:51:27 +0100 Subject: [PATCH] [Notifier] Add Bluesky notifier bridge --- .../FrameworkExtension.php | 1 + .../Resources/config/notifier_transports.php | 4 + .../Notifier/Bridge/Bluesky/.gitattributes | 4 + .../Notifier/Bridge/Bluesky/.gitignore | 3 + .../Bridge/Bluesky/BlueskyTransport.php | 227 ++++++++++++++ .../Bluesky/BlueskyTransportFactory.php | 55 ++++ .../Notifier/Bridge/Bluesky/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Bluesky/LICENSE | 19 ++ .../Notifier/Bridge/Bluesky/README.md | 19 ++ .../Tests/BlueskyTransportFactoryTest.php | 48 +++ .../Bluesky/Tests/BlueskyTransportTest.php | 280 ++++++++++++++++++ .../Notifier/Bridge/Bluesky/composer.json | 36 +++ .../Notifier/Bridge/Bluesky/phpunit.xml.dist | 31 ++ 13 files changed, 734 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/.gitignore create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Bluesky/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index d160942f60477..c25327f3d98be 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2695,6 +2695,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\AllMySms\AllMySmsTransportFactory::class => 'notifier.transport_factory.all-my-sms', NotifierBridge\AmazonSns\AmazonSnsTransportFactory::class => 'notifier.transport_factory.amazon-sns', NotifierBridge\Bandwidth\BandwidthTransportFactory::class => 'notifier.transport_factory.bandwidth', + NotifierBridge\Bluesky\BlueskyTransportFactory::class => 'notifier.transport_factory.bluesky', NotifierBridge\Brevo\BrevoTransportFactory::class => 'notifier.transport_factory.brevo', NotifierBridge\Chatwork\ChatworkTransportFactory::class => 'notifier.transport_factory.chatwork', NotifierBridge\Clickatell\ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index 3feb1c080c623..f678e0588672f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -22,6 +22,10 @@ ->abstract() ->args([service('event_dispatcher'), service('http_client')->ignoreOnInvalid()]) + ->set('notifier.transport_factory.bluesky', Bridge\Bluesky\BlueskyTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.brevo', Bridge\Brevo\BrevoTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Bluesky/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/.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/Bluesky/.gitignore b/src/Symfony/Component/Notifier/Bridge/Bluesky/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php new file mode 100644 index 0000000000000..dc307a9aea6be --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Bluesky; + +use Psr\Log\LoggerInterface; +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\Component\String\AbstractString; +use Symfony\Component\String\ByteString; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Tobias Nyholm + */ +final class BlueskyTransport extends AbstractTransport +{ + private array $authSession = []; + + public function __construct( + #[\SensitiveParameter] + private string $user, + #[\SensitiveParameter] + private string $password, + private LoggerInterface $logger, + HttpClientInterface $client = null, + EventDispatcherInterface $dispatcher = null, + ) { + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('bluesky://%s', $this->getEndpoint()); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + if ([] === $this->authSession) { + $this->authenticate(); + } + + $post = [ + '$type' => 'app.bsky.feed.post', + 'text' => $message->getSubject(), + 'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\\TH:i:s.u\\Z'), + ]; + if ([] !== $facets = $this->parseFacets($post['text'])) { + $post['facets'] = $facets; + } + + $response = $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.repo.createRecord', $this->getEndpoint()), [ + 'auth_bearer' => $this->authSession['accessJwt'] ?? null, + 'json' => [ + 'repo' => $this->authSession['did'] ?? null, + 'collection' => 'app.bsky.feed.post', + 'record' => $post, + ], + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote bluesky server.', $response, 0, $e); + } + + if (200 === $statusCode) { + $content = $response->toArray(); + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($content['cid']); + + return $sentMessage; + } + + try { + $content = $response->toArray(false); + } catch (DecodingExceptionInterface $e) { + throw new TransportException('Unexpected response from bluesky server.', $response, 0, $e); + } + + $title = $content['error'] ?? ''; + $errorDescription = $content['message'] ?? ''; + + throw new TransportException(sprintf('Unable to send message to Bluesky: Status code %d (%s) with message "%s".', $statusCode, $title, $errorDescription), $response); + } + + private function authenticate(): void + { + $response = $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.server.createSession', $this->getEndpoint()), [ + 'json' => [ + 'identifier' => $this->user, + 'password' => $this->password, + ], + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote bluesky server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + throw new TransportException('Could not authenticate with the remote bluesky server.', $response); + } + + try { + $this->authSession = $response->toArray(false) ?? []; + } catch (DecodingExceptionInterface $e) { + throw new TransportException('Unexpected response from bluesky server.', $response, 0, $e); + } + } + + private function parseFacets(string $input): array + { + $facets = []; + $text = new ByteString($input); + + // regex based on: https://bluesky.com/specs/handle#handle-identifier-syntax + $regex = '#[$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)#'; + foreach ($this->getMatchAndPosition($text, $regex) as $match) { + $response = $this->client->request('GET', sprintf('https://%s/xrpc/com.atproto.identity.resolveHandle', $this->getEndpoint()), [ + 'query' => [ + 'handle' => ltrim($match['match'], '@'), + ], + ]); + try { + if (200 !== $response->getStatusCode()) { + continue; + } + } catch (TransportExceptionInterface $e) { + $this->logger->error('Could not reach the remote bluesky server. Tried to lookup username.', ['exception' => $e]); + throw $e; + } + + $did = $response->toArray(false)['did'] ?? null; + if (null === $did) { + $this->logger->error('Could not get a good response from bluesky server. Tried to lookup username.'); + continue; + } + + $facets[] = [ + 'index' => [ + 'byteStart' => $match['start'], + 'byteEnd' => $match['end'], + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#mention', + 'did' => $did, + ], + ], + ]; + } + + // partial/naive URL regex based on: https://stackoverflow.com/a/3809435 + // tweaked to disallow some trailing punctuation + $regex = ';[$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?);'; + foreach ($this->getMatchAndPosition($text, $regex) as $match) { + $facets[] = [ + 'index' => [ + 'byteStart' => $match['start'], + 'byteEnd' => $match['end'], + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#link', + 'uri' => $match['match'], + ], + ], + ]; + } + + return $facets; + } + + private function getMatchAndPosition(AbstractString $text, string $regex): array + { + $output = []; + $handled = []; + $matches = $text->match($regex, \PREG_PATTERN_ORDER); + if ([] === $matches) { + return $output; + } + + $length = $text->length(); + foreach ($matches[1] as $match) { + if (isset($handled[$match])) { + continue; + } + $handled[$match] = true; + $end = -1; + while (null !== $start = $text->indexOf($match, min($length, $end + 1))) { + $output[] = [ + 'start' => $start, + 'end' => $end = $start + (new ByteString($match))->length(), + 'match' => $match, + ]; + } + } + + return $output; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransportFactory.php new file mode 100644 index 0000000000000..bf949ea6c56d3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransportFactory.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Bluesky; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Tobias Nyholm + */ +final class BlueskyTransportFactory extends AbstractTransportFactory +{ + public function __construct( + EventDispatcherInterface $dispatcher = null, + HttpClientInterface $client = null, + private ?LoggerInterface $logger = null + ) { + parent::__construct($dispatcher, $client); + } + + public function create(Dsn $dsn): BlueskyTransport + { + $scheme = $dsn->getScheme(); + + if ('bluesky' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'bluesky', $this->getSupportedSchemes()); + } + + $user = $this->getUser($dsn); + $secret = $this->getPassword($dsn); + + return (new BlueskyTransport($user, $secret, $this->logger ?? new NullLogger(), $this->client, $this->dispatcher)) + ->setHost($dsn->getHost()) + ->setPort($dsn->getPort()); + } + + protected function getSupportedSchemes(): array + { + return ['bluesky']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md new file mode 100644 index 0000000000000..5be39cbeeb951 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/LICENSE b/src/Symfony/Component/Notifier/Bridge/Bluesky/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/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/Bluesky/README.md b/src/Symfony/Component/Notifier/Bridge/Bluesky/README.md new file mode 100644 index 0000000000000..72f5bb9000f58 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/README.md @@ -0,0 +1,19 @@ +Bluesky Notifier +================ + +Provides [Bluesky](https://bsky.app/) integration for Symfony Notifier. + +DSN example +----------- + +``` +BLUESKY_DSN=bluesky://nyholm.bsky.social:p4ssw0rd@bsky.social +``` + +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/Bluesky/Tests/BlueskyTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportFactoryTest.php new file mode 100644 index 0000000000000..5f5b9a37ee47f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportFactoryTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Bluesky\Tests; + +use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; + +class BlueskyTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): BlueskyTransportFactory + { + return new BlueskyTransportFactory(); + } + + public static function createProvider(): iterable + { + yield [ + 'bluesky://bsky.social', + 'bluesky://user:pass@bsky.social', + ]; + } + + public static function supportsProvider(): iterable + { + yield [true, 'bluesky://foo:bar@bsky.social']; + yield [false, 'somethingElse://foo:bar@bsky.social']; + } + + public static function incompleteDsnProvider(): iterable + { + yield 'missing user and password token' => ['bluesky://host']; + yield 'missing password token' => ['bluesky://user@host']; + } + + public static function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://foo:bar@default']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php new file mode 100644 index 0000000000000..bcf0d04fa2e1d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php @@ -0,0 +1,280 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Bluesky\Tests; + +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyTransport; +use Symfony\Component\Notifier\Exception\LogicException; +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\Tests\Transport\DummyMessage; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class BlueskyTransportTest extends TransportTestCase +{ + public static function createTransport(HttpClientInterface $client = null): BlueskyTransport + { + $blueskyTransport = new BlueskyTransport('username', 'password', new NullLogger(), $client ?? new MockHttpClient()); + $blueskyTransport->setHost('bsky.social'); + + return $blueskyTransport; + } + + public static function toStringProvider(): iterable + { + yield ['bluesky://bsky.social', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('+33612345678', 'Hello!')]; + yield [new DummyMessage()]; + } + + public function testExceptionIsThrownWhenNoMessageIsSent() + { + $transport = self::createTransport(); + + $this->expectException(LogicException::class); + $transport->send($this->createMock(MessageInterface::class)); + } + + /** + * Example from + * - https://atproto.com/blog/create-post + * - https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacets() + { + $input = '✨ example mentioning @atproto.com the URL 👨‍❤️‍👨 https://en.wikipedia.org/wiki/CBOR.'; + $expected = + [ + [ + 'index' => ['byteStart' => 23, 'byteEnd' => 35], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did=>plc=>ewvi7nxzyoun6zhxrhs64oiz'], + ], + ], + [ + 'index' => ['byteStart' => 65, 'byteEnd' => 99], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://en.wikipedia.org/wiki/CBOR'], + ], + ], + ]; + $output = $this->parseFacets($input, new MockHttpClient(new JsonMockResponse(['did' => 'did=>plc=>ewvi7nxzyoun6zhxrhs64oiz']))); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsMultipleHandles() + { + $input = 'prefix @handle.example.com @handle.com suffix'; + $expected = [ + [ + 'index' => ['byteStart' => 7, 'byteEnd' => 26], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did1'], + ], + ], + [ + 'index' => ['byteStart' => 27, 'byteEnd' => 38], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did2'], + ], + ], + ]; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'did1']), new JsonMockResponse(['did' => 'did2'])])); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsNoHandles() + { + $input = 'handle.example.com'; + $expected = []; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'no_value'])])); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsInvalidHandle() + { + $input = '@bare'; + $expected = []; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'no_value'])])); + $this->assertEquals($expected, $output); + + $input = 'email@example.com'; + $expected = []; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'no_value'])])); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsMentionWithEmoji() + { + $input = '💩💩💩 @handle.example.com'; + $expected = [ + [ + 'index' => ['byteStart' => 13, 'byteEnd' => 32], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did0'], + ], + ], + ]; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'did0'])])); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsWithEmail() + { + $input = 'cc:@example.com'; + $expected = [ + [ + 'index' => ['byteStart' => 3, 'byteEnd' => 15], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did0'], + ], + ], + ]; + $output = $this->parseFacets($input, new MockHttpClient([new JsonMockResponse(['did' => 'did0'])])); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsUrl() + { + $input = 'prefix https://example.com/index.html http://bsky.app suffix'; + $expected = [ + [ + 'index' => ['byteStart' => 7, 'byteEnd' => 37], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://example.com/index.html'], + ], + ], + [ + 'index' => ['byteStart' => 38, 'byteEnd' => 53], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'http://bsky.app'], + ], + ], + ]; + $output = $this->parseFacets($input); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsNoUrls() + { + $input = 'example.com'; + $expected = []; + $output = $this->parseFacets($input); + $this->assertEquals($expected, $output); + + $input = 'runonhttp://blah.comcontinuesafter'; + $expected = []; + $output = $this->parseFacets($input); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsUrlWithEmoji() + { + $input = '💩💩💩 http://bsky.app'; + $expected = [ + [ + 'index' => ['byteStart' => 13, 'byteEnd' => 28], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'http://bsky.app']], + ], + ]; + $output = $this->parseFacets($input); + $this->assertEquals($expected, $output); + } + + /** + * Example from https://github.com/bluesky-social/atproto-website/blob/main/examples/create_bsky_post.py. + */ + public function testParseFacetsUrlWithTrickyRegex() + { + $input = 'ref [https://bsky.app]'; + $expected = [ + [ + 'index' => ['byteStart' => 5, 'byteEnd' => 21], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://bsky.app']], + ], + ]; + $this->assertEquals($expected, $this->parseFacets($input)); + + $input = 'ref (https://bsky.app/)'; + $expected = [ + [ + 'index' => ['byteStart' => 5, 'byteEnd' => 22], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://bsky.app/']], + ], + ]; + $this->assertEquals($expected, $this->parseFacets($input)); + + $input = 'ends https://bsky.app. what else?'; + $expected = [ + [ + 'index' => ['byteStart' => 5, 'byteEnd' => 21], + 'features' => [ + ['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://bsky.app']], + ], + ]; + $this->assertEquals($expected, $this->parseFacets($input)); + } + + /** + * A small helper function to test BlueskyTransport::parseFacets(). + */ + private function parseFacets(string $input, HttpClientInterface $httpClient = null): array + { + $class = new \ReflectionClass(BlueskyTransport::class); + $method = $class->getMethod('parseFacets'); + $method->setAccessible(true); + + $object = $class->newInstance('user', 'pass', new NullLogger(), $httpClient ?? new MockHttpClient([])); + + return $method->invoke($object, $input); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json b/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json new file mode 100644 index 0000000000000..453dd757bc574 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/bluesky-notifier", + "type": "symfony-notifier-bridge", + "description": "Symfony Bluesky Notifier Bridge", + "keywords": ["bluesky", "bluesky", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.1", + "symfony/string": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Bluesky\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Bluesky/phpunit.xml.dist new file mode 100644 index 0000000000000..99623d7aefed3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +