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 .= "
$externalTitle
"; } else { - $description .= "

External Link: $externalTitle

"; - $description .= "

$externalDescription

"; - - if ($thumb) { - $thumbUrl = 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg'; - $description .= "

\"External

"; - } + //link embed preview + $host = parse_url($externalUri)['host']; + $thumbDesc = $thumb ? ('') : ''; + $externalDescription = strlen($externalDescription) > 0 ? "
($host) $externalDescription
" : ''; + $description .= '
' . $externalTitle . ''; + $description .= '
' . $thumbDesc . $externalDescription . '
'; } 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 .= "

\"Video

"; + $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 .= "

\"Image\""; - } - } + 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 .= '


Quote from ' . htmlspecialchars($quotedDisplayName) . ' (@ ' . htmlspecialchars($quotedAuthor) . '):
'; - $description .= $this->textToDescription($quotedText); - if (isset($quotedPostUri)) { - $description .= "

View original quote post

"; + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $quotedRecord, + 'quote' + ); + + if (isset($quotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($quotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $quotedRecord['value']['embed']['video'] ?? $quotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($quotedRecord['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 .= '

'; } - if (isset($post['post']['embed']['record']['value']['embed']['images'])) { - $quotedImages = $post['post']['embed']['record']['value']['embed']['images']; - foreach ($quotedImages as $image) { - $linkRef = $image['image']['ref']['$link'] ?? null; - if ($linkRef) { - $quotedAuthorDid = $post['post']['embed']['record']['author']['did'] ?? null; - $thumbnailUrl = $this->resolveThumbnailUrl($quotedAuthorDid, $linkRef); - $fullsizeUrl = $this->resolveFullsizeUrl($quotedAuthorDid, $linkRef); - $description .= "

\"Quoted"; + //reply + if ($replyContext && isset($post['reply']) && !isset($post['reply']['parent']['notFound'])) { + $replyPost = $post['reply']['parent']; + $replyPostRecord = $replyPost['record']; + $description .= '
'; + $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 ? '
' . e($image['alt']) . '
' : ''; + return "
$alt
"; + } + + private function getPostDescription( + string $postDisplayName, + string $postAuthorHandle, + string $postUri, + array $postRecord, + string $type + ) { + $description = ''; + if ($type === 'quote') { + // Quoted post/reply from bbb @bbb.com: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "Quoted $postType from $postDisplayName $postAuthorHandle:
"; + } elseif ($type === 'reply') { + // Replying to aaa @aaa.com's post/reply: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "Replying to $postDisplayName $postAuthorHandle's $postType:
"; + } else { + // aaa @aaa.com posted: + $description .= "$postDisplayName $postAuthorHandle posted:
"; + } + $description .= $this->textToDescription($postRecord); + return $description; + } + + //used if handle verification fails, fallsback to displayName or DID depending on context. + private function fallbackAuthor($author, $reason) + { + if ($author['handle'] === 'handle.invalid') { + switch ($reason) { + case 'url': + return $author['did']; + case 'display': + $displayName = $author['displayName'] ?? ''; + return e($displayName); + } + } + return $author['handle']; + } + + private function generateVerboseTitle($post) + { + //use "Post by A, replying to B, quoting C" instead of post contents + $title = ''; + if (isset($post['reason']) && str_contains($post['reason']['$type'], 'reasonRepost')) { + $title .= 'Repost by ' . $this->fallbackAuthor($post['reason']['by'], 'display') . ', post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } else { + $title .= 'Post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } + + if (isset($post['reply'])) { + if (isset($post['reply']['parent']['blocked'])) { + $replyAuthor = 'blocked user'; + } elseif (isset($post['reply']['parent']['notFound'])) { + $replyAuthor = 'deleted post'; + } else { + $replyAuthor = $this->fallbackAuthor($post['reply']['parent']['author'], 'display'); + } + $title .= ', replying to ' . $replyAuthor; + } + if (isset($post['post']['embed']) && isset($post['post']['embed']['record'])) { + if (isset($post['post']['embed']['record']['blocked'])) { + $quotedAuthor = 'blocked user'; + } elseif (isset($post['post']['embed']['record']['notFound'])) { + $quotedAuthor = 'deleted post'; + } elseif (isset($post['post']['embed']['record']['detached'])) { + $quotedAuthor = 'detached post'; + } else { + $quotedAuthor = $this->fallbackAuthor($post['post']['embed']['record']['record']['author'] ?? $post['post']['embed']['record']['author'], 'display'); + } + $title .= ', quoting ' . $quotedAuthor; + } + return $title; + } + private function resolveHandle($handle) { $uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle); @@ -214,17 +578,10 @@ class BlueskyBridge extends BridgeAbstract private function getAuthorFeed($did, $filter) { $uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30'; + if (Debug::isEnabled()) { + error_log($uri); + } $response = json_decode(getContents($uri), true); return $response; } - - private function resolveThumbnailUrl($authorDid, $linkRef) - { - return 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $authorDid . '/' . $linkRef . '@jpeg'; - } - - private function resolveFullsizeUrl($authorDid, $linkRef) - { - return 'https://cdn.bsky.app/img/feed_fullsize/plain/' . $authorDid . '/' . $linkRef . '@jpeg'; - } }