diff --git a/bluesky/bluesky.php b/bluesky/bluesky.php index dd56f9a6..eb8f07a4 100644 --- a/bluesky/bluesky.php +++ b/bluesky/bluesky.php @@ -6,21 +6,15 @@ * Author: Michael Vogel * * @todo - * Nice to have: - * - Probing for contacts + * - Links in outgoing comments + * - Outgoing mentions * - * Need more information: + * Possibly not possible: * - only fetch new posts - * - detect contact relations - * - receive likes - * - follow contacts - * - unfollow contacts * - * Possible but less important: - * - Block contacts - * - unblock contacts - * - mute contacts - * - unmute contacts + * 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 */ use Friendica\Content\Text\BBCode; @@ -31,6 +25,7 @@ use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\Protocol; use Friendica\Core\Renderer; +use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; @@ -57,14 +52,14 @@ 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('probe_detect', __FILE__, 'bluesky_probe_detect'); Hook::register('item_by_link', __FILE__, 'bluesky_item_by_link'); } @@ -75,15 +70,59 @@ 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_get($pconfig['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]); + if (empty($data)) { + return; + } + + $hookData['result'] = bluesky_get_contact($data, 0, $pconfig['uid']); + + // Authoritative probe should set the result even if the probe was unsuccessful + if ($hookData['network'] == Protocol::BLUESKY && empty($hookData['result'])) { + $hookData['result'] = []; + } +} + function bluesky_item_by_link(array &$hookData) { // Don't overwrite an existing result @@ -109,7 +148,7 @@ function bluesky_item_by_link(array &$hookData) $uri = 'at://' . $did . '/app.bsky.feed.post/' . $matches[2]; - $uri = bluesky_fetch_missing_post($uri, $hookData['uid'], 0, true); + $uri = bluesky_fetch_missing_post($uri, $hookData['uid'], 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']]); @@ -119,6 +158,129 @@ function bluesky_item_by_link(array &$hookData) } } +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_post($hook_data['uid'], '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]); + 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_get($hook_data['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $hook_data['contact']['url'], HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]); + 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_post($hook_data['uid'], '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]); + 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_get($hook_data['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $hook_data['contact']['url'], HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]); + 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()) { @@ -243,6 +405,21 @@ function bluesky_cron() Logger::notice('importing timeline - start', ['user' => $pconfig['uid']]); bluesky_fetch_timeline($pconfig['uid']); Logger::notice('importing timeline - done', ['user' => $pconfig['uid']]); + Logger::notice('importing notifications - start', ['user' => $pconfig['uid']]); + bluesky_fetch_notifications($pconfig['uid']); + Logger::notice('importing notifications - done', ['user' => $pconfig['uid']]); + } + + $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'); @@ -521,7 +698,7 @@ function bluesky_add_embed(int $uid, array $msg, array $record): array $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)) { @@ -600,7 +777,7 @@ function bluesky_process_reason(stdClass $reason, string $uri, int $uid) return; } - $contact = bluesky_get_contact($reason->by, $uid); + $contact = bluesky_get_contact($reason->by, $uid, $uid); $item = [ 'network' => Protocol::BLUESKY, @@ -633,6 +810,65 @@ function bluesky_process_reason(stdClass $reason, string $uri, int $uid) } } +function bluesky_fetch_notifications(int $uid) +{ + $result = bluesky_get($uid, '/xrpc/app.bsky.notification.listNotifications', HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]); + if (empty($result->notifications)) { + return; + } + foreach ($result->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); + $result = Item::insert($item); + Logger::debug('Got like', ['uid' => $uid, 'result' => $result]); + 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); + $result = Item::insert($item); + Logger::debug('Got repost', ['uid' => $uid, 'result' => $result]); + break; + + case 'follow': + $contact = bluesky_get_contact($notification->author, $uid, $uid); + Logger::debug('New follower', ['uid' => $uid, 'nick' => $contact['nick']]); + break; + + case 'mention': + $result = bluesky_process_post($notification, $uid); + Logger::debug('Got mention', ['uid' => $uid, 'result' => $result]); + break; + + case 'reply': + $result = bluesky_process_post($notification, $uid); + Logger::debug('Got reply', ['uid' => $uid, 'result' => $result]); + break; + + case 'quote': + $result = bluesky_process_post($notification, $uid); + Logger::debug('Got quote', ['uid' => $uid, 'result' => $result]); + break; + + default: + Logger::notice('Unhandled reason', ['reason' => $notification->reason]); + break; + } + } +} + function bluesky_process_post(stdClass $post, int $uid): int { $uri = bluesky_get_uri($post); @@ -643,23 +879,23 @@ function bluesky_process_post(stdClass $post, int $uid): int Logger::debug('Importing post', ['uid' => $uid, 'indexedAt' => $post->indexedAt, 'uri' => $post->uri, 'cid' => $post->cid]); - $item = bluesky_get_header($post, $uri, $uid); + $item = bluesky_get_header($post, $uri, $uid, $uid); $item = bluesky_get_content($item, $post->record, $uid); if (!empty($post->embed)) { - $item = bluesky_add_media($post->embed, $item); + $item = bluesky_add_media($post->embed, $item, $uid); } 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 { $parts = bluesky_get_uri_parts($uri); if (empty($post->author)) { return []; } - $contact = bluesky_get_contact($post->author, $uid); + $contact = bluesky_get_contact($post->author, $uid, $fetch_uid); $item = [ 'network' => Protocol::BLUESKY, 'uid' => $uid, @@ -716,21 +952,29 @@ function bluesky_get_text(stdClass $record, int $uid): string $linktext = substr($text, $facet->index->byteStart, $facet->index->byteEnd - $facet->index->byteStart); $suffix = substr($text, $facet->index->byteEnd); - $url = ''; - + $url = ''; + $type = '$type'; foreach ($facet->features as $feature) { - if (!empty($feature->uri)) { - $url = $feature->uri; - } - if (!empty($feature->did)) { - $contact = Contact::selectFirst(['id'], ['nurl' => $feature->did, 'uid' => [0, $uid]]); - if (!empty($contact['id'])) { - $url = DI::baseUrl() . '/contact/' . $contact['id']; - if (substr($linktext, 0, 1) == '@') { - $prefix .= '@'; - $linktext = substr($linktext, 1); - } - } + + switch ($feature->$type) { + case 'app.bsky.richtext.facet#link': + $url = $feature->uri; + break; + + case 'app.bsky.richtext.facet#mention': + $contact = Contact::selectFirst(['id'], ['nurl' => $feature->did, 'uid' => [0, $uid]]); + 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)) { @@ -740,50 +984,86 @@ function bluesky_get_text(stdClass $record, int $uid): string return $text; } -function bluesky_add_media(stdClass $embed, array $item): array +function bluesky_add_media(stdClass $embed, array $item, int $fetch_uid): 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); - if (!empty($shared)) { - $shared = bluesky_get_content($shared, $embed->record->value, $item['uid']); + break; - if (!empty($embed->record->embeds)) { - foreach ($embed->record->embeds as $single) { - $shared = bluesky_add_media($single, $shared); + 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)) { + $shared = bluesky_get_header($embed->record, $uri, 0, $fetch_uid); + if (!empty($shared)) { + $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, $fetch_uid); + } } + $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); + if (!empty($shared)) { + $shared = bluesky_get_content($shared, $embed->record->record->value, $item['uid']); + + if (!empty($embed->record->embeds)) { + foreach ($embed->record->record->embeds as $single) { + $shared = bluesky_add_media($single, $shared, $fetch_uid); + } + } + + if (!empty($embed->media)) { + bluesky_add_media($embed->media, $item, $fetch_uid); + } + + $id = Item::insert($shared); + $shared = Post::selectFirst(['uri-id'], ['id' => $id]); + } + } + if (!empty($shared)) { + $item['quote-uri-id'] = $shared['uri-id']; + } + break; + + default: + Logger::notice('Unhandled embed type', ['type' => $embed->$type, 'embed' => $embed]); + break; } return $item; } @@ -810,6 +1090,11 @@ function bluesky_get_uri_class(string $uri): ?stdClass $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; } @@ -831,7 +1116,7 @@ function bluesky_get_uri_parts(string $uri): ?stdClass return $class; } -function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $original = false): string +function bluesky_fetch_missing_post(string $uri, int $uid, int $causer): string { if (Post::exists(['uri' => $uri, 'uid' => [$uid, 0]])) { Logger::debug('Post exists', ['uri' => $uri]); @@ -844,12 +1129,8 @@ function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $or } Logger::debug('Fetch missing post', ['uri' => $uri]); - if (!$original) { - $class = bluesky_get_uri_class($uri); - $fetch_uri = $class->uri; - } else { - $fetch_uri = $uri; - } + $class = bluesky_get_uri_class($uri); + $fetch_uri = $class->uri; $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getPosts?uris=' . urlencode($fetch_uri), HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]); if (empty($data)) { @@ -862,7 +1143,7 @@ function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $or foreach ($data->posts as $post) { $uri = bluesky_get_uri($post); - $item = bluesky_get_header($post, $uri, $uid); + $item = bluesky_get_header($post, $uri, $uid, $uid); $item = bluesky_get_content($item, $post->record, $uid); $item['post-reason'] = Item::PR_FETCHED; @@ -872,7 +1153,7 @@ function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $or } if (!empty($post->embed)) { - $item = bluesky_add_media($post->embed, $item); + $item = bluesky_add_media($post->embed, $item, $uid); } $id = Item::insert($item); Logger::debug('Stored item', ['id' => $id, 'uri' => $uri]); @@ -881,52 +1162,55 @@ function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $or 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 = [ - 'alias' => BLUESKY_HOST . '/profile/' . $author->handle, - '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['alias'] != $contact['alias'] || $fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) { - Contact::update($fields, ['id' => $cid]); + Contact::update($public_fields, ['id' => $cid], true); + } + + if ($uid != 0) { + $condition = ['network' => Protocol::BLUESKY, 'uid' => $uid, 'url' => $author->did]; + + $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; } } - $condition['uid'] = 0; - - $contact = Contact::selectFirst([], $condition); - if (empty($contact)) { - $pcid = bluesky_insert_contact($author, 0); + 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 { - $pcid = $contact['id']; - if ($fields['alias'] != $contact['alias'] || $fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) { - Contact::update($fields, ['id' => $pcid]); - } + 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, @@ -943,23 +1227,19 @@ function bluesky_insert_contact(stdClass $author, int $uid) '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 (empty($data)) { - return; + if (!$update) { + Logger::debug('Got contact fields', ['uid' => $uid, 'url' => $fields['url']]); + return $fields; } - $fields = [ - 'alias' => BLUESKY_HOST . '/profile/' . $data->handle, - 'name' => $data->displayName, - 'nick' => $data->handle, - 'addr' => $data->handle, - 'updated' => DateTimeFormat::utcNow(DateTimeFormat::MYSQL), - ]; + $data = bluesky_get($uid, '/xrpc/app.bsky.actor.getProfile?actor=' . $author->did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]); + if (empty($data)) { + Logger::debug('Error fetching contact fields', ['uid' => $uid, 'url' => $fields['url']]); + return $fields; + } + + $fields['updated'] = DateTimeFormat::utcNow(DateTimeFormat::MYSQL); if (!empty($data->description)) { $fields['about'] = HTML::toBBCode($data->description); @@ -969,8 +1249,20 @@ function bluesky_update_contact(stdClass $author, int $uid, int $cid, int $pcid) $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 $handle): string @@ -1061,4 +1353,4 @@ function bluesky_get(int $uid, string $url, string $accept_content = HttpClientA } return json_decode($curlResult->getBody()); -} +} \ No newline at end of file diff --git a/tumblr/tumblr.php b/tumblr/tumblr.php index 1eae01bf..09b5af86 100644 --- a/tumblr/tumblr.php +++ b/tumblr/tumblr.php @@ -18,11 +18,11 @@ 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\Model\Item; -use Friendica\Model\ItemURI; use Friendica\Model\Photo; use Friendica\Model\Post; use Friendica\Model\Tag; @@ -79,7 +79,7 @@ function tumblr_check_item_notification(array &$notification_data) return; } - $own_user = Contact::selectFirst(['url', 'alias'], ['uid' => $notification_data['uid'], 'poll' => 'tumblr::' . $page]); + $own_user = Contact::selectFirst(['url', 'alias'], ['network' => Protocol::TUMBLR, 'uid' => [0, $notification_data['uid']], 'poll' => 'tumblr::' . $page]); if ($own_user) { $notification_data['profiles'][] = $own_user['url']; $notification_data['profiles'][] = $own_user['alias']; @@ -444,6 +444,18 @@ function tumblr_cron() Logger::notice('importing timeline - done', ['user' => $pconfig['uid']]); } + $last_clean = DI::keyValue()->get('tumblr_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::TUMBLR, 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('tumblr_last_clean', time()); + Logger::notice('Contact cleanup done'); + } + Logger::notice('cron_end'); DI::keyValue()->set('tumblr_last_poll', time()); @@ -1042,39 +1054,58 @@ function tumblr_get_type_replacement(array $data, string $plink): string */ function tumblr_get_contact(stdClass $blog, int $uid): array { - $condition = ['network' => Protocol::TUMBLR, 'uid' => $uid, 'poll' => 'tumblr::' . $blog->uuid]; - $contact = Contact::selectFirst([], $condition); - if (!empty($contact) && (strtotime($contact['updated']) >= $blog->updated)) { - return $contact; - } + $condition = ['network' => Protocol::TUMBLR, 'uid' => 0, 'poll' => 'tumblr::' . $blog->uuid]; + $contact = Contact::selectFirst(['id', 'updated'], $condition); + + $update = empty($contact) || $contact['updated'] < DateTimeFormat::utc('now -24 hours'); + + $public_fields = $fields = tumblr_get_contact_fields($blog, $uid, $update); + + $avatar = $fields['avatar'] ?? ''; + unset($fields['avatar']); + unset($public_fields['avatar']); + + $public_fields['uid'] = 0; + $public_fields['rel'] = Contact::NOTHING; + if (empty($contact)) { - $cid = tumblr_insert_contact($blog, $uid); + $cid = Contact::insert($public_fields); } else { $cid = $contact['id']; + Contact::update($public_fields, ['id' => $cid], true); } - $condition['uid'] = 0; + if ($uid != 0) { + $condition = ['network' => Protocol::TUMBLR, 'uid' => $uid, 'poll' => 'tumblr::' . $blog->uuid]; - $contact = Contact::selectFirst([], $condition); - if (empty($contact)) { - $pcid = tumblr_insert_contact($blog, 0); + $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 { - $pcid = $contact['id']; + Logger::debug('Get public contact', ['id' => $cid, 'uid' => $uid, 'update' => $update]); } - tumblr_update_contact($blog, $uid, $cid, $pcid); + if (!empty($avatar)) { + Contact::updateAvatar($cid, $avatar); + } return Contact::getById($cid); } -/** - * Create a new contact - * - * @param stdClass $blog - * @param integer $uid - * @return void - */ -function tumblr_insert_contact(stdClass $blog, int $uid) +function tumblr_get_contact_fields(stdClass $blog, int $uid, bool $update): array { $baseurl = 'https://tumblr.com'; $url = $baseurl . '/' . $blog->name; @@ -1098,63 +1129,37 @@ function tumblr_insert_contact(stdClass $blog, int $uid) 'about' => HTML::toBBCode($blog->description), 'updated' => date(DateTimeFormat::MYSQL, $blog->updated) ]; - return Contact::insert($fields); -} -/** - * Updates the given contact for the given user and proviced contact ids - * - * @param stdClass $blog - * @param integer $uid - * @param integer $cid - * @param integer $pcid - * @return void - */ -function tumblr_update_contact(stdClass $blog, int $uid, int $cid, int $pcid) -{ + if (!$update) { + Logger::debug('Got contact fields', ['uid' => $uid, 'url' => $fields['url']]); + return $fields; + } + $info = tumblr_get($uid, 'blog/' . $blog->uuid . '/info'); if ($info->meta->status > 399) { - Logger::notice('Error fetching dashboard', ['meta' => $info->meta, 'response' => $info->response, 'errors' => $info->errors]); - return; + Logger::notice('Error fetching blog info', ['meta' => $info->meta, 'response' => $info->response, 'errors' => $info->errors]); + return $fields; } $avatar = $info->response->blog->avatar; if (!empty($avatar)) { - Contact::updateAvatar($cid, $avatar[0]->url); + $fields['avatar'] = $avatar[0]->url; } - $baseurl = 'https://tumblr.com'; - $url = $baseurl . '/' . $info->response->blog->name; - if ($info->response->blog->followed && $info->response->blog->subscribed) { - $rel = Contact::FRIEND; + $fields['rel'] = Contact::FRIEND; } elseif ($info->response->blog->followed && !$info->response->blog->subscribed) { - $rel = Contact::SHARING; + $fields['rel'] = Contact::SHARING; } elseif (!$info->response->blog->followed && $info->response->blog->subscribed) { - $rel = Contact::FOLLOWER; + $fields['rel'] = Contact::FOLLOWER; } else { - $rel = Contact::NOTHING; + $fields['rel'] = Contact::NOTHING; } - $uri_id = ItemURI::getIdByURI($url); - $fields = [ - 'url' => $url, - 'nurl' => Strings::normaliseLink($url), - 'uri-id' => $uri_id, - 'alias' => $info->response->blog->url, - 'name' => $info->response->blog->title ?: $info->response->blog->name, - 'nick' => $info->response->blog->name, - 'addr' => $info->response->blog->name . '@tumblr.com', - 'about' => HTML::toBBCode($info->response->blog->description), - 'updated' => date(DateTimeFormat::MYSQL, $info->response->blog->updated), - 'header' => $info->response->blog->theme->header_image_focused, - 'rel' => $rel, - ]; + $fields['header'] = $info->response->blog->theme->header_image_focused; - Contact::update($fields, ['id' => $cid]); - - $fields['rel'] = Contact::NOTHING; - Contact::update($fields, ['id' => $pcid]); + Logger::debug('Got updated contact fields', ['uid' => $uid, 'url' => $fields['url']]); + return $fields; } /**