Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a9d6f3c

Browse files
Nyholmfabpot
authored andcommitted
[Notifier] Add Bluesky notifier bridge
1 parent a3686fb commit a9d6f3c

File tree

13 files changed

+734
-0
lines changed

13 files changed

+734
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -2695,6 +2695,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
26952695
NotifierBridge\AllMySms\AllMySmsTransportFactory::class => 'notifier.transport_factory.all-my-sms',
26962696
NotifierBridge\AmazonSns\AmazonSnsTransportFactory::class => 'notifier.transport_factory.amazon-sns',
26972697
NotifierBridge\Bandwidth\BandwidthTransportFactory::class => 'notifier.transport_factory.bandwidth',
2698+
NotifierBridge\Bluesky\BlueskyTransportFactory::class => 'notifier.transport_factory.bluesky',
26982699
NotifierBridge\Brevo\BrevoTransportFactory::class => 'notifier.transport_factory.brevo',
26992700
NotifierBridge\Chatwork\ChatworkTransportFactory::class => 'notifier.transport_factory.chatwork',
27002701
NotifierBridge\Clickatell\ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell',

src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
->abstract()
2323
->args([service('event_dispatcher'), service('http_client')->ignoreOnInvalid()])
2424

25+
->set('notifier.transport_factory.bluesky', Bridge\Bluesky\BlueskyTransportFactory::class)
26+
->parent('notifier.transport_factory.abstract')
27+
->tag('chatter.transport_factory')
28+
2529
->set('notifier.transport_factory.brevo', Bridge\Brevo\BrevoTransportFactory::class)
2630
->parent('notifier.transport_factory.abstract')
2731
->tag('texter.transport_factory')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.gitattributes export-ignore
4+
/.gitignore export-ignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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 Psr\Log\LoggerInterface;
15+
use Symfony\Component\Notifier\Exception\TransportException;
16+
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
17+
use Symfony\Component\Notifier\Message\ChatMessage;
18+
use Symfony\Component\Notifier\Message\MessageInterface;
19+
use Symfony\Component\Notifier\Message\SentMessage;
20+
use Symfony\Component\Notifier\Transport\AbstractTransport;
21+
use Symfony\Component\String\AbstractString;
22+
use Symfony\Component\String\ByteString;
23+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
24+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
25+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
26+
use Symfony\Contracts\HttpClient\HttpClientInterface;
27+
28+
/**
29+
* @author Tobias Nyholm <[email protected]>
30+
*/
31+
final class BlueskyTransport extends AbstractTransport
32+
{
33+
private array $authSession = [];
34+
35+
public function __construct(
36+
#[\SensitiveParameter]
37+
private string $user,
38+
#[\SensitiveParameter]
39+
private string $password,
40+
private LoggerInterface $logger,
41+
HttpClientInterface $client = null,
42+
EventDispatcherInterface $dispatcher = null,
43+
) {
44+
parent::__construct($client, $dispatcher);
45+
}
46+
47+
public function __toString(): string
48+
{
49+
return sprintf('bluesky://%s', $this->getEndpoint());
50+
}
51+
52+
public function supports(MessageInterface $message): bool
53+
{
54+
return $message instanceof ChatMessage;
55+
}
56+
57+
protected function doSend(MessageInterface $message): SentMessage
58+
{
59+
if (!$message instanceof ChatMessage) {
60+
throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message);
61+
}
62+
63+
if ([] === $this->authSession) {
64+
$this->authenticate();
65+
}
66+
67+
$post = [
68+
'$type' => 'app.bsky.feed.post',
69+
'text' => $message->getSubject(),
70+
'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\\TH:i:s.u\\Z'),
71+
];
72+
if ([] !== $facets = $this->parseFacets($post['text'])) {
73+
$post['facets'] = $facets;
74+
}
75+
76+
$response = $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.repo.createRecord', $this->getEndpoint()), [
77+
'auth_bearer' => $this->authSession['accessJwt'] ?? null,
78+
'json' => [
79+
'repo' => $this->authSession['did'] ?? null,
80+
'collection' => 'app.bsky.feed.post',
81+
'record' => $post,
82+
],
83+
]);
84+
85+
try {
86+
$statusCode = $response->getStatusCode();
87+
} catch (TransportExceptionInterface $e) {
88+
throw new TransportException('Could not reach the remote bluesky server.', $response, 0, $e);
89+
}
90+
91+
if (200 === $statusCode) {
92+
$content = $response->toArray();
93+
$sentMessage = new SentMessage($message, (string) $this);
94+
$sentMessage->setMessageId($content['cid']);
95+
96+
return $sentMessage;
97+
}
98+
99+
try {
100+
$content = $response->toArray(false);
101+
} catch (DecodingExceptionInterface $e) {
102+
throw new TransportException('Unexpected response from bluesky server.', $response, 0, $e);
103+
}
104+
105+
$title = $content['error'] ?? '';
106+
$errorDescription = $content['message'] ?? '';
107+
108+
throw new TransportException(sprintf('Unable to send message to Bluesky: Status code %d (%s) with message "%s".', $statusCode, $title, $errorDescription), $response);
109+
}
110+
111+
private function authenticate(): void
112+
{
113+
$response = $this->client->request('POST', sprintf('https://%s/xrpc/com.atproto.server.createSession', $this->getEndpoint()), [
114+
'json' => [
115+
'identifier' => $this->user,
116+
'password' => $this->password,
117+
],
118+
]);
119+
120+
try {
121+
$statusCode = $response->getStatusCode();
122+
} catch (TransportExceptionInterface $e) {
123+
throw new TransportException('Could not reach the remote bluesky server.', $response, 0, $e);
124+
}
125+
126+
if (200 !== $statusCode) {
127+
throw new TransportException('Could not authenticate with the remote bluesky server.', $response);
128+
}
129+
130+
try {
131+
$this->authSession = $response->toArray(false) ?? [];
132+
} catch (DecodingExceptionInterface $e) {
133+
throw new TransportException('Unexpected response from bluesky server.', $response, 0, $e);
134+
}
135+
}
136+
137+
private function parseFacets(string $input): array
138+
{
139+
$facets = [];
140+
$text = new ByteString($input);
141+
142+
// regex based on: https://bluesky.com/specs/handle#handle-identifier-syntax
143+
$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])?)#';
144+
foreach ($this->getMatchAndPosition($text, $regex) as $match) {
145+
$response = $this->client->request('GET', sprintf('https://%s/xrpc/com.atproto.identity.resolveHandle', $this->getEndpoint()), [
146+
'query' => [
147+
'handle' => ltrim($match['match'], '@'),
148+
],
149+
]);
150+
try {
151+
if (200 !== $response->getStatusCode()) {
152+
continue;
153+
}
154+
} catch (TransportExceptionInterface $e) {
155+
$this->logger->error('Could not reach the remote bluesky server. Tried to lookup username.', ['exception' => $e]);
156+
throw $e;
157+
}
158+
159+
$did = $response->toArray(false)['did'] ?? null;
160+
if (null === $did) {
161+
$this->logger->error('Could not get a good response from bluesky server. Tried to lookup username.');
162+
continue;
163+
}
164+
165+
$facets[] = [
166+
'index' => [
167+
'byteStart' => $match['start'],
168+
'byteEnd' => $match['end'],
169+
],
170+
'features' => [
171+
[
172+
'$type' => 'app.bsky.richtext.facet#mention',
173+
'did' => $did,
174+
],
175+
],
176+
];
177+
}
178+
179+
// partial/naive URL regex based on: https://stackoverflow.com/a/3809435
180+
// tweaked to disallow some trailing punctuation
181+
$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@%_\+~#//=])?);';
182+
foreach ($this->getMatchAndPosition($text, $regex) as $match) {
183+
$facets[] = [
184+
'index' => [
185+
'byteStart' => $match['start'],
186+
'byteEnd' => $match['end'],
187+
],
188+
'features' => [
189+
[
190+
'$type' => 'app.bsky.richtext.facet#link',
191+
'uri' => $match['match'],
192+
],
193+
],
194+
];
195+
}
196+
197+
return $facets;
198+
}
199+
200+
private function getMatchAndPosition(AbstractString $text, string $regex): array
201+
{
202+
$output = [];
203+
$handled = [];
204+
$matches = $text->match($regex, \PREG_PATTERN_ORDER);
205+
if ([] === $matches) {
206+
return $output;
207+
}
208+
209+
$length = $text->length();
210+
foreach ($matches[1] as $match) {
211+
if (isset($handled[$match])) {
212+
continue;
213+
}
214+
$handled[$match] = true;
215+
$end = -1;
216+
while (null !== $start = $text->indexOf($match, min($length, $end + 1))) {
217+
$output[] = [
218+
'start' => $start,
219+
'end' => $end = $start + (new ByteString($match))->length(),
220+
'match' => $match,
221+
];
222+
}
223+
}
224+
225+
return $output;
226+
}
227+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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 Psr\Log\LoggerInterface;
15+
use Psr\Log\NullLogger;
16+
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
17+
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
18+
use Symfony\Component\Notifier\Transport\Dsn;
19+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
20+
use Symfony\Contracts\HttpClient\HttpClientInterface;
21+
22+
/**
23+
* @author Tobias Nyholm <[email protected]>
24+
*/
25+
final class BlueskyTransportFactory extends AbstractTransportFactory
26+
{
27+
public function __construct(
28+
EventDispatcherInterface $dispatcher = null,
29+
HttpClientInterface $client = null,
30+
private ?LoggerInterface $logger = null
31+
) {
32+
parent::__construct($dispatcher, $client);
33+
}
34+
35+
public function create(Dsn $dsn): BlueskyTransport
36+
{
37+
$scheme = $dsn->getScheme();
38+
39+
if ('bluesky' !== $scheme) {
40+
throw new UnsupportedSchemeException($dsn, 'bluesky', $this->getSupportedSchemes());
41+
}
42+
43+
$user = $this->getUser($dsn);
44+
$secret = $this->getPassword($dsn);
45+
46+
return (new BlueskyTransport($user, $secret, $this->logger ?? new NullLogger(), $this->client, $this->dispatcher))
47+
->setHost($dsn->getHost())
48+
->setPort($dsn->getPort());
49+
}
50+
51+
protected function getSupportedSchemes(): array
52+
{
53+
return ['bluesky'];
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
7.1
5+
---
6+
7+
* Add the bridge
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2023-present Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Bluesky Notifier
2+
================
3+
4+
Provides [Bluesky](https://bsky.app/) integration for Symfony Notifier.
5+
6+
DSN example
7+
-----------
8+
9+
```
10+
BLUESKY_DSN=bluesky://nyholm.bsky.social:[email protected]
11+
```
12+
13+
Resources
14+
---------
15+
16+
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
17+
* [Report issues](https://github.com/symfony/symfony/issues) and
18+
[send Pull Requests](https://github.com/symfony/symfony/pulls)
19+
in the [main Symfony repository](https://github.com/symfony/symfony)

0 commit comments

Comments
 (0)