diff --git a/bridges/BlueskyBridge.php b/bridges/BlueskyBridge.php index 8dab82f4..89e5f3bc 100644 --- a/bridges/BlueskyBridge.php +++ b/bridges/BlueskyBridge.php @@ -2,10 +2,12 @@ class BlueskyBridge extends BridgeAbstract { - const NAME = 'Bluesky'; + //Initial PR by [RSSBridge contributors](https://github.com/RSS-Bridge/rss-bridge/issues/4058). + //Modified from [©DIYgod and contributors at RSSHub](https://github.com/DIYgod/RSSHub/tree/master/lib/routes/bsky), MIT License'; + const NAME = 'Bluesky Bridge'; const URI = 'https://bsky.app'; const DESCRIPTION = 'Fetches posts from Bluesky'; - const MAINTAINER = 'Code modified from rsshub (TonyRL https://github.com/TonyRL) and expanded'; + const MAINTAINER = 'mruac'; const PARAMETERS = [ [ 'data_source' => [ @@ -17,24 +19,39 @@ class BlueskyBridge extends BridgeAbstract ], 'title' => 'Select the type of data source to fetch from Bluesky.' ], - 'handle' => [ - 'name' => 'User Handle', + 'user_id' => [ + 'name' => 'User Handle or DID', 'type' => 'text', 'required' => true, - 'exampleValue' => 'jackdodo.bsky.social', - 'title' => 'Handle found in URL' + 'exampleValue' => 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'title' => 'ATProto / Bsky.app handle or DID' ], - 'filter' => [ - 'name' => 'Filter', + 'feed_filter' => [ + 'name' => 'Feed type', 'type' => 'list', 'defaultValue' => 'posts_and_author_threads', 'values' => [ - 'posts_and_author_threads' => 'posts_and_author_threads', - 'posts_with_replies' => 'posts_with_replies', - 'posts_no_replies' => 'posts_no_replies', - 'posts_with_media' => 'posts_with_media', - ], - 'title' => 'Combinations of post/repost types to include in response.' + 'Posts feed' => 'posts_and_author_threads', + 'All posts and replies' => 'posts_with_replies', + 'Root posts only' => 'posts_no_replies', + 'Media only' => 'posts_with_media', + ] + ], + + 'include_reposts' => [ + 'name' => 'Include Reposts?', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + + 'include_reply_context' => [ + 'name' => 'Include Reply context?', + 'type' => 'checkbox' + ], + + 'verbose_title' => [ + 'name' => 'Use verbose feed item titles?', + 'type' => 'checkbox' ] ] ]; @@ -44,7 +61,11 @@ class BlueskyBridge extends BridgeAbstract public function getName() { if (isset($this->profile)) { - return sprintf('%s (@%s) - Bluesky', $this->profile['displayName'], $this->profile['handle']); + if ($this->profile['handle'] === 'handle.invalid') { + return sprintf('Bluesky - %s', $this->profile['displayName']); + } else { + return sprintf('Bluesky - %s (@%s)', $this->profile['displayName'], $this->profile['handle']); + } } return parent::getName(); } @@ -52,7 +73,11 @@ class BlueskyBridge extends BridgeAbstract public function getURI() { if (isset($this->profile)) { - return self::URI . '/profile/' . $this->profile['handle']; + if ($this->profile['handle'] === 'handle.invalid') { + return self::URI . '/profile/' . $this->profile['did']; + } else { + return self::URI . '/profile/' . $this->profile['handle']; + } } return parent::getURI(); } @@ -77,118 +102,365 @@ class BlueskyBridge extends BridgeAbstract { $description = ''; $externalUri = $external['uri']; - $externalTitle = htmlspecialchars($external['title'], ENT_QUOTES, 'UTF-8'); - $externalDescription = htmlspecialchars($external['description'], ENT_QUOTES, 'UTF-8'); + $externalTitle = e($external['title']); + $externalDescription = e($external['description']); $thumb = $external['thumb'] ?? null; - if (preg_match('/youtube\.com\/watch\?v=([^\&\?\/]+)/', $externalUri, $id) || preg_match('/youtu\.be\/([^\&\?\/]+)/', $externalUri, $id)) { - $videoId = $id[1]; - $description .= "
External Link: $externalTitle
"; - $description .= ""; + if (preg_match('/http(|s):\/\/media\.tenor\.com/', $externalUri)) { + //tenor gif embed + $tenorInterstitial = str_replace('media.tenor.com', 'media1.tenor.com/m', $externalUri); + $description .= ""; } else { - $description .= "External Link: $externalTitle
"; - $description .= "$externalDescription
"; - - if ($thumb) { - $thumbUrl = 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg'; - $description .= ""; - } + //link embed preview + $host = parse_url($externalUri)['host']; + $thumbDesc = $thumb ? ('') : ''; + $externalDescription = strlen($externalDescription) > 0 ? "' . $externalTitle . ''; + $description .= ''; } return $description; } - private function textToDescription($text) + private function textToDescription($record) { - $text = nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')); - $text = preg_replace('/(https?:\/\/[^\s]+)/i', '$1', $text); - + if (isset($record['value'])) { + $record = $record['value']; + } + $text = $record['text']; + $text_copy = $text; + $text = nl2br(e($text)); + if (isset($record['facets'])) { + $facets = $record['facets']; + foreach ($facets as $facet) { + if ($facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link') { + $substring = substr($text_copy, $facet['index']['byteStart'], $facet['index']['byteEnd'] - $facet['index']['byteStart']); + $text = str_replace($substring, '' . $substring . '', $text); + } + } + } return $text; } public function collectData() { - $handle = $this->getInput('handle'); - $filter = $this->getInput('filter') ?: 'posts_and_author_threads'; + $user_id = $this->getInput('user_id'); + $handle_match = preg_match('/(?:[a-zA-Z]*\.)+([a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)/', $user_id, $handle_res); //gets the TLD in $handle_match[1] + $did_match = preg_match('/did:plc:[a-z2-7]{24}/', $user_id); //https://github.com/did-method-plc/did-method-plc#identifier-syntax + $exclude = ['alt', 'arpa', 'example', 'internal', 'invalid', 'local', 'localhost', 'onion']; //https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains + if ($handle_match == true && array_search($handle_res[1], $exclude) == false) { + //valid bsky handle + $did = $this->resolveHandle($user_id); + } elseif ($did_match == true) { + //valid DID + $did = $user_id; + } else { + returnClientError('Invalid ATproto handle or DID provided.'); + } + + $filter = $this->getInput('feed_filter') ?: 'posts_and_author_threads'; + $replyContext = $this->getInput('include_reply_context'); - $did = $this->resolveHandle($handle); $this->profile = $this->getProfile($did); $authorFeed = $this->getAuthorFeed($did, $filter); foreach ($authorFeed['feed'] as $post) { + $postRecord = $post['post']['record']; + $item = []; - $item['uri'] = self::URI . '/profile/' . $post['post']['author']['handle'] . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1]; - $item['title'] = strtok($post['post']['record']['text'], "\n"); - $item['timestamp'] = strtotime($post['post']['record']['createdAt']); - $item['author'] = $this->profile['displayName']; + $item['uri'] = self::URI . '/profile/' . $this->fallbackAuthor($post['post']['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1]; + $item['title'] = $this->getInput('verbose_title') ? $this->generateVerboseTitle($post) : strtok($postRecord['text'], "\n"); + $item['timestamp'] = strtotime($postRecord['createdAt']); + $item['author'] = $this->fallbackAuthor($post['post']['author'], 'display'); - $description = $this->textToDescription($post['post']['record']['text']); + $postAuthorDID = $post['post']['author']['did']; + $postAuthorHandle = $post['post']['author']['handle'] !== 'handle.invalid' ? '@' . $post['post']['author']['handle'] . ' ' : ''; + $postDisplayName = $post['post']['author']['displayName'] ?? ''; + $postDisplayName = e($postDisplayName); + $postUri = $item['uri']; - // Retrieve DID for constructing image URLs - $authorDid = $post['post']['author']['did']; - - if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.external') { - $description .= $this->parseExternal($post['post']['record']['embed']['external'], $authorDid); + if (Debug::isEnabled()) { + $url = explode('/', $post['post']['uri']); + error_log('https://bsky.app/profile/' . $url[2] . '/post/' . $url[4]); } - if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.video') { - $thumbnail = $post['post']['embed']['thumbnail'] ?? null; - if ($thumbnail) { - $itemUri = self::URI . '/profile/' . $post['post']['author']['handle'] . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1]; - $description .= ""; + $description = ''; + $description .= '
'; + //post + $description .= $this->getPostDescription( + $postDisplayName, + $postAuthorHandle, + $postUri, + $postRecord, + 'post' + ); + + if (isset($postRecord['embed']['$type'])) { + //post link embed + if ($postRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($postRecord['embed']['external'], $postAuthorDID); + } elseif ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($postRecord['embed']['media']['external'], $postAuthorDID); + } + + //post images + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $post['post']['embed']['images'] ?? $post['post']['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $postRecord['embed']['video'] ?? $postRecord['embed']['media']['video'], + $postAuthorDID + ); } } + $description .= '
'; - if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.recordWithMedia#view') { - $thumbnail = $post['post']['embed']['media']['thumbnail'] ?? null; - $playlist = $post['post']['embed']['media']['playlist'] ?? null; - if ($thumbnail) { - $description .= "'; - } - } + //quote post + if ( + isset($postRecord['embed']) && + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.record' || + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' + ) && + isset($post['post']['embed']['record']) + ) { + $description .= '';
+ $quotedRecord = $post['post']['embed']['record']['record'] ?? $post['post']['embed']['record'];
- if (!empty($post['post']['record']['embed']['images'])) {
- foreach ($post['post']['record']['embed']['images'] as $image) {
- $linkRef = $image['image']['ref']['$link'];
- $thumbnailUrl = $this->resolveThumbnailUrl($authorDid, $linkRef);
- $fullsizeUrl = $this->resolveFullsizeUrl($authorDid, $linkRef);
- $description .= "
";
- }
- }
+ if (isset($quotedRecord['notFound']) && $quotedRecord['notFound']) { //deleted post
+ $description .= 'Quoted post deleted.';
+ } elseif (isset($quotedRecord['detached']) && $quotedRecord['detached']) { //detached quote
+ $uri_explode = explode('/', $quotedRecord['uri']);
+ $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4];
+ $description .= 'Quoted post detached.';
+ } elseif (isset($quotedRecord['blocked']) && $quotedRecord['blocked']) { //blocked by quote author
+ $description .= 'Author of quoted post has blocked OP.';
+ } else {
+ $quotedAuthorDid = $quotedRecord['author']['did'];
+ $quotedDisplayName = $quotedRecord['author']['displayName'] ?? '';
+ $quotedDisplayName = e($quotedDisplayName);
+ $quotedAuthorHandle = $quotedRecord['author']['handle'] !== 'handle.invalid' ? '@' . $quotedRecord['author']['handle'] . '' : '';
- // Enhanced handling for quote posts with images
- if (isset($post['post']['record']['embed']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.record') {
- $quotedRecord = $post['post']['record']['embed']['record'];
- $quotedAuthor = $post['post']['embed']['record']['author']['handle'] ?? null;
- $quotedDisplayName = $post['post']['embed']['record']['author']['displayName'] ?? null;
- $quotedText = $post['post']['embed']['record']['value']['text'] ?? null;
-
- if ($quotedAuthor && isset($quotedRecord['uri'])) {
$parts = explode('/', $quotedRecord['uri']);
$quotedPostId = end($parts);
- $quotedPostUri = self::URI . '/profile/' . $quotedAuthor . '/post/' . $quotedPostId;
- }
+ $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($quotedRecord['author'], 'url') . '/post/' . $quotedPostId;
- if ($quotedText) {
- $description .= '
'; + + $replyPostAuthorDID = $replyPost['author']['did']; + $replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '@' . $replyPost['author']['handle'] . ' ' : ''; + $replyPostDisplayName = $replyPost['author']['displayName'] ?? ''; + $replyPostDisplayName = e($replyPostDisplayName); + $replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1]; + + // reply post + $description .= $this->getPostDescription( + $replyPostDisplayName, + $replyPostAuthorHandle, + $replyPostUri, + $replyPostRecord, + 'reply' + ); + + if (isset($replyPostRecord['embed']['$type'])) { + //post link embed + if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID); + } elseif ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID); } + + //post images + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'], + $replyPostAuthorDID + ); + } + } + $description .= '
'; + + //quote post + if ( + isset($replyPostRecord['embed']) && + ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') && + isset($replyPost['embed']['record']) + ) { + $description .= ''; + $replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record']; + + if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post + $description .= 'Quoted post deleted.'; + } elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote + $uri_explode = explode('/', $replyQuotedRecord['uri']); + $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4]; + $description .= 'Quoted post detached.'; + } elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author + $description .= 'Author of quoted post has blocked OP.'; + } else { + $quotedAuthorDid = $replyQuotedRecord['author']['did']; + $quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? ''; + $quotedDisplayName = e($quotedDisplayName); + $quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '@' . $replyQuotedRecord['author']['handle'] . '' : ''; + + $parts = explode('/', $replyQuotedRecord['uri']); + $quotedPostId = end($parts); + $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId; + + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $replyQuotedRecord, + 'quote' + ); + + if (isset($replyQuotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($replyQuotedRecord['embeds'] as $embed) { + if ( + $embed['$type'] === 'app.bsky.embed.images#view' || + ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view') + ) { + $images = $embed['images'] ?? $embed['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + } + } + } + } + $description .= '
'; } } @@ -197,6 +469,98 @@ class BlueskyBridge extends BridgeAbstract } } + private function getPostVideoDescription(array $video, $authorDID) + { + //https://video.bsky.app/watch/$did/$cid/thumbnail.jpg + $videoCID = $video['ref']['$link']; + $videoMime = $video['mimeType']; + $thumbnail = "poster=\"https://video.bsky.app/watch/$authorDID/$videoCID/thumbnail.jpg\"" ?? ''; + $videoURL = "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=$authorDID&cid=$videoCID"; + return ""; + } + + private function getPostImageDescription(array $image) + { + $thumbnailUrl = $image['thumb']; + $fullsizeUrl = $image['fullsize']; + $alt = strlen($image['alt']) > 0 ? '