aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bridges/BlueskyBridge.php555
1 files changed, 456 insertions, 99 deletions
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 .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
- $description .= "<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/$videoId\" frameborder=\"0\" allowfullscreen></iframe>";
+ if (preg_match('/http(|s):\/\/media\.tenor\.com/', $externalUri)) {
+ //tenor gif embed
+ $tenorInterstitial = str_replace('media.tenor.com', 'media1.tenor.com/m', $externalUri);
+ $description .= "<figure><a href=\"$tenorInterstitial\"><img src=\"$externalUri\"/></a><figcaption>$externalTitle</figcaption></figure>";
} else {
- $description .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
- $description .= "<p>$externalDescription</p>";
-
- if ($thumb) {
- $thumbUrl = 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg';
- $description .= "<p><a href=\"$externalUri\"><img src=\"$thumbUrl\" alt=\"External Thumbnail\" /></a></p>";
- }
+ //link embed preview
+ $host = parse_url($externalUri)['host'];
+ $thumbDesc = $thumb ? ('<img src="https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg"/>') : '';
+ $externalDescription = strlen($externalDescription) > 0 ? "<figcaption>($host) $externalDescription</figcaption>" : '';
+ $description .= '<br><blockquote><b><a href="' . $externalUri . '">' . $externalTitle . '</a></b>';
+ $description .= '<figure>' . $thumbDesc . $externalDescription . '</figure></blockquote>';
}
return $description;
}
- private function textToDescription($text)
+ private function textToDescription($record)
{
- $text = nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
- $text = preg_replace('/(https?:\/\/[^\s]+)/i', '<a href="$1">$1</a>', $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, '<a href="' . $facet['features'][0]['uri'] . '">' . $substring . '</a>', $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) {
- $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'];
+ $postRecord = $post['post']['record'];
- $description = $this->textToDescription($post['post']['record']['text']);
+ $item = [];
+ $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');
- // Retrieve DID for constructing image URLs
- $authorDid = $post['post']['author']['did'];
+ $postAuthorDID = $post['post']['author']['did'];
+ $postAuthorHandle = $post['post']['author']['handle'] !== 'handle.invalid' ? '<i>@' . $post['post']['author']['handle'] . '</i> ' : '';
+ $postDisplayName = $post['post']['author']['displayName'] ?? '';
+ $postDisplayName = e($postDisplayName);
+ $postUri = $item['uri'];
- 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 .= "<p><a href=\"$itemUri\"><img src=\"$thumbnail\" alt=\"Video Thumbnail\" /></a></p>";
+ $description = '';
+ $description .= '<p>';
+ //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);
}
- }
- 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 .= "<p><video controls poster=\"$thumbnail\">";
- $description .= "<source src=\"$playlist\" type=\"application/x-mpegURL\">";
- $description .= 'Video source not supported</video></p>';
+ //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);
+ }
}
- }
- 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 .= "<br /><br /><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\" alt=\"Image\"></a>";
+ //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 .= '</p>';
- // 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;
+ //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 .= '<p>';
+ $quotedRecord = $post['post']['embed']['record']['record'] ?? $post['post']['embed']['record'];
+
+ 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 .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>';
+ } 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' ? '<i>@' . $quotedRecord['author']['handle'] . '</i>' : '';
- 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;
+
+ //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
+ );
+ }
- if ($quotedText) {
- $description .= '<hr /><strong>Quote from ' . htmlspecialchars($quotedDisplayName) . ' (@ ' . htmlspecialchars($quotedAuthor) . '):</strong><br />';
- $description .= $this->textToDescription($quotedText);
- if (isset($quotedPostUri)) {
- $description .= "<p><a href=\"$quotedPostUri\">View original quote post</a></p>";
+ //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 .= '</p>';
}
- 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 .= "<br /><br /><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\" alt=\"Quoted Image\"></a>";
+ //reply
+ if ($replyContext && isset($post['reply']) && !isset($post['reply']['parent']['notFound'])) {
+ $replyPost = $post['reply']['parent'];
+ $replyPostRecord = $replyPost['record'];
+ $description .= '<hr/>';
+ $description .= '<p>';
+
+ $replyPostAuthorDID = $replyPost['author']['did'];
+ $replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyPost['author']['handle'] . '</i> ' : '';
+ $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 .= '</p>';
+
+ //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 .= '<p>';
+ $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 .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>';
+ } 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' ? '<i>@' . $replyQuotedRecord['author']['handle'] . '</i>' : '';
+
+ $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 .= '</p>';
}
}
@@ -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 "<figure><video loop $thumbnail controls src=\"$videoURL\" type=\"$videoMime\"/></figure>";
+ }
+
+ private function getPostImageDescription(array $image)
+ {
+ $thumbnailUrl = $image['thumb'];
+ $fullsizeUrl = $image['fullsize'];
+ $alt = strlen($image['alt']) > 0 ? '<figcaption>' . e($image['alt']) . '</figcaption>' : '';
+ return "<figure><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\"></a>$alt</figure>";
+ }
+
+ 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 .= "<a href=\"$postUri\">Quoted $postType</a> from <b>$postDisplayName</b> $postAuthorHandle:<br>";
+ } elseif ($type === 'reply') {
+ // Replying to aaa @aaa.com's post/reply:
+ $postType = isset($postRecord['reply']) ? 'reply' : 'post';
+ $description .= "Replying to <b>$postDisplayName</b> $postAuthorHandle's <a href=\"$postUri\">$postType</a>:<br>";
+ } else {
+ // aaa @aaa.com posted:
+ $description .= "<b>$postDisplayName</b> $postAuthorHandle <a href=\"$postUri\">posted</a>:<br>";
+ }
+ $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';
- }
}