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