|
| 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 | +namespaceSymfony\Component\Notifier\Bridge\Bluesky; |
| 13 | + |
| 14 | +usePsr\Log\LoggerInterface; |
| 15 | +useSymfony\Component\Notifier\Exception\TransportException; |
| 16 | +useSymfony\Component\Notifier\Exception\UnsupportedMessageTypeException; |
| 17 | +useSymfony\Component\Notifier\Message\ChatMessage; |
| 18 | +useSymfony\Component\Notifier\Message\MessageInterface; |
| 19 | +useSymfony\Component\Notifier\Message\SentMessage; |
| 20 | +useSymfony\Component\Notifier\Transport\AbstractTransport; |
| 21 | +useSymfony\Component\String\AbstractString; |
| 22 | +useSymfony\Component\String\ByteString; |
| 23 | +useSymfony\Contracts\EventDispatcher\EventDispatcherInterface; |
| 24 | +useSymfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; |
| 25 | +useSymfony\Contracts\HttpClient\Exception\TransportExceptionInterface; |
| 26 | +useSymfony\Contracts\HttpClient\HttpClientInterface; |
| 27 | + |
| 28 | +/** |
| 29 | + * @author Tobias Nyholm <tobias.nyholm@gmail.com> |
| 30 | + */ |
| 31 | +finalclass BlueskyTransportextends AbstractTransport |
| 32 | +{ |
| 33 | +privatearray$authSession = []; |
| 34 | + |
| 35 | +publicfunction__construct( |
| 36 | + #[\SensitiveParameter] |
| 37 | +privatestring$user, |
| 38 | + #[\SensitiveParameter] |
| 39 | +privatestring$password, |
| 40 | +privateLoggerInterface$logger, |
| 41 | +HttpClientInterface$client =null, |
| 42 | +EventDispatcherInterface$dispatcher =null, |
| 43 | + ) { |
| 44 | +parent::__construct($client,$dispatcher); |
| 45 | + } |
| 46 | + |
| 47 | +publicfunction__toString():string |
| 48 | + { |
| 49 | +returnsprintf('bluesky://%s',$this->getEndpoint()); |
| 50 | + } |
| 51 | + |
| 52 | +publicfunctionsupports(MessageInterface$message):bool |
| 53 | + { |
| 54 | +return$messageinstanceof ChatMessage; |
| 55 | + } |
| 56 | + |
| 57 | +protectedfunctiondoSend(MessageInterface$message):SentMessage |
| 58 | + { |
| 59 | +if (!$messageinstanceof ChatMessage) { |
| 60 | +thrownewUnsupportedMessageTypeException(__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 | +thrownewTransportException('Could not reach the remote bluesky server.',$response,0,$e); |
| 89 | + } |
| 90 | + |
| 91 | +if (200 ===$statusCode) { |
| 92 | +$content =$response->toArray(); |
| 93 | +$sentMessage =newSentMessage($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 | +thrownewTransportException('Unexpected response from bluesky server.',$response,0,$e); |
| 103 | + } |
| 104 | + |
| 105 | +$title =$content['error'] ??''; |
| 106 | +$errorDescription =$content['message'] ??''; |
| 107 | + |
| 108 | +thrownewTransportException(sprintf('Unable to send message to Bluesky: Status code %d (%s) with message "%s".',$statusCode,$title,$errorDescription),$response); |
| 109 | + } |
| 110 | + |
| 111 | +privatefunctionauthenticate():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 | +thrownewTransportException('Could not reach the remote bluesky server.',$response,0,$e); |
| 124 | + } |
| 125 | + |
| 126 | +if (200 !==$statusCode) { |
| 127 | +thrownewTransportException('Could not authenticate with the remote bluesky server.',$response); |
| 128 | + } |
| 129 | + |
| 130 | +try { |
| 131 | +$this->authSession =$response->toArray(false) ?? []; |
| 132 | + }catch (DecodingExceptionInterface$e) { |
| 133 | +thrownewTransportException('Unexpected response from bluesky server.',$response,0,$e); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | +privatefunctionparseFacets(string$input):array |
| 138 | + { |
| 139 | +$facets = []; |
| 140 | +$text =newByteString($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 | +privatefunctiongetMatchAndPosition(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 + (newByteString($match))->length(), |
| 220 | +'match' =>$match, |
| 221 | + ]; |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | +return$output; |
| 226 | + } |
| 227 | +} |