* Author: Michael Vogel <https://pirati.ca/profile/heluecht>
*
* @todo
- * Piece of cake?
- * - Process facets
- * - create facets
+ * Currently technical issues in the core:
+ * - Outgoing mentions
*
- * Possible but less important:
- * - Block, unblock, mute and unmute contacts
+ * At some point in time:
+ * - Sending Quote shares https://atproto.com/lexicons/app-bsky-embed#appbskyembedrecord and https://atproto.com/lexicons/app-bsky-embed#appbskyembedrecordwithmedia
*
- * Need inspiration:
- * - alternate link for contacts
- * - plink for posts
- *
- * Need more information:
+ * Possibly not possible:
* - only fetch new posts
- * - detect incoming reshares
- * - detect contact relations
- * - receive likes
+ *
+ * Currently not possible, due to limitations in Friendica
+ * - mute contacts https://atproto.com/lexicons/app-bsky-graph#appbskygraphmuteactor
+ * - unmute contacts https://atproto.com/lexicons/app-bsky-graph#appbskygraphunmuteactor
+ *
+ * Possibly interesting:
+ * - https://atproto.com/lexicons/com-atproto-label#comatprotolabelsubscribelabels
*/
use Friendica\Content\Text\BBCode;
use Friendica\Content\Text\HTML;
use Friendica\Content\Text\Plaintext;
+use Friendica\Core\Cache\Enum\Duration;
use Friendica\Core\Config\Util\ConfigFileManager;
use Friendica\Core\Hook;
use Friendica\Core\Logger;
use Friendica\Core\Protocol;
use Friendica\Core\Renderer;
+use Friendica\Core\System;
+use Friendica\Core\Worker;
use Friendica\Database\DBA;
use Friendica\DI;
use Friendica\Model\Contact;
use Friendica\Network\HTTPClient\Client\HttpClientAccept;
use Friendica\Network\HTTPClient\Client\HttpClientOptions;
use Friendica\Protocol\Activity;
+use Friendica\Protocol\Relay;
use Friendica\Util\DateTimeFormat;
+use Friendica\Util\Strings;
-define('BLUESKY_DEFAULT_POLL_INTERVAL', 10); // given in minutes
+const BLUESKY_DEFAULT_POLL_INTERVAL = 10; // given in minutes
+const BLUESKY_HOST = 'https://bsky.app'; // Hard wired until Bluesky will run on multiple systems
function bluesky_install()
{
Hook::register('connector_settings', __FILE__, 'bluesky_settings');
Hook::register('connector_settings_post', __FILE__, 'bluesky_settings_post');
Hook::register('cron', __FILE__, 'bluesky_cron');
- // Hook::register('support_follow', __FILE__, 'bluesky_support_follow');
- // Hook::register('support_probe', __FILE__, 'bluesky_support_probe');
- // Hook::register('follow', __FILE__, 'bluesky_follow');
- // Hook::register('unfollow', __FILE__, 'bluesky_unfollow');
- // Hook::register('block', __FILE__, 'bluesky_block');
- // Hook::register('unblock', __FILE__, 'bluesky_unblock');
+ Hook::register('support_follow', __FILE__, 'bluesky_support_follow');
+ Hook::register('support_probe', __FILE__, 'bluesky_support_probe');
+ Hook::register('follow', __FILE__, 'bluesky_follow');
+ Hook::register('unfollow', __FILE__, 'bluesky_unfollow');
+ Hook::register('block', __FILE__, 'bluesky_block');
+ Hook::register('unblock', __FILE__, 'bluesky_unblock');
Hook::register('check_item_notification', __FILE__, 'bluesky_check_item_notification');
- // Hook::register('probe_detect', __FILE__, 'bluesky_probe_detect');
- // Hook::register('item_by_link', __FILE__, 'bluesky_item_by_link');
+ Hook::register('probe_detect', __FILE__, 'bluesky_probe_detect');
+ Hook::register('item_by_link', __FILE__, 'bluesky_item_by_link');
}
function bluesky_load_config(ConfigFileManager $loader)
function bluesky_check_item_notification(array &$notification_data)
{
- $handle = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'handle');
- $did = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'did');
+ $did = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'did');
- if (!empty($handle) && !empty($did)) {
- $notification_data['profiles'][] = $handle;
+ if (!empty($did)) {
$notification_data['profiles'][] = $did;
}
}
+function bluesky_probe_detect(array &$hookData)
+{
+ // Don't overwrite an existing result
+ if (isset($hookData['result'])) {
+ return;
+ }
+
+ // Avoid a lookup for the wrong network
+ if (!in_array($hookData['network'], ['', Protocol::BLUESKY])) {
+ return;
+ }
+
+ $pconfig = DBA::selectFirst('pconfig', ['uid'], ["`cat` = ? AND `k` = ? AND `v` != ?", 'bluesky', 'access_token', '']);
+ if (empty($pconfig['uid'])) {
+ return;
+ }
+
+ if (parse_url($hookData['uri'], PHP_URL_SCHEME) == 'did') {
+ $did = $hookData['uri'];
+ } elseif (preg_match('#^' . BLUESKY_HOST . '/profile/(.+)#', $hookData['uri'], $matches)) {
+ $did = bluesky_get_did($pconfig['uid'], $matches[1]);
+ if (empty($did)) {
+ return;
+ }
+ } else {
+ return;
+ }
+
+ $token = bluesky_get_token($pconfig['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ $data = bluesky_xrpc_get($pconfig['uid'], 'app.bsky.actor.getProfile', ['actor' => $did]);
+ if (empty($data)) {
+ return;
+ }
+
+ $hookData['result'] = bluesky_get_contact_fields($data, 0, false);
+
+ // Preparing probe data. This differs slightly from the contact array
+ $hookData['result']['about'] = HTML::toBBCode($data->description ?? '');
+ $hookData['result']['photo'] = $data->avatar ?? '';
+ $hookData['result']['header'] = $data->banner ?? '';
+ $hookData['result']['batch'] = '';
+ $hookData['result']['notify'] = '';
+ $hookData['result']['poll'] = '';
+ $hookData['result']['poco'] = '';
+ $hookData['result']['pubkey'] = '';
+ $hookData['result']['priority'] = 0;
+ $hookData['result']['guid'] = '';
+}
+
+function bluesky_item_by_link(array &$hookData)
+{
+ // Don't overwrite an existing result
+ if (isset($hookData['item_id'])) {
+ return;
+ }
+
+ $token = bluesky_get_token($hookData['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ if (!preg_match('#^' . BLUESKY_HOST . '/profile/(.+)/post/(.+)#', $hookData['uri'], $matches)) {
+ return;
+ }
+
+ $did = bluesky_get_did($hookData['uid'], $matches[1]);
+ if (empty($did)) {
+ return;
+ }
+
+ Logger::debug('Found bluesky post', ['url' => $hookData['uri'], 'handle' => $matches[1], 'did' => $did, 'cid' => $matches[2]]);
+
+ $uri = 'at://' . $did . '/app.bsky.feed.post/' . $matches[2];
+
+ $uri = bluesky_fetch_missing_post($uri, $hookData['uid'], 0, 0);
+ Logger::debug('Got post', ['profile' => $matches[1], 'cid' => $matches[2], 'result' => $uri]);
+ if (!empty($uri)) {
+ $item = Post::selectFirst(['id'], ['uri' => $uri, 'uid' => $hookData['uid']]);
+ if (!empty($item['id'])) {
+ $hookData['item_id'] = $item['id'];
+ }
+ }
+}
+
+function bluesky_support_follow(array &$data)
+{
+ if ($data['protocol'] == Protocol::BLUESKY) {
+ $data['result'] = true;
+ }
+}
+
+function bluesky_support_probe(array &$data)
+{
+ if ($data['protocol'] == Protocol::BLUESKY) {
+ $data['result'] = true;
+ }
+}
+
+function bluesky_follow(array &$hook_data)
+{
+ $token = bluesky_get_token($hook_data['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ Logger::debug('Check if contact is bluesky', ['data' => $hook_data]);
+ $contact = DBA::selectFirst('contact', [], ['network' => Protocol::BLUESKY, 'url' => $hook_data['url'], 'uid' => [0, $hook_data['uid']]]);
+ if (empty($contact)) {
+ return;
+ }
+
+ $record = [
+ 'subject' => $contact['url'],
+ 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
+ '$type' => 'app.bsky.graph.follow'
+ ];
+
+ $post = [
+ 'collection' => 'app.bsky.graph.follow',
+ 'repo' => DI::pConfig()->get($hook_data['uid'], 'bluesky', 'did'),
+ 'record' => $record
+ ];
+
+ $activity = bluesky_xrpc_post($hook_data['uid'], 'com.atproto.repo.createRecord', $post);
+ if (!empty($activity->uri)) {
+ $hook_data['contact'] = $contact;
+ Logger::debug('Successfully start following', ['url' => $contact['url'], 'uri' => $activity->uri]);
+ }
+}
+
+function bluesky_unfollow(array &$hook_data)
+{
+ $token = bluesky_get_token($hook_data['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
+ return;
+ }
+
+ $data = bluesky_xrpc_get($hook_data['uid'], 'app.bsky.actor.getProfile', ['actor' => $hook_data['contact']['url']]);
+ if (empty($data->viewer) || empty($data->viewer->following)) {
+ return;
+ }
+
+ bluesky_delete_post($data->viewer->following, $hook_data['uid']);
+
+ $hook_data['result'] = true;
+}
+
+function bluesky_block(array &$hook_data)
+{
+ $token = bluesky_get_token($hook_data['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ Logger::debug('Check if contact is bluesky', ['data' => $hook_data]);
+ $contact = DBA::selectFirst('contact', [], ['network' => Protocol::BLUESKY, 'url' => $hook_data['url'], 'uid' => [0, $hook_data['uid']]]);
+ if (empty($contact)) {
+ return;
+ }
+
+ $record = [
+ 'subject' => $contact['url'],
+ 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
+ '$type' => 'app.bsky.graph.block'
+ ];
+
+ $post = [
+ 'collection' => 'app.bsky.graph.block',
+ 'repo' => DI::pConfig()->get($hook_data['uid'], 'bluesky', 'did'),
+ 'record' => $record
+ ];
+
+ $activity = bluesky_xrpc_post($hook_data['uid'], 'com.atproto.repo.createRecord', $post);
+ if (!empty($activity->uri)) {
+ $cdata = Contact::getPublicAndUserContactID($hook_data['contact']['id'], $hook_data['uid']);
+ if (!empty($cdata['user'])) {
+ Contact::remove($cdata['user']);
+ }
+ Logger::debug('Successfully blocked contact', ['url' => $hook_data['contact']['url'], 'uri' => $activity->uri]);
+ }
+}
+
+function bluesky_unblock(array &$hook_data)
+{
+ $token = bluesky_get_token($hook_data['uid']);
+ if (empty($token)) {
+ return;
+ }
+
+ if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
+ return;
+ }
+
+ $data = bluesky_xrpc_get($hook_data['uid'], 'app.bsky.actor.getProfile', ['actor' => $hook_data['contact']['url']]);
+ if (empty($data->viewer) || empty($data->viewer->blocking)) {
+ return;
+ }
+
+ bluesky_delete_post($data->viewer->blocking, $hook_data['uid']);
+
+ $hook_data['result'] = true;
+}
+
function bluesky_settings(array &$data)
{
if (!DI::userSession()->getLocalUserId()) {
return;
}
- $enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false;
- $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false;
- $host = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host') ?: 'https://bsky.social';
- $handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
- $did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
- $token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token');
- $import = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false;
+ $enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false;
+ $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false;
+ $host = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host') ?: 'https://bsky.social';
+ $handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
+ $did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
+ $token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token');
+ $import = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false;
+ $import_feeds = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import_feeds') ?? false;
$status = $token ? DI::l10n()->t("You are authenticated to Bluesky. For security reasons the password isn't stored.") : DI::l10n()->t('You are not authenticated. Please enter the app password.');
$t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/bluesky/');
$html = Renderer::replaceMacros($t, [
- '$enable' => ['bluesky', DI::l10n()->t('Enable Bluesky Post Addon'), $enabled],
- '$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post to Bluesky by default'), $def_enabled],
- '$import' => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import],
- '$host' => ['bluesky_host', DI::l10n()->t('Bluesky host'), $host, '', '', 'readonly'],
- '$handle' => ['bluesky_handle', DI::l10n()->t('Bluesky handle'), $handle],
- '$did' => ['bluesky_did', DI::l10n()->t('Bluesky DID'), $did, DI::l10n()->t('This is the unique identifier. It will be fetched automatically, when the handle is entered.'), '', 'readonly'],
- '$password' => ['bluesky_password', DI::l10n()->t('Bluesky app password'), '', DI::l10n()->t("Please don't add your real password here, but instead create a specific app password in the Bluesky settings.")],
- '$status' => $status
+ '$enable' => ['bluesky', DI::l10n()->t('Enable Bluesky Post Addon'), $enabled],
+ '$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post to Bluesky by default'), $def_enabled],
+ '$import' => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import],
+ '$import_feeds' => ['bluesky_import_feeds', DI::l10n()->t('Import the pinned feeds'), $import_feeds, DI::l10n()->t('When activated, Posts will be imported from all the feeds that you pinned in Bluesky.')],
+ '$host' => ['bluesky_host', DI::l10n()->t('Bluesky host'), $host, '', '', 'readonly'],
+ '$handle' => ['bluesky_handle', DI::l10n()->t('Bluesky handle'), $handle],
+ '$did' => ['bluesky_did', DI::l10n()->t('Bluesky DID'), $did, DI::l10n()->t('This is the unique identifier. It will be fetched automatically, when the handle is entered.'), '', 'readonly'],
+ '$password' => ['bluesky_password', DI::l10n()->t('Bluesky app password'), '', DI::l10n()->t("Please don't add your real password here, but instead create a specific app password in the Bluesky settings.")],
+ '$status' => $status
]);
$data = [
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'host', $host);
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'handle', $handle);
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import', intval($_POST['bluesky_import']));
+ DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import_feeds', intval($_POST['bluesky_import_feeds']));
if (!empty($host) && !empty($handle)) {
if (empty($old_did) || $old_host != $host || $old_handle != $handle) {
- DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'did', bluesky_get_did(DI::userSession()->getLocalUserId()));
+ DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'did', bluesky_get_did(DI::userSession()->getLocalUserId(), DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle')));
}
} else {
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
if (!empty($_POST['bluesky_password'])) {
bluesky_create_token(DI::userSession()->getLocalUserId(), $_POST['bluesky_password']);
}
-
}
function bluesky_jot_nets(array &$jotnets_fields)
}
}
- Logger::notice('importing timeline - start', ['user' => $pconfig['uid']]);
- bluesky_fetch_timeline($pconfig['uid']);
- Logger::notice('importing timeline - done', ['user' => $pconfig['uid']]);
+ // Refresh the token now, so that it doesn't need to be refreshed in parallel by the following workers
+ bluesky_get_token($pconfig['uid']);
+
+ Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/bluesky/bluesky_timeline.php', $pconfig['uid']);
+ Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/bluesky/bluesky_notifications.php', $pconfig['uid']);
+
+ if (DI::pConfig()->get($pconfig['uid'], 'bluesky', 'import_feeds')) {
+ $feeds = bluesky_get_feeds($pconfig['uid']);
+ foreach ($feeds as $feed) {
+ Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/bluesky/bluesky_feed.php', $pconfig['uid'], $feed);
+ }
+ }
+ }
+
+ $last_clean = DI::keyValue()->get('bluesky_last_clean');
+ if (empty($last_clean) || ($last_clean + 86400 < time())) {
+ Logger::notice('Start contact cleanup');
+ $contacts = DBA::select('account-user-view', ['id', 'pid'], ["`network` = ? AND `uid` != ? AND `rel` = ?", Protocol::BLUESKY, 0, Contact::NOTHING]);
+ while ($contact = DBA::fetch($contacts)) {
+ Worker::add(Worker::PRIORITY_LOW, 'MergeContact', $contact['pid'], $contact['id'], 0);
+ }
+ DBA::close($contacts);
+ DI::keyValue()->set('bluesky_last_clean', time());
+ Logger::notice('Contact cleanup done');
}
Logger::notice('cron_end');
'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
'$type' => 'app.bsky.feed.like'
];
-
+
$post = [
'collection' => 'app.bsky.feed.like',
'repo' => $did,
];
}
- $activity = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
+ $activity = bluesky_xrpc_post($uid, 'com.atproto.repo.createRecord', $post);
if (empty($activity)) {
return;
}
}
$did = DI::pConfig()->get($uid, 'bluesky', 'did');
+ $urls = bluesky_get_urls(Post\Media::removeFromBody($item['body']));
+ $item['body'] = $urls['body'];
- $msg = Plaintext::getPost($item, 300, false, BBCode::CONNECTORS);
+ $msg = Plaintext::getPost($item, 300, false, BBCode::BLUESKY);
foreach ($msg['parts'] as $key => $part) {
+
+ $facets = bluesky_get_facets($part, $urls['urls']);
+
$record = [
- 'text' => $part,
+ 'text' => $facets['body'],
'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
'$type' => 'app.bsky.feed.post'
];
+ if (!empty($facets['facets'])) {
+ $record['facets'] = $facets['facets'];
+ }
+
if (!empty($root)) {
$record['reply'] = ['root' => $root, 'parent' => $parent];
}
'record' => $record
];
- $parent = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
+ $parent = bluesky_xrpc_post($uid, 'com.atproto.repo.createRecord', $post);
if (empty($parent)) {
return;
}
}
}
+function bluesky_get_urls(string $body): array
+{
+ // Remove all hashtag and mention links
+ $body = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '$1$3', $body);
+
+ $urls = [];
+
+ // Search for pure links
+ if (preg_match_all("/\[url\](https?:.*?)\[\/url\]/ism", $body, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ $text = Strings::getStyledURL($match[1]);
+ $hash = bluesky_get_hash_for_url($match[0], mb_strlen($text));
+ $urls[] = ['url' => $match[1], 'text' => $text, 'hash' => $hash];
+ $body = str_replace($match[0], $hash, $body);
+ }
+ }
+
+ // Search for links with descriptions
+ if (preg_match_all("/\[url\=(https?:.*?)\](.*?)\[\/url\]/ism", $body, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ if ($match[1] == $match[2]) {
+ $text = Strings::getStyledURL($match[1]);
+ } else {
+ $text = $match[2];
+ }
+ if (mb_strlen($text) < 100) {
+ $hash = bluesky_get_hash_for_url($match[0], mb_strlen($text));
+ $urls[] = ['url' => $match[1], 'text' => $text, 'hash' => $hash];
+ $body = str_replace($match[0], $hash, $body);
+ } else {
+ $text = Strings::getStyledURL($match[1]);
+ $hash = bluesky_get_hash_for_url($match[0], mb_strlen($text));
+ $urls[] = ['url' => $match[1], 'text' => $text, 'hash' => $hash];
+ $body = str_replace($match[0], $text . ' ' . $hash, $body);
+ }
+ }
+ }
+
+ return ['body' => $body, 'urls' => $urls];
+}
+
+function bluesky_get_hash_for_url(string $text, int $linklength): string
+{
+ if ($linklength <= 10) {
+ return '|' . hash('crc32', $text) . '|';
+ }
+ return substr('|' . hash('crc32', $text) . base64_encode($text), 0, $linklength - 2) . '|';
+}
+
+function bluesky_get_facets(string $body, array $urls): array
+{
+ $facets = [];
+
+ foreach ($urls as $url) {
+ $pos = strpos($body, $url['hash']);
+ if ($pos === false) {
+ continue;
+ }
+ if ($pos > 0) {
+ $prefix = substr($body, 0, $pos);
+ } else {
+ $prefix = '';
+ }
+
+ $body = $prefix . $url['text'] . substr($body, $pos + strlen($url['hash']));
+
+ $facet = new stdClass;
+ $facet->index = new stdClass;
+ $facet->index->byteEnd = $pos + strlen($url['text']);
+ $facet->index->byteStart = $pos;
+
+ $feature = new stdClass;
+ $feature->uri = $url['url'];
+ $type = '$type';
+ $feature->$type = 'app.bsky.richtext.facet#link';
+
+ $facet->features = [$feature];
+ $facets[] = $facet;
+ }
+
+ return ['facets' => $facets, 'body' => $body];
+}
+
function bluesky_add_embed(int $uid, array $msg, array $record): array
{
if (($msg['type'] != 'link') && !empty($msg['images'])) {
$photo = Photo::selectFirst([], ["`resource-id` = ? AND `scale` > ?", $photo['resource-id'], 0], ['order' => ['scale']]);
$blob = bluesky_upload_blob($uid, $photo);
if (!empty($blob) && count($images) < 4) {
- $images[] = ['alt' => $image['description'], 'image' => $blob];
+ $images[] = ['alt' => $image['description'] ?? '', 'image' => $blob];
}
}
if (!empty($images)) {
'$type' => 'app.bsky.embed.external',
'external' => [
'uri' => $msg['url'],
- 'title' => $msg['title'],
- 'description' => $msg['description'],
+ 'title' => $msg['title'] ?? '',
+ 'description' => $msg['description'] ?? '',
]
];
if (!empty($msg['image'])) {
function bluesky_delete_post(string $uri, int $uid)
{
- $token = bluesky_get_token($uid);
$parts = bluesky_get_uri_parts($uri);
if (empty($parts)) {
Logger::debug('No uri delected', ['uri' => $uri]);
return;
}
- bluesky_post($uid, '/xrpc/com.atproto.repo.deleteRecord', json_encode($parts), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
+ bluesky_xrpc_post($uid, 'com.atproto.repo.deleteRecord', $parts);
Logger::debug('Deleted', ['parts' => $parts]);
}
function bluesky_fetch_timeline(int $uid)
{
- $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getTimeline', HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
+ $data = bluesky_xrpc_get($uid, 'app.bsky.feed.getTimeline');
if (empty($data)) {
return;
}
}
foreach (array_reverse($data->feed) as $entry) {
- bluesky_process_post($entry->post, $uid);
+ bluesky_process_post($entry->post, $uid, Item::PR_NONE, 0);
+ if (!empty($entry->reason)) {
+ bluesky_process_reason($entry->reason, bluesky_get_uri($entry->post), $uid);
+ }
}
// @todo Support paging
// [cursor] => 1684670516000::bafyreidq3ilwslmlx72jf5vrk367xcc63s6lrhzlyup2bi3zwcvso6w2vi
}
-function bluesky_process_post(stdClass $post, int $uid): int
+function bluesky_process_reason(stdClass $reason, string $uri, int $uid)
+{
+ $type = '$type';
+ if ($reason->$type != 'app.bsky.feed.defs#reasonRepost') {
+ return;
+ }
+
+ $contact = bluesky_get_contact($reason->by, $uid, $uid);
+
+ $item = [
+ 'network' => Protocol::BLUESKY,
+ 'uid' => $uid,
+ 'wall' => false,
+ 'uri' => $reason->by->did . '/app.bsky.feed.repost/' . $reason->indexedAt,
+ 'private' => Item::UNLISTED,
+ 'verb' => Activity::POST,
+ 'contact-id' => $contact['id'],
+ 'author-name' => $contact['name'],
+ 'author-link' => $contact['url'],
+ 'author-avatar' => $contact['avatar'],
+ 'verb' => Activity::ANNOUNCE,
+ 'body' => Activity::ANNOUNCE,
+ 'gravity' => Item::GRAVITY_ACTIVITY,
+ 'object-type' => Activity\ObjectType::NOTE,
+ 'thr-parent' => $uri,
+ ];
+
+ if (Post::exists(['uri' => $item['uri'], 'uid' => $uid])) {
+ return;
+ }
+
+ $item['guid'] = Item::guidFromUri($item['uri'], $contact['alias']);
+ $item['owner-name'] = $item['author-name'];
+ $item['owner-link'] = $item['author-link'];
+ $item['owner-avatar'] = $item['author-avatar'];
+ if (Item::insert($item)) {
+ $cdata = Contact::getPublicAndUserContactID($contact['id'], $uid);
+ Item::update(['post-reason' => Item::PR_ANNOUNCEMENT, 'causer-id' => $cdata['public']], ['uri' => $uri, 'uid' => $uid]);
+ }
+}
+
+function bluesky_fetch_notifications(int $uid)
+{
+ $data = bluesky_xrpc_get($uid, 'app.bsky.notification.listNotifications');
+ if (empty($data->notifications)) {
+ return;
+ }
+ foreach ($data->notifications as $notification) {
+ $uri = bluesky_get_uri($notification);
+ if (Post::exists(['uri' => $uri, 'uid' => $uid]) || Post::exists(['extid' => $uri, 'uid' => $uid])) {
+ Logger::debug('Notification already processed', ['uid' => $uid, 'reason' => $notification->reason, 'uri' => $uri, 'indexedAt' => $notification->indexedAt]);
+ continue;
+ }
+ Logger::debug('Process notification', ['uid' => $uid, 'reason' => $notification->reason, 'uri' => $uri, 'indexedAt' => $notification->indexedAt]);
+ switch ($notification->reason) {
+ case 'like':
+ $item = bluesky_get_header($notification, $uri, $uid, $uid);
+ $item['gravity'] = Item::GRAVITY_ACTIVITY;
+ $item['body'] = $item['verb'] = Activity::LIKE;
+ $item['thr-parent'] = bluesky_get_uri($notification->record->subject);
+ $item['thr-parent'] = bluesky_fetch_missing_post($item['thr-parent'], $uid, $item['contact-id'], 0);
+ if (!empty($item['thr-parent'])) {
+ $data = Item::insert($item);
+ Logger::debug('Got like', ['uid' => $uid, 'result' => $data, 'uri' => $uri]);
+ } else {
+ Logger::info('Thread parent not found', ['uid' => $uid, 'parent' => $$item['thr-parent'], 'uri' => $uri]);
+ }
+ break;
+
+ case 'repost':
+ $item = bluesky_get_header($notification, $uri, $uid, $uid);
+ $item['gravity'] = Item::GRAVITY_ACTIVITY;
+ $item['body'] = $item['verb'] = Activity::ANNOUNCE;
+ $item['thr-parent'] = bluesky_get_uri($notification->record->subject);
+ $item['thr-parent'] = bluesky_fetch_missing_post($item['thr-parent'], $uid, $item['contact-id'], 0);
+ if (!empty($item['thr-parent'])) {
+ $data = Item::insert($item);
+ Logger::debug('Got repost', ['uid' => $uid, 'result' => $data, 'uri' => $uri]);
+ } else {
+ Logger::info('Thread parent not found', ['uid' => $uid, 'parent' => $$item['thr-parent'], 'uri' => $uri]);
+ }
+ break;
+
+ case 'follow':
+ $contact = bluesky_get_contact($notification->author, $uid, $uid);
+ Logger::debug('New follower', ['uid' => $uid, 'nick' => $contact['nick'], 'uri' => $uri]);
+ break;
+
+ case 'mention':
+ $data = bluesky_process_post($notification, $uid, Item::PR_PUSHED, 0);
+ Logger::debug('Got mention', ['uid' => $uid, 'result' => $data, 'uri' => $uri]);
+ break;
+
+ case 'reply':
+ $data = bluesky_process_post($notification, $uid, Item::PR_PUSHED, 0);
+ Logger::debug('Got reply', ['uid' => $uid, 'result' => $data, 'uri' => $uri]);
+ break;
+
+ case 'quote':
+ $data = bluesky_process_post($notification, $uid, Item::PR_PUSHED, 0);
+ Logger::debug('Got quote', ['uid' => $uid, 'result' => $data, 'uri' => $uri]);
+ break;
+
+ default:
+ Logger::notice('Unhandled reason', ['reason' => $notification->reason, 'uri' => $uri]);
+ break;
+ }
+ }
+}
+
+function bluesky_fetch_feed(int $uid, string $feed)
+{
+ $data = bluesky_xrpc_get($uid, 'app.bsky.feed.getFeed', ['feed' => $feed]);
+ if (empty($data)) {
+ return;
+ }
+
+ if (empty($data->feed)) {
+ return;
+ }
+
+ foreach (array_reverse($data->feed) as $entry) {
+ if (!Relay::isWantedLanguage($entry->post->record->text)) {
+ Logger::debug('Unwanted language detected', ['text' => $entry->post->record->text]);
+ continue;
+ }
+ bluesky_process_post($entry->post, $uid, Item::PR_TAG, 0);
+ if (!empty($entry->reason)) {
+ bluesky_process_reason($entry->reason, bluesky_get_uri($entry->post), $uid);
+ }
+ }
+}
+
+function bluesky_process_post(stdClass $post, int $uid, int $post_reason, $level): int
{
$uri = bluesky_get_uri($post);
return 0;
}
- Logger::debug('Importing post', ['uid' => $uid, 'indexedAt' => $post->indexedAt, 'uri' => $post->uri, 'cid' => $post->cid]);
-
- $item = bluesky_get_header($post, $uri, $uid);
+ Logger::debug('Importing post', ['uid' => $uid, 'indexedAt' => $post->indexedAt, 'uri' => $post->uri, 'cid' => $post->cid, 'root' => $post->record->reply->root ?? '']);
- $item = bluesky_get_content($item, $post->record, $uid);
+ $item = bluesky_get_header($post, $uri, $uid, $uid);
+ $item = bluesky_get_content($item, $post->record, $uri, $uid, $level);
+ if (empty($item)) {
+ return 0;
+ }
if (!empty($post->embed)) {
- $item = bluesky_add_media($post->embed, $item);
+ $item = bluesky_add_media($post->embed, $item, $uid, $level);
}
+
+ if (empty($item['post-reason'])) {
+ $item['post-reason'] = $post_reason;
+ }
+
return item::insert($item);
}
-function bluesky_get_header(stdClass $post, string $uri, int $uid): array
+function bluesky_get_header(stdClass $post, string $uri, int $uid, int $fetch_uid): array
{
- $contact = bluesky_get_contact($post->author, $uid);
+ $parts = bluesky_get_uri_parts($uri);
+ if (empty($post->author)) {
+ return [];
+ }
+ $contact = bluesky_get_contact($post->author, $uid, $fetch_uid);
$item = [
'network' => Protocol::BLUESKY,
'uid' => $uid,
'author-name' => $contact['name'],
'author-link' => $contact['url'],
'author-avatar' => $contact['avatar'],
- // 'plink' => '', @todo Path to a web representation
+ 'plink' => $contact['alias'] . '/post/' . $parts->rkey,
];
$item['uri-id'] = ItemURI::getIdByURI($uri);
$item['owner-link'] = $item['author-link'];
$item['owner-avatar'] = $item['author-avatar'];
+ if (in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) {
+ $item['post-reason'] = Item::PR_FOLLOWER;
+ }
+
return $item;
}
-function bluesky_get_content(array $item, stdClass $record, int $uid): array
+function bluesky_get_content(array $item, stdClass $record, string $uri, int $uid, int $level): array
{
+ if (empty($item)) {
+ return [];
+ }
+
if (!empty($record->reply)) {
$item['parent-uri'] = bluesky_get_uri($record->reply->root);
- bluesky_fetch_missing_post($item['parent-uri'], $uid);
+ if ($item['parent-uri'] != $uri) {
+ $item['parent-uri'] = bluesky_fetch_missing_post($item['parent-uri'], $uid, $item['contact-id'], $level);
+ if (empty($item['parent-uri'])) {
+ return [];
+ }
+ }
+
$item['thr-parent'] = bluesky_get_uri($record->reply->parent);
- bluesky_fetch_missing_post($item['thr-parent'], $uid);
+ if (!in_array($item['thr-parent'], [$uri, $item['parent-uri']])) {
+ $item['thr-parent'] = bluesky_fetch_missing_post($item['thr-parent'], $uid, $item['contact-id'], $level, $item['parent-uri']);
+ if (empty($item['thr-parent'])) {
+ return [];
+ }
+ }
}
- $body = $record->text;
+ $item['body'] = bluesky_get_text($record);
+ $item['created'] = DateTimeFormat::utc($record->createdAt, DateTimeFormat::MYSQL);
+ return $item;
+}
+
+function bluesky_get_text(stdClass $record): string
+{
+ $text = $record->text;
- if (!empty($record->facets)) {
- // @todo add Links
+ if (empty($record->facets)) {
+ return $text;
}
- $item['body'] = $body;
- $item['created'] = DateTimeFormat::utc($record->createdAt, DateTimeFormat::MYSQL);
- return $item;
+ $facets = [];
+ foreach ($record->facets as $facet) {
+ $facets[$facet->index->byteStart] = $facet;
+ }
+ krsort($facets);
+
+ foreach ($facets as $facet) {
+ $prefix = substr($text, 0, $facet->index->byteStart);
+ $linktext = substr($text, $facet->index->byteStart, $facet->index->byteEnd - $facet->index->byteStart);
+ $suffix = substr($text, $facet->index->byteEnd);
+
+ $url = '';
+ $type = '$type';
+ foreach ($facet->features as $feature) {
+
+ switch ($feature->$type) {
+ case 'app.bsky.richtext.facet#link':
+ $url = $feature->uri;
+ break;
+
+ case 'app.bsky.richtext.facet#mention':
+ $contact = Contact::getByURL($feature->did, null, ['id']);
+ if (!empty($contact['id'])) {
+ $url = DI::baseUrl() . '/contact/' . $contact['id'];
+ if (substr($linktext, 0, 1) == '@') {
+ $prefix .= '@';
+ $linktext = substr($linktext, 1);
+ }
+ }
+ break;
+
+ default:
+ Logger::notice('Unhandled feature type', ['type' => $feature->$type, 'record' => $record]);
+ break;
+ }
+ }
+ if (!empty($url)) {
+ $text = $prefix . '[url=' . $url . ']' . $linktext . '[/url]' . $suffix;
+ }
+ }
+ return $text;
}
-function bluesky_add_media(stdClass $embed, array $item): array
+function bluesky_add_media(stdClass $embed, array $item, int $fetch_uid, int $level): array
{
- if (!empty($embed->images)) {
- foreach ($embed->images as $image) {
+ $type = '$type';
+ switch ($embed->$type) {
+ case 'app.bsky.embed.images#view':
+ foreach ($embed->images as $image) {
+ $media = [
+ 'uri-id' => $item['uri-id'],
+ 'type' => Post\Media::IMAGE,
+ 'url' => $image->fullsize,
+ 'preview' => $image->thumb,
+ 'description' => $image->alt,
+ ];
+ Post\Media::insert($media);
+ }
+ break;
+
+ case 'app.bsky.embed.external#view':
$media = [
- 'uri-id' => $item['uri-id'],
- 'type' => Post\Media::IMAGE,
- 'url' => $image->fullsize,
- 'preview' => $image->thumb,
- 'description' => $image->alt,
+ 'uri-id' => $item['uri-id'],
+ 'type' => Post\Media::HTML,
+ 'url' => $embed->external->uri,
+ 'name' => $embed->external->title,
+ 'description' => $embed->external->description,
];
Post\Media::insert($media);
- }
- } elseif (!empty($embed->external)) {
- $media = ['uri-id' => $item['uri-id'],
- 'type' => Post\Media::HTML,
- 'url' => $embed->external->uri,
- 'name' => $embed->external->title,
- 'description' => $embed->external->description,
- ];
- Post\Media::insert($media);
- } elseif (!empty($embed->record)) {
- $uri = bluesky_get_uri($embed->record);
- $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
- if (empty($shared)) {
- $shared = bluesky_get_header($embed->record, $uri, 0);
- $shared = bluesky_get_content($shared, $embed->record->value, $item['uid']);
-
- if (!empty($embed->record->embeds)) {
- foreach ($embed->record->embeds as $single) {
- $shared = bluesky_add_media($single, $shared);
+ break;
+
+ case 'app.bsky.embed.record#view':
+ $uri = bluesky_get_uri($embed->record);
+ $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
+ if (empty($shared)) {
+ if (empty($embed->record->value)) {
+ Logger::info('Record has got no value', ['record' => $embed->record]);
+ break;
+ }
+ $shared = bluesky_get_header($embed->record, $uri, 0, $fetch_uid);
+ $shared = bluesky_get_content($shared, $embed->record->value, $uri, $item['uid'], $level);
+ if (!empty($shared)) {
+ if (!empty($embed->record->embeds)) {
+ foreach ($embed->record->embeds as $single) {
+ $shared = bluesky_add_media($single, $shared, $fetch_uid, $level);
+ }
+ }
+ $id = Item::insert($shared);
+ $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
}
}
- $id = Item::insert($shared);
- $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
- }
- if (!empty($shared)) {
- $item['quote-uri-id'] = $shared['uri-id'];
- }
- } else {
- Logger::debug('Unsupported embed', ['embed' => $embed, 'item' => $item]);
+ if (!empty($shared)) {
+ $item['quote-uri-id'] = $shared['uri-id'];
+ }
+ break;
+
+ case 'app.bsky.embed.recordWithMedia#view':
+ $uri = bluesky_get_uri($embed->record->record);
+ $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
+ if (empty($shared)) {
+ $shared = bluesky_get_header($embed->record->record, $uri, 0, $fetch_uid);
+ $shared = bluesky_get_content($shared, $embed->record->record->value, $uri, $item['uid'], $level);
+ if (!empty($shared)) {
+ if (!empty($embed->record->record->embeds)) {
+ foreach ($embed->record->record->embeds as $single) {
+ $shared = bluesky_add_media($single, $shared, $fetch_uid, $level);
+ }
+ }
+
+ $id = Item::insert($shared);
+ $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
+ }
+ }
+ if (!empty($shared)) {
+ $item['quote-uri-id'] = $shared['uri-id'];
+ }
+
+ if (!empty($embed->media)) {
+ bluesky_add_media($embed->media, $item, $fetch_uid, $level);
+ }
+ break;
+
+ default:
+ Logger::notice('Unhandled embed type', ['type' => $embed->$type, 'embed' => $embed]);
+ break;
}
return $item;
}
function bluesky_get_uri(stdClass $post): string
{
+ if (empty($post->cid)) {
+ Logger::info('Invalid URI', ['post' => $post, 'callstack' => System::callstack(10, 0, true)]);
+ return '';
+ }
return $post->uri . ':' . $post->cid;
}
$class->cid = array_pop($elements);
$class->uri = implode(':', $elements);
+ if ((substr_count($class->uri, '/') == 2) && (substr_count($class->cid, '/') == 2)) {
+ $class->uri .= ':' . $class->cid;
+ $class->cid = '';
+ }
+
return $class;
}
return $class;
}
-function bluesky_fetch_missing_post(string $uri, int $uid)
+function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, int $level, string $fallback = ''): string
{
- if (Post::exists(['uri' => $uri, 'uid' => [$uid, 0]])) {
- Logger::debug('Post exists', ['uri' => $uri]);
- return;
+ $fetched_uri = bluesky_fetch_post($uri, $uid);
+ if (!empty($fetched_uri)) {
+ return $fetched_uri;
+ }
+
+ if (++$level > 100) {
+ Logger::info('Recursion level too deep', ['level' => $level, 'uid' => $uid, 'uri' => $uri, 'fallback' => $fallback]);
+ // When the level is too deep we will fallback to the parent uri.
+ // Allthough the threading won't be correct, we at least had stored all posts and won't try again
+ return $fallback;
}
- Logger::debug('Fetch missing post', ['uri' => $uri]);
$class = bluesky_get_uri_class($uri);
-
- $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getPosts?uris=' . $class->uri, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
+ $fetch_uri = $class->uri;
+
+ Logger::debug('Fetch missing post', ['level' => $level, 'uid' => $uid, 'uri' => $uri]);
+ $data = bluesky_xrpc_get($uid, 'app.bsky.feed.getPostThread', ['uri' => $fetch_uri]);
if (empty($data)) {
- return;
+ Logger::info('Thread was not fetched', ['level' => $level, 'uid' => $uid, 'uri' => $uri, 'fallback' => $fallback]);
+ return $fallback;
+ }
+
+ Logger::debug('Reply count', ['replies' => $data->thread->post->replyCount, 'level' => $level, 'uid' => $uid, 'uri' => $uri]);
+
+ if ($causer != 0) {
+ $cdata = Contact::getPublicAndUserContactID($causer, $uid);
+ } else {
+ $cdata = [];
+ }
+
+ return bluesky_process_thread($data->thread, $uid, $cdata, $level);
+}
+
+function bluesky_fetch_post(string $uri, int $uid): string
+{
+ if (Post::exists(['uri' => $uri, 'uid' => [$uid, 0]])) {
+ Logger::debug('Post exists', ['uri' => $uri]);
+ return $uri;
}
- foreach ($data->posts as $post) {
- $item = bluesky_get_header($post, $uri, $uid);
- $item = bluesky_get_content($item, $post->record, $uid);
- if (!empty($post->embed)) {
- $item = bluesky_add_media($post->embed, $item);
+ $reply = Post::selectFirst(['uri'], ['extid' => $uri, 'uid' => [$uid, 0]]);
+ if (!empty($reply['uri'])) {
+ Logger::debug('Post with extid exists', ['uri' => $uri]);
+ return $reply['uri'];
+ }
+ return '';
+}
+
+function bluesky_process_thread(stdClass $thread, int $uid, array $cdata, int $level): string
+{
+ $uri = bluesky_get_uri($thread->post);
+ $fetched_uri = bluesky_fetch_post($uri, $uid);
+ if (empty($fetched_uri)) {
+ Logger::debug('Process missing post', ['uri' => $uri]);
+ $item = bluesky_get_header($thread->post, $uri, $uid, $uid);
+ $item = bluesky_get_content($item, $thread->post->record, $uri, $uid, $level);
+ if (!empty($item)) {
+ $item['post-reason'] = Item::PR_FETCHED;
+
+ if (!empty($cdata['public'])) {
+ $item['causer-id'] = $cdata['public'];
+ }
+
+ if (!empty($thread->post->embed)) {
+ $item = bluesky_add_media($thread->post->embed, $item, $uid, $level);
+ }
+ $id = Item::insert($item);
+ if (!$id) {
+ Logger::info('Item has not not been stored', ['uri' => $uri]);
+ return '';
+ }
+ Logger::debug('Stored item', ['id' => $id, 'uri' => $uri]);
+ } else {
+ Logger::info('Post has not not been fetched', ['uri' => $uri]);
+ return '';
}
- $id = Item::insert($item);
- Logger::debug('Stored item', ['id' => $id, 'uri' => $uri]);
+ } else {
+ Logger::debug('Post exists', ['uri' => $uri]);
+ $uri = $fetched_uri;
}
+
+ foreach ($thread->replies ?? [] as $reply) {
+ $reply_uri = bluesky_process_thread($reply, $uid, $cdata, $level);
+ Logger::debug('Reply has been processed', ['uri' => $uri, 'reply' => $reply_uri]);
+ }
+
+ return $uri;
}
-function bluesky_get_contact(stdClass $author, int $uid): array
+function bluesky_get_contact(stdClass $author, int $uid, int $fetch_uid): array
{
- $condition = ['network' => Protocol::BLUESKY, 'uid' => $uid, 'url' => $author->did];
+ $condition = ['network' => Protocol::BLUESKY, 'uid' => 0, 'url' => $author->did];
+ $contact = Contact::selectFirst(['id', 'updated'], $condition);
- $fields = [
- 'name' => $author->displayName,
- 'nick' => $author->handle,
- 'addr' => $author->handle,
- ];
+ $update = empty($contact) || $contact['updated'] < DateTimeFormat::utc('now -24 hours');
- $contact = Contact::selectFirst([], $condition);
+ $public_fields = $fields = bluesky_get_contact_fields($author, $fetch_uid, $update);
+
+ $public_fields['uid'] = 0;
+ $public_fields['rel'] = Contact::NOTHING;
if (empty($contact)) {
- $cid = bluesky_insert_contact($author, $uid);
+ $cid = Contact::insert($public_fields);
} else {
$cid = $contact['id'];
- if ($fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) {
- Contact::update($fields, ['id' => $cid]);
- }
+ Contact::update($public_fields, ['id' => $cid], true);
}
- $condition['uid'] = 0;
+ if ($uid != 0) {
+ $condition = ['network' => Protocol::BLUESKY, 'uid' => $uid, 'url' => $author->did];
- $contact = Contact::selectFirst([], $condition);
- if (empty($contact)) {
- $pcid = bluesky_insert_contact($author, 0);
- } else {
- $pcid = $contact['id'];
- if ($fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) {
- Contact::update($fields, ['id' => $pcid]);
+ $contact = Contact::selectFirst(['id', 'rel', 'uid'], $condition);
+ if (!isset($fields['rel']) && isset($contact['rel'])) {
+ $fields['rel'] = $contact['rel'];
+ } elseif (!isset($fields['rel'])) {
+ $fields['rel'] = Contact::NOTHING;
}
}
+ if (($uid != 0) && ($fields['rel'] != Contact::NOTHING)) {
+ if (empty($contact)) {
+ $cid = Contact::insert($fields);
+ } else {
+ $cid = $contact['id'];
+ Contact::update($fields, ['id' => $cid], true);
+ }
+ Logger::debug('Get user contact', ['id' => $cid, 'uid' => $uid, 'update' => $update]);
+ } else {
+ Logger::debug('Get public contact', ['id' => $cid, 'uid' => $uid, 'update' => $update]);
+ }
if (!empty($author->avatar)) {
Contact::updateAvatar($cid, $author->avatar);
}
- if (empty($contact) || $contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
- bluesky_update_contact($author, $uid, $cid, $pcid);
- }
-
return Contact::getById($cid);
}
-function bluesky_insert_contact(stdClass $author, int $uid)
+function bluesky_get_contact_fields(stdClass $author, int $uid, bool $update): array
{
$fields = [
'uid' => $uid,
'blocked' => false,
'readonly' => false,
'pending' => false,
+ 'baseurl' => BLUESKY_HOST,
'url' => $author->did,
'nurl' => $author->did,
- // 'alias' => '', @todo Path to a web representation
- 'name' => $author->displayName,
+ 'alias' => BLUESKY_HOST . '/profile/' . $author->handle,
+ 'name' => $author->displayName ?? $author->handle,
'nick' => $author->handle,
'addr' => $author->handle,
];
- return Contact::insert($fields);
-}
-function bluesky_update_contact(stdClass $author, int $uid, int $cid, int $pcid)
-{
- $data = bluesky_get($uid, '/xrpc/app.bsky.actor.getProfile?actor=' . $author->did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
+ if (!$update) {
+ Logger::debug('Got contact fields', ['uid' => $uid, 'url' => $fields['url']]);
+ return $fields;
+ }
+
+ $data = bluesky_xrpc_get($uid, 'app.bsky.actor.getProfile', ['actor' => $author->did]);
if (empty($data)) {
- return;
+ Logger::debug('Error fetching contact fields', ['uid' => $uid, 'url' => $fields['url']]);
+ return $fields;
}
- $fields = [
- 'name' => $data->displayName,
- 'nick' => $data->handle,
- 'addr' => $data->handle,
- 'about' => HTML::toBBCode($data->description),
- 'updated' => DateTimeFormat::utcNow(DateTimeFormat::MYSQL),
- ];
+ $fields['updated'] = DateTimeFormat::utcNow(DateTimeFormat::MYSQL);
+
+ if (!empty($data->description)) {
+ $fields['about'] = HTML::toBBCode($data->description);
+ }
if (!empty($data->banner)) {
$fields['header'] = $data->banner;
}
- Contact::update($fields, ['id' => $cid]);
- Contact::update($fields, ['id' => $pcid]);
+ if (!empty($data->viewer)) {
+ if (!empty($data->viewer->following) && !empty($data->viewer->followedBy)) {
+ $fields['rel'] = Contact::FRIEND;
+ } elseif (!empty($data->viewer->following) && empty($data->viewer->followedBy)) {
+ $fields['rel'] = Contact::SHARING;
+ } elseif (empty($data->viewer->following) && !empty($data->viewer->followedBy)) {
+ $fields['rel'] = Contact::FOLLOWER;
+ } else {
+ $fields['rel'] = Contact::NOTHING;
+ }
+ }
+
+ Logger::debug('Got updated contact fields', ['uid' => $uid, 'url' => $fields['url']]);
+ return $fields;
}
-function bluesky_get_did(int $uid): string
+function bluesky_get_feeds(int $uid): array
{
- $data = bluesky_get($uid, '/xrpc/com.atproto.identity.resolveHandle?handle=' . DI::pConfig()->get($uid, 'bluesky', 'handle'));
+ $type = '$type';
+ $preferences = bluesky_get_preferences($uid);
+ foreach ($preferences->preferences as $preference) {
+ if ($preference->$type == 'app.bsky.actor.defs#savedFeedsPref') {
+ return $preference->pinned ?? [];
+ }
+ }
+ return [];
+}
+
+function bluesky_get_preferences(int $uid): stdClass
+{
+ $cachekey = 'bluesky:preferences:' . $uid;
+ $data = DI::cache()->get($cachekey);
+ if (!is_null($data)) {
+ return $data;
+ }
+
+ $data = bluesky_xrpc_get($uid, 'app.bsky.actor.getPreferences');
+
+ DI::cache()->set($cachekey, $data, Duration::HOUR);
+ return $data;
+}
+
+function bluesky_get_did(int $uid, string $handle): string
+{
+ $data = bluesky_get($uid, '/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle));
if (empty($data)) {
return '';
}
return $data->accessJwt;
}
+function bluesky_xrpc_post(int $uid, string $url, $parameters): ?stdClass
+{
+ return bluesky_post($uid, '/xrpc/' . $url, json_encode($parameters), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . bluesky_get_token($uid)]]);
+}
+
function bluesky_post(int $uid, string $url, string $params, array $headers): ?stdClass
{
try {
return json_decode($curlResult->getBody());
}
+function bluesky_xrpc_get(int $uid, string $url, array $parameters = []): ?stdClass
+{
+ if (!empty($parameters)) {
+ $url .= '?' . http_build_query($parameters);
+ }
+
+ return bluesky_get($uid, '/xrpc/' . $url, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
+}
+
function bluesky_get(int $uid, string $url, string $accept_content = HttpClientAccept::DEFAULT, array $opts = []): ?stdClass
{
try {
}
return json_decode($curlResult->getBody());
-}
+}
\ No newline at end of file