[ 'name' => 'Bluesky Data Source', 'type' => 'list', 'defaultValue' => 'Profile', 'values' => [ 'Profile' => 'getAuthorFeed', ], 'title' => 'Select the type of data source to fetch from Bluesky.' ], 'user_id' => [ 'name' => 'User Handle or DID', 'type' => 'text', 'required' => true, 'exampleValue' => 'did:plc:z72i7hdynmk6r22z27h6tvur', 'title' => 'ATProto / Bsky.app handle or DID' ], 'feed_filter' => [ 'name' => 'Feed type', 'type' => 'list', 'defaultValue' => 'posts_and_author_threads', 'values' => [ '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' ] ] ]; private $profile; public function getName() { if (isset($this->profile)) { 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(); } public function getURI() { if (isset($this->profile)) { if ($this->profile['handle'] === 'handle.invalid') { return self::URI . '/profile/' . $this->profile['did']; } else { 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 = e($external['title']); $externalDescription = e($external['description']); $thumb = $external['thumb'] ?? null; 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 { //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($record) { 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() { $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'); $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/' . $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'); $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']; if (Debug::isEnabled()) { $url = explode('/', $post['post']['uri']); error_log('https://bsky.app/profile/' . $url[2] . '/post/' . $url[4]); } $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 .= '

'; //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 (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'] . '' : ''; $parts = explode('/', $quotedRecord['uri']); $quotedPostId = end($parts); $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 ); } //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 .= '

'; } //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 .= '

'; } } $item['content'] = $description; $this->items[] = $item; } } 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); $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'; if (Debug::isEnabled()) { error_log($uri); } $response = json_decode(getContents($uri), true); return $response; } }