Skip to content

Commit fb8a294

Browse files
jdecoolfabpot
authored andcommitted
[Notifier] [Bluesky] Allow to attach image
1 parent eccdbea commit fb8a294

File tree

6 files changed

+169
-7
lines changed

6 files changed

+169
-7
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Bluesky;
13+
14+
use Symfony\Component\Mime\Part\File;
15+
use Symfony\Component\Notifier\Message\MessageOptionsInterface;
16+
17+
final class BlueskyOptions implements MessageOptionsInterface
18+
{
19+
public function __construct(
20+
private array $options = [],
21+
) {
22+
}
23+
24+
public function toArray(): array
25+
{
26+
return $this->options;
27+
}
28+
29+
public function getRecipientId(): ?string
30+
{
31+
return null;
32+
}
33+
34+
/**
35+
* @return $this
36+
*/
37+
public function attachMedia(File $file, string $description = ''): static
38+
{
39+
$this->options['attach'][] = [
40+
'file' => $file,
41+
'description' => $description,
42+
];
43+
44+
return $this;
45+
}
46+
}

src/Symfony/Component/Notifier/Bridge/Bluesky/BlueskyTransport.php

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Notifier\Bridge\Bluesky;
1313

1414
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\Mime\Part\File;
1516
use Symfony\Component\Notifier\Exception\TransportException;
1617
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
1718
use Symfony\Component\Notifier\Message\ChatMessage;
@@ -65,19 +66,28 @@ protected function doSend(MessageInterface $message): SentMessage
6566
$post = [
6667
'$type' => 'app.bsky.feed.post',
6768
'text' => $message->getSubject(),
68-
'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\\TH:i:s.u\\Z'),
69+
'createdAt' => \DateTimeImmutable::createFromFormat('U', time())->format('Y-m-d\\TH:i:s.u\\Z'),
6970
];
7071
if ([] !== $facets = $this->parseFacets($post['text'])) {
7172
$post['facets'] = $facets;
7273
}
7374

75+
$options = $message->getOptions()?->toArray() ?? [];
76+
$options['repo'] = $this->authSession['did'] ?? null;
77+
$options['collection'] = 'app.bsky.feed.post';
78+
$options['record'] = $post;
79+
80+
if (isset($options['attach'])) {
81+
$options['record']['embed'] = [
82+
'$type' => 'app.bsky.embed.images',
83+
'images' => $this->uploadMedia($options['attach']),
84+
];
85+
unset($options['attach']);
86+
}
87+
7488
$response = $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.repo.createRecord', $this->getEndpoint()), [
7589
'auth_bearer' => $this->authSession['accessJwt'] ?? null,
76-
'json' => [
77-
'repo' => $this->authSession['did'] ?? null,
78-
'collection' => 'app.bsky.feed.post',
79-
'record' => $post,
80-
],
90+
'json' => $options,
8191
]);
8292

8393
try {
@@ -222,4 +232,51 @@ private function getMatchAndPosition(AbstractString $text, string $regex): array
222232

223233
return $output;
224234
}
235+
236+
/**
237+
* @param array<array{file: File, description: string}> $media
238+
*
239+
* @return array<array{alt: string, image: array{$type: string, ref: array{$link: string}, mimeType: string, size: int}}>
240+
*/
241+
private function uploadMedia(array $media): array
242+
{
243+
$pool = [];
244+
245+
foreach ($media as ['file' => $file, 'description' => $description]) {
246+
$pool[] = [
247+
'description' => $description,
248+
'response' => $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.repo.uploadBlob', $this->getEndpoint()), [
249+
'auth_bearer' => $this->authSession['accessJwt'] ?? null,
250+
'headers' => [
251+
'Content-Type: '.$file->getContentType(),
252+
],
253+
'body' => fopen($file->getPath(), 'r'),
254+
]),
255+
];
256+
}
257+
258+
$embeds = [];
259+
260+
try {
261+
foreach ($pool as $i => ['description' => $description, 'response' => $response]) {
262+
unset($pool[$i]);
263+
$result = $response->toArray(false);
264+
265+
if (300 <= $response->getStatusCode()) {
266+
throw new TransportException('Unable to embed medias.', $response);
267+
}
268+
269+
$embeds[] = [
270+
'alt' => $description,
271+
'image' => $result['blob'],
272+
];
273+
}
274+
} finally {
275+
foreach ($pool as ['response' => $response]) {
276+
$response->cancel();
277+
}
278+
}
279+
280+
return $embeds;
281+
}
225282
}

