Skip to content

Commit

Permalink
[Notifier] Allow to send image to Bluesky
Browse files Browse the repository at this point in the history
  • Loading branch information
jdecool committed Apr 28, 2024
1 parent c834064 commit 43e51aa
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 6 deletions.
46 changes: 46 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}
69 changes: 63 additions & 6 deletions src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -222,4 +232,51 @@ private function getMatchAndPosition(AbstractString $text, string $regex): array

return $output;
}

/**
* @param array<array{file: File, description: string}> $media
*
* @return array<array{alt: string, image: array{$type: string, ref: array{$link: string}, mimeType: string, size: int}}>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand Down Expand Up @@ -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) { // did
$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().
*/
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"php": ">=8.2",
"psr/log": "^1|^2|^3",
"symfony/http-client": "^6.4|^7.0",
"symfony/mime": "^7.1",
"symfony/notifier": "^7.1",
"symfony/string": "^6.4|^7.0"
},
Expand Down

0 comments on commit 43e51aa

Please sign in to comment.