diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyOptions.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyOptions.php new file mode 100644 index 000000000000..fe576986164e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyOptions.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\Bluesky; + +use Symfony\Component\Mime\Part\File; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +final class BlueskyOptions implements MessageOptionsInterface +{ + public function __construct( + private array $options = [], + ) { + } + + public function toArray(): array + { + return $this->options; + } + + public function getRecipientId(): ?string + { + return null; + } + + /** + * @return $this + */ + public function attachMedia(File $file, string $description = ''): static + { + $this->options['attach'][] = [ + 'file' => $file, + 'description' => $description, + ]; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php index 2a3552e83402..71e6af7ede51 100644 --- a/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Notifier\Bridge\Bluesky; use Psr\Log\LoggerInterface; +use Symfony\Component\Mime\Part\File; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; use Symfony\Component\Notifier\Message\ChatMessage; @@ -65,19 +66,28 @@ protected function doSend(MessageInterface $message): SentMessage $post = [ '$type' => 'app.bsky.feed.post', 'text' => $message->getSubject(), - 'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\\TH:i:s.u\\Z'), + 'createdAt' => \DateTimeImmutable::createFromFormat('U', time())->format('Y-m-d\\TH:i:s.u\\Z'), ]; if ([] !== $facets = $this->parseFacets($post['text'])) { $post['facets'] = $facets; } + $options = $message->getOptions()?->toArray() ?? []; + $options['repo'] = $this->authSession['did'] ?? null; + $options['collection'] = 'app.bsky.feed.post'; + $options['record'] = $post; + + if (isset($options['attach'])) { + $options['record']['embed'] = [ + '$type' => 'app.bsky.embed.images', + 'images' => $this->uploadMedia($options['attach']), + ]; + unset($options['attach']); + } + $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, - ], + 'json' => $options, ]); try { @@ -222,4 +232,51 @@ private function getMatchAndPosition(AbstractString $text, string $regex): array return $output; } + + /** + * @param array $media + * + * @return array + */ + private function uploadMedia(array $media): array + { + $pool = []; + + foreach ($media as ['file' => $file, 'description' => $description]) { + $pool[] = [ + 'description' => $description, + 'response' => $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.repo.uploadBlob', $this->getEndpoint()), [ + 'auth_bearer' => $this->authSession['accessJwt'] ?? null, + 'headers' => [ + 'Content-Type: '.$file->getContentType(), + ], + 'body' => fopen($file->getPath(), 'r'), + ]), + ]; + } + + $embeds = []; + + try { + foreach ($pool as $i => ['description' => $description, 'response' => $response]) { + unset($pool[$i]); + $result = $response->toArray(false); + + if (300 <= $response->getStatusCode()) { + throw new TransportException('Unable to embed medias.', $response); + } + + $embeds[] = [ + 'alt' => $description, + 'image' => $result['blob'], + ]; + } + } finally { + foreach ($pool as ['response' => $response]) { + $response->cancel(); + } + } + + return $embeds; + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md index 5be39cbeeb95..d337db00df01 100644 --- a/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add option to attach a media + 7.1 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php index f6c5005c666c..59a6e76194e2 100644 --- a/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php @@ -12,8 +12,11 @@ namespace Symfony\Component\Notifier\Bridge\Bluesky\Tests; use Psr\Log\NullLogger; +use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Mime\Part\File; +use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyOptions; use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyTransport; use Symfony\Component\Notifier\Exception\LogicException; use Symfony\Component\Notifier\Message\ChatMessage; @@ -25,6 +28,12 @@ final class BlueskyTransportTest extends TransportTestCase { + protected function setUp(): void + { + ClockMock::register(self::class); + ClockMock::withClockMock(1714293617); + } + public static function createTransport(?HttpClientInterface $client = null): BlueskyTransport { $blueskyTransport = new BlueskyTransport('username', 'password', new NullLogger(), $client ?? new MockHttpClient()); @@ -264,6 +273,48 @@ public function testParseFacetsUrlWithTrickyRegex() $this->assertEquals($expected, $this->parseFacets($input)); } + public function testWithMedia() + { + $transport = $this->createTransport(new MockHttpClient((function () { + yield function (string $method, string $url, array $options) { + $this->assertSame('POST', $method); + $this->assertSame('https://bsky.social/xrpc/com.atproto.server.createSession', $url); + + return new JsonMockResponse(['accessJwt' => 'foo']); + }; + + yield function (string $method, string $url, array $options) { + $this->assertSame('POST', $method); + $this->assertSame('https://bsky.social/xrpc/com.atproto.repo.uploadBlob', $url); + $this->assertArrayHasKey('authorization', $options['normalized_headers']); + + return new JsonMockResponse(['blob' => [ + '$type' => 'blob', + 'ref' => [ + '$link' => 'bafkreibabalobzn6cd366ukcsjycp4yymjymgfxcv6xczmlgpemzkz3cfa', + ], + 'mimeType' => 'image/png', + 'size' => 760898, + ]]); + }; + + yield function (string $method, string $url, array $options) { + $this->assertSame('POST', $method); + $this->assertSame('https://bsky.social/xrpc/com.atproto.repo.createRecord', $url); + $this->assertArrayHasKey('authorization', $options['normalized_headers']); + $this->assertSame('{"repo":null,"collection":"app.bsky.feed.post","record":{"$type":"app.bsky.feed.post","text":"Hello World!","createdAt":"2024-04-28T08:40:17.000000Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"A fixture","image":{"$type":"blob","ref":{"$link":"bafkreibabalobzn6cd366ukcsjycp4yymjymgfxcv6xczmlgpemzkz3cfa"},"mimeType":"image\/png","size":760898}}]}}}', $options['body']); + + return new JsonMockResponse(['cid' => '103254962155278888']); + }; + })())); + + $options = (new BlueskyOptions()) + ->attachMedia(new File(__DIR__.'/fixtures.gif'), 'A fixture'); + $result = $transport->send(new ChatMessage('Hello World!', $options)); + + $this->assertSame('103254962155278888', $result->getMessageId()); + } + /** * A small helper function to test BlueskyTransport::parseFacets(). */ diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/fixtures.gif b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/fixtures.gif new file mode 100644 index 000000000000..443aca422f76 Binary files /dev/null and b/src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/fixtures.gif differ diff --git a/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json b/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json index 453dd757bc57..3f5fa2558379 100644 --- a/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json @@ -23,9 +23,12 @@ "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/http-client": "^6.4|^7.0", - "symfony/notifier": "^7.1", + "symfony/notifier": "^7.2", "symfony/string": "^6.4|^7.0" }, + "require-dev": { + "symfony/mime": "^6.4|^7.0" + }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Bluesky\\": "" }, "exclude-from-classmap": [