src/Symfony/Component/Notifier/Bridge/Bluesky/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Add option to attach a media
8+
49
7.1
510
---
611

src/Symfony/Component/Notifier/Bridge/Bluesky/Tests/BlueskyTransportTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
namespace Symfony\Component\Notifier\Bridge\Bluesky\Tests;
1313

1414
use Psr\Log\NullLogger;
15+
use Symfony\Bridge\PhpUnit\ClockMock;
1516
use Symfony\Component\HttpClient\MockHttpClient;
1617
use Symfony\Component\HttpClient\Response\JsonMockResponse;
18+
use Symfony\Component\Mime\Part\File;
19+
use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyOptions;
1720
use Symfony\Component\Notifier\Bridge\Bluesky\BlueskyTransport;
1821
use Symfony\Component\Notifier\Exception\LogicException;
1922
use Symfony\Component\Notifier\Message\ChatMessage;
@@ -25,6 +28,12 @@
2528

2629
final class BlueskyTransportTest extends TransportTestCase
2730
{
31+
protected function setUp(): void
32+
{
33+
ClockMock::register(self::class);
34+
ClockMock::withClockMock(1714293617);
35+
}
36+
2837
public static function createTransport(?HttpClientInterface $client = null): BlueskyTransport
2938
{
3039
$blueskyTransport = new BlueskyTransport('username', 'password', new NullLogger(), $client ?? new MockHttpClient());
@@ -264,6 +273,48 @@ public function testParseFacetsUrlWithTrickyRegex()
264273
$this->assertEquals($expected, $this->parseFacets($input));
265274
}
266275

276+
public function testWithMedia()
277+
{
278+
$transport = $this->createTransport(new MockHttpClient((function () {
279+
yield function (string $method, string $url, array $options) {
280+
$this->assertSame('POST', $method);
281+
$this->assertSame('https://bsky.social/xrpc/com.atproto.server.createSession', $url);
282+
283+
return new JsonMockResponse(['accessJwt' => 'foo']);
284+
};
285+
286+
yield function (string $method, string $url, array $options) {
287+
$this->assertSame('POST', $method);
288+
$this->assertSame('https://bsky.social/xrpc/com.atproto.repo.uploadBlob', $url);
289+
$this->assertArrayHasKey('authorization', $options['normalized_headers']);
290+
291+
return new JsonMockResponse(['blob' => [
292+
'$type' => 'blob',
293+
'ref' => [
294+
'$link' => 'bafkreibabalobzn6cd366ukcsjycp4yymjymgfxcv6xczmlgpemzkz3cfa',
295+
],
296+
'mimeType' => 'image/png',
297+
'size' => 760898,
298+
]]);
299+
};
300+
301+
yield function (string $method, string $url, array $options) {
302+
$this->assertSame('POST', $method);
303+
$this->assertSame('https://bsky.social/xrpc/com.atproto.repo.createRecord', $url);
304+
$this->assertArrayHasKey('authorization', $options['normalized_headers']);
305+
$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']);
306+
307+
return new JsonMockResponse(['cid' => '103254962155278888']);
308+
};
309+
})()));
310+
311+
$options = (new BlueskyOptions())
312+
->attachMedia(new File(__DIR__.'/fixtures.gif'), 'A fixture');
313+
$result = $transport->send(new ChatMessage('Hello World!', $options));
314+
315+
$this->assertSame('103254962155278888', $result->getMessageId());
316+
}
317+
267318
/**
268319
* A small helper function to test BlueskyTransport::parseFacets().
269320
*/
Loading

src/Symfony/Component/Notifier/Bridge/Bluesky/composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@
2323
"php": ">=8.2",
2424
"psr/log": "^1|^2|^3",
2525
"symfony/http-client": "^6.4|^7.0",
26-
"symfony/notifier": "^7.1",
26+
"symfony/notifier": "^7.2",
2727
"symfony/string": "^6.4|^7.0"
2828
},
29+
"require-dev": {
30+
"symfony/mime": "^6.4|^7.0"
31+
},
2932
"autoload": {
3033
"psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Bluesky\\": "" },
3134
"exclude-from-classmap": [

0 commit comments

Comments
 (0)