aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar thomas-333 <144368654+thomas-333@users.noreply.github.com> 2024-11-25 18:01:37 +0000
committerGravatar GitHub <noreply@github.com> 2024-11-25 19:01:37 +0100
commitb42a993176bc1e291f52b4de4ce3397fa307e84b (patch)
tree55a4ed40c83de9b22f8769bbf42e5df045c876be
parentec6f98e3c272cd06ae59fafadaaaca77904a522b (diff)
downloadrss-bridge-b42a993176bc1e291f52b4de4ce3397fa307e84b.tar.gz
rss-bridge-b42a993176bc1e291f52b4de4ce3397fa307e84b.tar.zst
rss-bridge-b42a993176bc1e291f52b4de4ce3397fa307e84b.zip
[Bluesky] New bridge (#4341)
* Create BlueskyProfileBridge.php Bridge for Bluesky * Update BlueskyProfileBridge.php Attempt to fix test error * Rename BlueskyProfileBridge.php to BlueskyBridge.php and add list of select data source * Update BlueskyBridge.php to pass lint checks
-rw-r--r--bridges/BlueskyBridge.php230
1 files changed, 230 insertions, 0 deletions
diff --git a/bridges/BlueskyBridge.php b/bridges/BlueskyBridge.php
new file mode 100644
index 00000000..8dab82f4
--- /dev/null
+++ b/bridges/BlueskyBridge.php
@@ -0,0 +1,230 @@
+<?php
+
+class BlueskyBridge extends BridgeAbstract
+{
+ const NAME = 'Bluesky';
+ 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 PARAMETERS = [
+ [
+ 'data_source' => [
+ 'name' => 'Bluesky Data Source',
+ 'type' => 'list',
+ 'defaultValue' => 'Profile',
+ 'values' => [
+ 'Profile' => 'getAuthorFeed',
+ ],
+ 'title' => 'Select the type of data source to fetch from Bluesky.'
+ ],
+ 'handle' => [
+ 'name' => 'User Handle',
+ 'type' => 'text',
+ 'required' => true,
+ 'exampleValue' => 'jackdodo.bsky.social',
+ 'title' => 'Handle found in URL'
+ ],
+ 'filter' => [
+ 'name' => 'Filter',
+ '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.'
+ ]
+ ]
+ ];
+
+ private $profile;
+
+ public function getName()
+ {
+ if (isset($this->profile)) {
+ return sprintf('%s (@%s) - Bluesky', $this->profile['displayName'], $this->profile['handle']);
+ }
+ return parent::getName();
+ }
+
+ public function getURI()
+ {
+ if (isset($this->profile)) {
+ return self::URI . '/profile/' . $this->profile['handle'];
+ }
+ return parent::getURI();
+ }
+
+ public function getIcon()
+ {
+ if (isset($this->profile)) {
+ return $this->profile['avatar'];
+ }
+ return parent::getIcon();
+ }
+
+ public function getDescription()
+ {
+ if (isset($this->profile)) {
+ return $this->profile['description'];
+ }
+ return parent::getDescription();
+ }
+
+ private function parseExternal($external, $did)
+ {
+ $description = '';
+ $externalUri = $external['uri'];
+ $externalTitle = htmlspecialchars($external['title'], ENT_QUOTES, 'UTF-8');
+ $externalDescription = htmlspecialchars($external['description'], ENT_QUOTES, 'UTF-8');
+ $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>";
+ } 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>";
+ }
+ }
+ return $description;
+ }
+
+ private function textToDescription($text)
+ {
+ $text = nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
+ $text = preg_replace('/(https?:\/\/[^\s]+)/i', '<a href="$1">$1</a>', $text);
+
+ return $text;
+ }
+
+ public function collectData()
+ {
+ $handle = $this->getInput('handle');
+ $filter = $this->getInput('filter') ?: 'posts_and_author_threads';
+
+ $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'];
+
+ $description = $this->textToDescription($post['post']['record']['text']);
+
+ // 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 (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>";
+ }
+ }
+
+ 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>';
+ }
+ }
+
+ 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>";
+ }
+ }
+
+ // 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;
+ }
+
+ 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>";
+ }
+ }
+ }
+
+ 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>";
+ }
+ }
+ }
+
+ $item['content'] = $description;
+ $this->items[] = $item;
+ }
+ }
+
+ private function resolveHandle($handle)
+ {
+ $uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle);
+ $response = json_decode(getContents($uri), true);
+ return $response['did'];
+ }
+
+ private function getProfile($did)
+ {
+ $uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did);
+ $response = json_decode(getContents($uri), true);
+ return $response;
+ }
+
+ private function getAuthorFeed($did, $filter)
+ {
+ $uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30';
+ $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';
+ }
+}