3 * Name: Bluesky Connector
4 * Description: Post to Bluesky
6 * Author: Michael Vogel <https://pirati.ca/profile/heluecht>
9 * - Links in outgoing comments
12 * Possibly not possible:
13 * - only fetch new posts
15 * Currently not possible, due to limitations in Friendica
16 * - mute contacts https://atproto.com/lexicons/app-bsky-graph#appbskygraphmuteactor
17 * - unmute contacts https://atproto.com/lexicons/app-bsky-graph#appbskygraphunmuteactor
20 use Friendica\Content\Text\BBCode;
21 use Friendica\Content\Text\HTML;
22 use Friendica\Content\Text\Plaintext;
23 use Friendica\Core\Config\Util\ConfigFileManager;
24 use Friendica\Core\Hook;
25 use Friendica\Core\Logger;
26 use Friendica\Core\Protocol;
27 use Friendica\Core\Renderer;
28 use Friendica\Core\Worker;
29 use Friendica\Database\DBA;
31 use Friendica\Model\Contact;
32 use Friendica\Model\Item;
33 use Friendica\Model\ItemURI;
34 use Friendica\Model\Photo;
35 use Friendica\Model\Post;
36 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
37 use Friendica\Network\HTTPClient\Client\HttpClientOptions;
38 use Friendica\Protocol\Activity;
39 use Friendica\Util\DateTimeFormat;
40 use Friendica\Util\Strings;
42 const BLUESKY_DEFAULT_POLL_INTERVAL = 10; // given in minutes
43 const BLUESKY_HOST = 'https://bsky.app'; // Hard wired until Bluesky will run on multiple systems
45 function bluesky_install()
47 Hook::register('load_config', __FILE__, 'bluesky_load_config');
48 Hook::register('hook_fork', __FILE__, 'bluesky_hook_fork');
49 Hook::register('post_local', __FILE__, 'bluesky_post_local');
50 Hook::register('notifier_normal', __FILE__, 'bluesky_send');
51 Hook::register('jot_networks', __FILE__, 'bluesky_jot_nets');
52 Hook::register('connector_settings', __FILE__, 'bluesky_settings');
53 Hook::register('connector_settings_post', __FILE__, 'bluesky_settings_post');
54 Hook::register('cron', __FILE__, 'bluesky_cron');
55 Hook::register('support_follow', __FILE__, 'bluesky_support_follow');
56 Hook::register('support_probe', __FILE__, 'bluesky_support_probe');
57 Hook::register('follow', __FILE__, 'bluesky_follow');
58 Hook::register('unfollow', __FILE__, 'bluesky_unfollow');
59 Hook::register('block', __FILE__, 'bluesky_block');
60 Hook::register('unblock', __FILE__, 'bluesky_unblock');
61 Hook::register('check_item_notification', __FILE__, 'bluesky_check_item_notification');
62 Hook::register('probe_detect', __FILE__, 'bluesky_probe_detect');
63 Hook::register('item_by_link', __FILE__, 'bluesky_item_by_link');
66 function bluesky_load_config(ConfigFileManager $loader)
68 DI::app()->getConfigCache()->load($loader->loadAddonConfig('bluesky'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
71 function bluesky_check_item_notification(array &$notification_data)
73 $did = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'did');
76 $notification_data['profiles'][] = $did;
80 function bluesky_probe_detect(array &$hookData)
82 // Don't overwrite an existing result
83 if (isset($hookData['result'])) {
87 // Avoid a lookup for the wrong network
88 if (!in_array($hookData['network'], ['', Protocol::BLUESKY])) {
92 $pconfig = DBA::selectFirst('pconfig', ['uid'], ["`cat` = ? AND `k` = ? AND `v` != ?", 'bluesky', 'access_token', '']);
93 if (empty($pconfig['uid'])) {
97 if (parse_url($hookData['uri'], PHP_URL_SCHEME) == 'did') {
98 $did = $hookData['uri'];
99 } elseif (preg_match('#^' . BLUESKY_HOST . '/profile/(.+)#', $hookData['uri'], $matches)) {
100 $did = bluesky_get_did($pconfig['uid'], $matches[1]);
108 $token = bluesky_get_token($pconfig['uid']);
113 $data = bluesky_get($pconfig['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]);
118 $hookData['result'] = bluesky_get_contact($data, 0, $pconfig['uid']);
120 // Authoritative probe should set the result even if the probe was unsuccessful
121 if ($hookData['network'] == Protocol::BLUESKY && empty($hookData['result'])) {
122 $hookData['result'] = [];
126 function bluesky_item_by_link(array &$hookData)
128 // Don't overwrite an existing result
129 if (isset($hookData['item_id'])) {
133 $token = bluesky_get_token($hookData['uid']);
138 if (!preg_match('#^' . BLUESKY_HOST . '/profile/(.+)/post/(.+)#', $hookData['uri'], $matches)) {
142 $did = bluesky_get_did($hookData['uid'], $matches[1]);
147 Logger::debug('Found bluesky post', ['url' => $hookData['uri'], 'handle' => $matches[1], 'did' => $did, 'cid' => $matches[2]]);
149 $uri = 'at://' . $did . '/app.bsky.feed.post/' . $matches[2];
151 $uri = bluesky_fetch_missing_post($uri, $hookData['uid'], 0);
152 Logger::debug('Got post', ['profile' => $matches[1], 'cid' => $matches[2], 'result' => $uri]);
154 $item = Post::selectFirst(['id'], ['uri' => $uri, 'uid' => $hookData['uid']]);
155 if (!empty($item['id'])) {
156 $hookData['item_id'] = $item['id'];
161 function bluesky_support_follow(array &$data)
163 if ($data['protocol'] == Protocol::BLUESKY) {
164 $data['result'] = true;
168 function bluesky_support_probe(array &$data)
170 if ($data['protocol'] == Protocol::BLUESKY) {
171 $data['result'] = true;
175 function bluesky_follow(array &$hook_data)
177 $token = bluesky_get_token($hook_data['uid']);
182 Logger::debug('Check if contact is bluesky', ['data' => $hook_data]);
183 $contact = DBA::selectFirst('contact', [], ['network' => Protocol::BLUESKY, 'url' => $hook_data['url'], 'uid' => [0, $hook_data['uid']]]);
184 if (empty($contact)) {
189 'subject' => $contact['url'],
190 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
191 '$type' => 'app.bsky.graph.follow'
195 'collection' => 'app.bsky.graph.follow',
196 'repo' => DI::pConfig()->get($hook_data['uid'], 'bluesky', 'did'),
200 $activity = bluesky_post($hook_data['uid'], '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
201 if (!empty($activity->uri)) {
202 $hook_data['contact'] = $contact;
203 Logger::debug('Successfully start following', ['url' => $contact['url'], 'uri' => $activity->uri]);
207 function bluesky_unfollow(array &$hook_data)
209 $token = bluesky_get_token($hook_data['uid']);
214 if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
218 $data = bluesky_get($hook_data['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $hook_data['contact']['url'], HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]);
219 if (empty($data->viewer) || empty($data->viewer->following)) {
223 bluesky_delete_post($data->viewer->following, $hook_data['uid']);
225 $hook_data['result'] = true;
228 function bluesky_block(array &$hook_data)
230 $token = bluesky_get_token($hook_data['uid']);
235 Logger::debug('Check if contact is bluesky', ['data' => $hook_data]);
236 $contact = DBA::selectFirst('contact', [], ['network' => Protocol::BLUESKY, 'url' => $hook_data['url'], 'uid' => [0, $hook_data['uid']]]);
237 if (empty($contact)) {
242 'subject' => $contact['url'],
243 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
244 '$type' => 'app.bsky.graph.block'
248 'collection' => 'app.bsky.graph.block',
249 'repo' => DI::pConfig()->get($hook_data['uid'], 'bluesky', 'did'),
253 $activity = bluesky_post($hook_data['uid'], '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
254 if (!empty($activity->uri)) {
255 $cdata = Contact::getPublicAndUserContactID($hook_data['contact']['id'], $hook_data['uid']);
256 if (!empty($cdata['user'])) {
257 Contact::remove($cdata['user']);
259 Logger::debug('Successfully blocked contact', ['url' => $hook_data['contact']['url'], 'uri' => $activity->uri]);
263 function bluesky_unblock(array &$hook_data)
265 $token = bluesky_get_token($hook_data['uid']);
270 if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
274 $data = bluesky_get($hook_data['uid'], '/xrpc/app.bsky.actor.getProfile?actor=' . $hook_data['contact']['url'], HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . $token]]]);
275 if (empty($data->viewer) || empty($data->viewer->blocking)) {
279 bluesky_delete_post($data->viewer->blocking, $hook_data['uid']);
281 $hook_data['result'] = true;
284 function bluesky_settings(array &$data)
286 if (!DI::userSession()->getLocalUserId()) {
290 $enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false;
291 $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false;
292 $host = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host') ?: 'https://bsky.social';
293 $handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
294 $did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
295 $token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token');
296 $import = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false;
298 $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.');
300 $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/bluesky/');
301 $html = Renderer::replaceMacros($t, [
302 '$enable' => ['bluesky', DI::l10n()->t('Enable Bluesky Post Addon'), $enabled],
303 '$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post to Bluesky by default'), $def_enabled],
304 '$import' => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import],
305 '$host' => ['bluesky_host', DI::l10n()->t('Bluesky host'), $host, '', '', 'readonly'],
306 '$handle' => ['bluesky_handle', DI::l10n()->t('Bluesky handle'), $handle],
307 '$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'],
308 '$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.")],
313 'connector' => 'bluesky',
314 'title' => DI::l10n()->t('Bluesky Import/Export'),
315 'image' => 'images/bluesky.jpg',
316 'enabled' => $enabled,
321 function bluesky_settings_post(array &$b)
323 if (empty($_POST['bluesky-submit'])) {
327 $old_host = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host');
328 $old_handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
329 $old_did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
331 $host = $_POST['bluesky_host'];
332 $handle = $_POST['bluesky_handle'];
334 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'post', intval($_POST['bluesky']));
335 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default', intval($_POST['bluesky_bydefault']));
336 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'host', $host);
337 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'handle', $handle);
338 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import', intval($_POST['bluesky_import']));
340 if (!empty($host) && !empty($handle)) {
341 if (empty($old_did) || $old_host != $host || $old_handle != $handle) {
342 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'did', bluesky_get_did(DI::userSession()->getLocalUserId(), DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle')));
345 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
348 if (!empty($_POST['bluesky_password'])) {
349 bluesky_create_token(DI::userSession()->getLocalUserId(), $_POST['bluesky_password']);
353 function bluesky_jot_nets(array &$jotnets_fields)
355 if (!DI::userSession()->getLocalUserId()) {
359 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post')) {
360 $jotnets_fields[] = [
361 'type' => 'checkbox',
364 DI::l10n()->t('Post to Bluesky'),
365 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default')
371 function bluesky_cron()
373 $last = DI::keyValue()->get('bluesky_last_poll');
375 $poll_interval = intval(DI::config()->get('bluesky', 'poll_interval'));
376 if (!$poll_interval) {
377 $poll_interval = BLUESKY_DEFAULT_POLL_INTERVAL;
381 $next = $last + ($poll_interval * 60);
382 if ($next > time()) {
383 Logger::notice('poll interval not reached');
387 Logger::notice('cron_start');
389 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
390 if ($abandon_days < 1) {
394 $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
396 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'bluesky', 'k' => 'import', 'v' => true]);
397 foreach ($pconfigs as $pconfig) {
398 if ($abandon_days != 0) {
399 if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $pconfig['uid'], $abandon_limit])) {
400 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $pconfig['uid']]);
405 Logger::notice('importing timeline - start', ['user' => $pconfig['uid']]);
406 bluesky_fetch_timeline($pconfig['uid']);
407 Logger::notice('importing timeline - done', ['user' => $pconfig['uid']]);
408 Logger::notice('importing notifications - start', ['user' => $pconfig['uid']]);
409 bluesky_fetch_notifications($pconfig['uid']);
410 Logger::notice('importing notifications - done', ['user' => $pconfig['uid']]);
413 $last_clean = DI::keyValue()->get('bluesky_last_clean');
414 if (empty($last_clean) || ($last_clean + 86400 < time())) {
415 Logger::notice('Start contact cleanup');
416 $contacts = DBA::select('account-user-view', ['id', 'pid'], ["`network` = ? AND `uid` != ? AND `rel` = ?", Protocol::BLUESKY, 0, Contact::NOTHING]);
417 while ($contact = DBA::fetch($contacts)) {
418 Worker::add(Worker::PRIORITY_LOW, 'MergeContact', $contact['pid'], $contact['id'], 0);
420 DBA::close($contacts);
421 DI::keyValue()->set('bluesky_last_clean', time());
422 Logger::notice('Contact cleanup done');
425 Logger::notice('cron_end');
427 DI::keyValue()->set('bluesky_last_poll', time());
430 function bluesky_hook_fork(array &$b)
432 if ($b['name'] != 'notifier_normal') {
438 if (($post['created'] !== $post['edited']) && !$post['deleted']) {
439 DI::logger()->info('Editing is not supported by the addon');
440 $b['execute'] = false;
444 if (DI::pConfig()->get($post['uid'], 'bluesky', 'import')) {
445 // Don't post if it isn't a reply to a bluesky post
446 if (($post['parent'] != $post['id']) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::BLUESKY])) {
447 Logger::notice('No bluesky parent found', ['item' => $post['id']]);
448 $b['execute'] = false;
451 } elseif (!strstr($post['postopts'] ?? '', 'bluesky') || ($post['parent'] != $post['id']) || $post['private']) {
452 DI::logger()->info('Activities are never exported when we don\'t import the bluesky timeline', ['uid' => $post['uid']]);
453 $b['execute'] = false;
458 function bluesky_post_local(array &$b)
464 if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
468 if ($b['private'] || $b['parent']) {
472 $bluesky_post = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post'));
473 $bluesky_enable = (($bluesky_post && !empty($_REQUEST['bluesky_enable'])) ? intval($_REQUEST['bluesky_enable']) : 0);
475 // if API is used, default to the chosen settings
476 if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default'))) {
480 if (!$bluesky_enable) {
484 if (strlen($b['postopts'])) {
485 $b['postopts'] .= ',';
488 $b['postopts'] .= 'bluesky';
491 function bluesky_send(array &$b)
493 if (($b['created'] !== $b['edited']) && !$b['deleted']) {
497 if ($b['gravity'] != Item::GRAVITY_PARENT) {
498 Logger::debug('Got comment', ['item' => $b]);
501 $uri = bluesky_get_uri_class($b['uri']);
503 Logger::debug('Not a bluesky post', ['uri' => $b['uri']]);
506 bluesky_delete_post($b['uri'], $b['uid']);
510 $root = bluesky_get_uri_class($b['parent-uri']);
511 $parent = bluesky_get_uri_class($b['thr-parent']);
513 if (empty($root) || empty($parent)) {
514 Logger::debug('No bluesky post', ['parent' => $b['parent'], 'thr-parent' => $b['thr-parent']]);
518 if ($b['gravity'] == Item::GRAVITY_COMMENT) {
519 Logger::debug('Posting comment', ['root' => $root, 'parent' => $parent]);
520 bluesky_create_post($b, $root, $parent);
522 } elseif (in_array($b['verb'], [Activity::LIKE, Activity::ANNOUNCE])) {
523 bluesky_create_activity($b, $parent);
526 } elseif ($b['private'] || !strstr($b['postopts'], 'bluesky')) {
530 bluesky_create_post($b);
533 function bluesky_create_activity(array $item, stdClass $parent = null)
536 $token = bluesky_get_token($uid);
541 $did = DI::pConfig()->get($uid, 'bluesky', 'did');
543 if ($item['verb'] == Activity::LIKE) {
545 'subject' => $parent,
546 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
547 '$type' => 'app.bsky.feed.like'
551 'collection' => 'app.bsky.feed.like',
555 } elseif ($item['verb'] == Activity::ANNOUNCE) {
557 'subject' => $parent,
558 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
559 '$type' => 'app.bsky.feed.repost'
563 'collection' => 'app.bsky.feed.repost',
569 $activity = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
570 if (empty($activity)) {
573 Logger::debug('Activity done', ['return' => $activity]);
574 $uri = bluesky_get_uri($activity);
575 Item::update(['extid' => $uri], ['id' => $item['id']]);
576 Logger::debug('Set extid', ['id' => $item['id'], 'extid' => $activity]);
579 function bluesky_create_post(array $item, stdClass $root = null, stdClass $parent = null)
582 $token = bluesky_get_token($uid);
587 $did = DI::pConfig()->get($uid, 'bluesky', 'did');
588 $urls = bluesky_get_urls($item['body']);
590 $msg = Plaintext::getPost($item, 300, false, BBCode::CONNECTORS);
591 foreach ($msg['parts'] as $key => $part) {
593 $facets = bluesky_get_facets($part, $urls);
596 'text' => $facets['body'],
597 'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
598 '$type' => 'app.bsky.feed.post'
601 if (!empty($facets['facets'])) {
602 $record['facets'] = $facets['facets'];
606 $record['reply'] = ['root' => $root, 'parent' => $parent];
609 if ($key == count($msg['parts']) - 1) {
610 $record = bluesky_add_embed($uid, $msg, $record);
614 'collection' => 'app.bsky.feed.post',
619 $parent = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
620 if (empty($parent)) {
623 Logger::debug('Posting done', ['return' => $parent]);
627 if (($key == 0) && ($item['gravity'] != Item::GRAVITY_PARENT)) {
628 $uri = bluesky_get_uri($parent);
629 Item::update(['extid' => $uri], ['id' => $item['id']]);
630 Logger::debug('Set extid', ['id' => $item['id'], 'extid' => $uri]);
635 function bluesky_get_urls(string $body): array
637 // Remove all hashtags and mentions
638 $body = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '', $body);
642 // Search for pure links
643 if (preg_match_all("/\[url\](https?:.*?)\[\/url\]/ism", $body, $matches)) {
644 foreach ($matches[1] as $url) {
649 // Search for links with descriptions
650 if (preg_match_all("/\[url\=(https?:.*?)\].*?\[\/url\]/ism", $body, $matches)) {
651 foreach ($matches[1] as $url) {
658 function bluesky_get_facets(string $body, array $urls): array
662 foreach ($urls as $url) {
663 $pos = strpos($body, $url);
664 if ($pos === false) {
668 $prefix = substr($body, 0, $pos);
672 $linktext = Strings::getStyledURL($url);
673 $body = $prefix . $linktext . substr($body, $pos + strlen($url));
675 $facet = new stdClass;
676 $facet->index = new stdClass;
677 $facet->index->byteEnd = $pos + strlen($linktext);
678 $facet->index->byteStart = $pos;
680 $feature = new stdClass;
681 $feature->uri = $url;
683 $feature->$type = 'app.bsky.richtext.facet#link';
685 $facet->features = [$feature];
689 return ['facets' => $facets, 'body' => $body];
692 function bluesky_add_embed(int $uid, array $msg, array $record): array
694 if (($msg['type'] != 'link') && !empty($msg['images'])) {
696 foreach ($msg['images'] as $image) {
697 $photo = Photo::selectFirst(['resource-id'], ['id' => $image['id']]);
698 $photo = Photo::selectFirst([], ["`resource-id` = ? AND `scale` > ?", $photo['resource-id'], 0], ['order' => ['scale']]);
699 $blob = bluesky_upload_blob($uid, $photo);
700 if (!empty($blob) && count($images) < 4) {
701 $images[] = ['alt' => $image['description'] ?? '', 'image' => $blob];
704 if (!empty($images)) {
705 $record['embed'] = ['$type' => 'app.bsky.embed.images', 'images' => $images];
707 } elseif ($msg['type'] == 'link') {
709 '$type' => 'app.bsky.embed.external',
711 'uri' => $msg['url'],
712 'title' => $msg['title'],
713 'description' => $msg['description'],
716 if (!empty($msg['image'])) {
717 $photo = Photo::createPhotoForExternalResource($msg['image']);
718 $blob = bluesky_upload_blob($uid, $photo);
720 $record['embed']['external']['thumb'] = $blob;
727 function bluesky_upload_blob(int $uid, array $photo): ?stdClass
729 $content = Photo::getImageForPhoto($photo);
730 $data = bluesky_post($uid, '/xrpc/com.atproto.repo.uploadBlob', $content, ['Content-type' => $photo['type'], 'Authorization' => ['Bearer ' . bluesky_get_token($uid)]]);
735 Logger::debug('Uploaded blob', ['return' => $data]);
739 function bluesky_delete_post(string $uri, int $uid)
741 $token = bluesky_get_token($uid);
742 $parts = bluesky_get_uri_parts($uri);
744 Logger::debug('No uri delected', ['uri' => $uri]);
747 bluesky_post($uid, '/xrpc/com.atproto.repo.deleteRecord', json_encode($parts), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
748 Logger::debug('Deleted', ['parts' => $parts]);
751 function bluesky_fetch_timeline(int $uid)
753 $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getTimeline', HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
758 if (empty($data->feed)) {
762 foreach (array_reverse($data->feed) as $entry) {
763 bluesky_process_post($entry->post, $uid);
764 if (!empty($entry->reason)) {
765 bluesky_process_reason($entry->reason, bluesky_get_uri($entry->post), $uid);
769 // @todo Support paging
770 // [cursor] => 1684670516000::bafyreidq3ilwslmlx72jf5vrk367xcc63s6lrhzlyup2bi3zwcvso6w2vi
773 function bluesky_process_reason(stdClass $reason, string $uri, int $uid)
776 if ($reason->$type != 'app.bsky.feed.defs#reasonRepost') {
780 $contact = bluesky_get_contact($reason->by, $uid, $uid);
783 'network' => Protocol::BLUESKY,
786 'uri' => $reason->by->did . '/app.bsky.feed.repost/' . $reason->indexedAt,
787 'private' => Item::UNLISTED,
788 'verb' => Activity::POST,
789 'contact-id' => $contact['id'],
790 'author-name' => $contact['name'],
791 'author-link' => $contact['url'],
792 'author-avatar' => $contact['avatar'],
793 'verb' => Activity::ANNOUNCE,
794 'body' => Activity::ANNOUNCE,
795 'gravity' => Item::GRAVITY_ACTIVITY,
796 'object-type' => Activity\ObjectType::NOTE,
797 'thr-parent' => $uri,
800 if (Post::exists(['uri' => $item['uri'], 'uid' => $uid])) {
804 $item['owner-name'] = $item['author-name'];
805 $item['owner-link'] = $item['author-link'];
806 $item['owner-avatar'] = $item['author-avatar'];
807 if (Item::insert($item)) {
808 $cdata = Contact::getPublicAndUserContactID($contact['id'], $uid);
809 Item::update(['post-reason' => Item::PR_ANNOUNCEMENT, 'causer-id' => $cdata['public']], ['uri' => $uri, 'uid' => $uid]);
813 function bluesky_fetch_notifications(int $uid)
815 $result = bluesky_get($uid, '/xrpc/app.bsky.notification.listNotifications', HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
816 if (empty($result->notifications)) {
819 foreach ($result->notifications as $notification) {
820 $uri = bluesky_get_uri($notification);
821 if (Post::exists(['uri' => $uri, 'uid' => $uid]) || Post::exists(['extid' => $uri, 'uid' => $uid])) {
822 Logger::debug('Notification already processed', ['uid' => $uid, 'reason' => $notification->reason, 'uri' => $uri, 'indexedAt' => $notification->indexedAt]);
825 Logger::debug('Process notification', ['uid' => $uid, 'reason' => $notification->reason, 'uri' => $uri, 'indexedAt' => $notification->indexedAt]);
826 switch ($notification->reason) {
828 $item = bluesky_get_header($notification, $uri, $uid, $uid);
829 $item['gravity'] = Item::GRAVITY_ACTIVITY;
830 $item['body'] = $item['verb'] = Activity::LIKE;
831 $item['thr-parent'] = bluesky_get_uri($notification->record->subject);
832 $result = Item::insert($item);
833 Logger::debug('Got like', ['uid' => $uid, 'result' => $result]);
837 $item = bluesky_get_header($notification, $uri, $uid, $uid);
838 $item['gravity'] = Item::GRAVITY_ACTIVITY;
839 $item['body'] = $item['verb'] = Activity::ANNOUNCE;
840 $item['thr-parent'] = bluesky_get_uri($notification->record->subject);
841 $result = Item::insert($item);
842 Logger::debug('Got repost', ['uid' => $uid, 'result' => $result]);
846 $contact = bluesky_get_contact($notification->author, $uid, $uid);
847 Logger::debug('New follower', ['uid' => $uid, 'nick' => $contact['nick']]);
851 $result = bluesky_process_post($notification, $uid);
852 Logger::debug('Got mention', ['uid' => $uid, 'result' => $result]);
856 $result = bluesky_process_post($notification, $uid);
857 Logger::debug('Got reply', ['uid' => $uid, 'result' => $result]);
861 $result = bluesky_process_post($notification, $uid);
862 Logger::debug('Got quote', ['uid' => $uid, 'result' => $result]);
866 Logger::notice('Unhandled reason', ['reason' => $notification->reason]);
872 function bluesky_process_post(stdClass $post, int $uid): int
874 $uri = bluesky_get_uri($post);
876 if (Post::exists(['uri' => $uri, 'uid' => $uid]) || Post::exists(['extid' => $uri, 'uid' => $uid])) {
880 Logger::debug('Importing post', ['uid' => $uid, 'indexedAt' => $post->indexedAt, 'uri' => $post->uri, 'cid' => $post->cid]);
882 $item = bluesky_get_header($post, $uri, $uid, $uid);
884 $item = bluesky_get_content($item, $post->record, $uid);
886 if (!empty($post->embed)) {
887 $item = bluesky_add_media($post->embed, $item, $uid);
889 return item::insert($item);
892 function bluesky_get_header(stdClass $post, string $uri, int $uid, int $fetch_uid): array
894 $parts = bluesky_get_uri_parts($uri);
895 if (empty($post->author)) {
898 $contact = bluesky_get_contact($post->author, $uid, $fetch_uid);
900 'network' => Protocol::BLUESKY,
904 'guid' => $post->cid,
905 'private' => Item::UNLISTED,
906 'verb' => Activity::POST,
907 'contact-id' => $contact['id'],
908 'author-name' => $contact['name'],
909 'author-link' => $contact['url'],
910 'author-avatar' => $contact['avatar'],
911 'plink' => $contact['alias'] . '/post/' . $parts->rkey,
914 $item['uri-id'] = ItemURI::getIdByURI($uri);
915 $item['owner-name'] = $item['author-name'];
916 $item['owner-link'] = $item['author-link'];
917 $item['owner-avatar'] = $item['author-avatar'];
922 function bluesky_get_content(array $item, stdClass $record, int $uid): array
924 if (!empty($record->reply)) {
925 $item['parent-uri'] = bluesky_get_uri($record->reply->root);
926 $item['parent-uri'] = bluesky_fetch_missing_post($item['parent-uri'], $uid, $item['contact-id']);
927 $item['thr-parent'] = bluesky_get_uri($record->reply->parent);
928 $item['thr-parent'] = bluesky_fetch_missing_post($item['thr-parent'], $uid, $item['contact-id']);
931 $item['body'] = bluesky_get_text($record, $uid);
932 $item['created'] = DateTimeFormat::utc($record->createdAt, DateTimeFormat::MYSQL);
936 function bluesky_get_text(stdClass $record, int $uid): string
938 $text = $record->text;
940 if (empty($record->facets)) {
945 foreach ($record->facets as $facet) {
946 $facets[$facet->index->byteStart] = $facet;
950 foreach ($facets as $facet) {
951 $prefix = substr($text, 0, $facet->index->byteStart);
952 $linktext = substr($text, $facet->index->byteStart, $facet->index->byteEnd - $facet->index->byteStart);
953 $suffix = substr($text, $facet->index->byteEnd);
957 foreach ($facet->features as $feature) {
959 switch ($feature->$type) {
960 case 'app.bsky.richtext.facet#link':
961 $url = $feature->uri;
964 case 'app.bsky.richtext.facet#mention':
965 $contact = Contact::selectFirst(['id'], ['nurl' => $feature->did, 'uid' => [0, $uid]]);
966 if (!empty($contact['id'])) {
967 $url = DI::baseUrl() . '/contact/' . $contact['id'];
968 if (substr($linktext, 0, 1) == '@') {
970 $linktext = substr($linktext, 1);
976 Logger::notice('Unhandled feature type', ['type' => $feature->$type, 'record' => $record]);
981 $text = $prefix . '[url=' . $url . ']' . $linktext . '[/url]' . $suffix;
987 function bluesky_add_media(stdClass $embed, array $item, int $fetch_uid): array
990 switch ($embed->$type) {
991 case 'app.bsky.embed.images#view':
992 foreach ($embed->images as $image) {
994 'uri-id' => $item['uri-id'],
995 'type' => Post\Media::IMAGE,
996 'url' => $image->fullsize,
997 'preview' => $image->thumb,
998 'description' => $image->alt,
1000 Post\Media::insert($media);
1004 case 'app.bsky.embed.external#view':
1006 'uri-id' => $item['uri-id'],
1007 'type' => Post\Media::HTML,
1008 'url' => $embed->external->uri,
1009 'name' => $embed->external->title,
1010 'description' => $embed->external->description,
1012 Post\Media::insert($media);
1015 case 'app.bsky.embed.record#view':
1016 $uri = bluesky_get_uri($embed->record);
1017 $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
1018 if (empty($shared)) {
1019 $shared = bluesky_get_header($embed->record, $uri, 0, $fetch_uid);
1020 if (!empty($shared)) {
1021 $shared = bluesky_get_content($shared, $embed->record->value, $item['uid']);
1023 if (!empty($embed->record->embeds)) {
1024 foreach ($embed->record->embeds as $single) {
1025 $shared = bluesky_add_media($single, $shared, $fetch_uid);
1028 $id = Item::insert($shared);
1029 $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
1032 if (!empty($shared)) {
1033 $item['quote-uri-id'] = $shared['uri-id'];
1037 case 'app.bsky.embed.recordWithMedia#view':
1038 $uri = bluesky_get_uri($embed->record->record);
1039 $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
1040 if (empty($shared)) {
1041 $shared = bluesky_get_header($embed->record->record, $uri, 0, $fetch_uid);
1042 if (!empty($shared)) {
1043 $shared = bluesky_get_content($shared, $embed->record->record->value, $item['uid']);
1045 if (!empty($embed->record->embeds)) {
1046 foreach ($embed->record->record->embeds as $single) {
1047 $shared = bluesky_add_media($single, $shared, $fetch_uid);
1051 if (!empty($embed->media)) {
1052 bluesky_add_media($embed->media, $item, $fetch_uid);
1055 $id = Item::insert($shared);
1056 $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
1059 if (!empty($shared)) {
1060 $item['quote-uri-id'] = $shared['uri-id'];
1065 Logger::notice('Unhandled embed type', ['type' => $embed->$type, 'embed' => $embed]);
1071 function bluesky_get_uri(stdClass $post): string
1073 return $post->uri . ':' . $post->cid;
1076 function bluesky_get_uri_class(string $uri): ?stdClass
1082 $elements = explode(':', $uri);
1083 if (empty($elements) || ($elements[0] != 'at')) {
1084 $post = Post::selectFirstPost(['extid'], ['uri' => $uri]);
1085 return bluesky_get_uri_class($post['extid'] ?? '');
1088 $class = new stdClass;
1090 $class->cid = array_pop($elements);
1091 $class->uri = implode(':', $elements);
1093 if ((substr_count($class->uri, '/') == 2) && (substr_count($class->cid, '/') == 2)) {
1094 $class->uri .= ':' . $class->cid;
1101 function bluesky_get_uri_parts(string $uri): ?stdClass
1103 $class = bluesky_get_uri_class($uri);
1104 if (empty($class)) {
1108 $parts = explode('/', substr($class->uri, 5));
1110 $class = new stdClass;
1112 $class->repo = $parts[0];
1113 $class->collection = $parts[1];
1114 $class->rkey = $parts[2];
1119 function bluesky_fetch_missing_post(string $uri, int $uid, int $causer): string
1121 if (Post::exists(['uri' => $uri, 'uid' => [$uid, 0]])) {
1122 Logger::debug('Post exists', ['uri' => $uri]);
1126 $reply = Post::selectFirst(['uri'], ['extid' => $uri, 'uid' => [$uid, 0]]);
1127 if (!empty($reply['uri'])) {
1128 return $reply['uri'];
1131 Logger::debug('Fetch missing post', ['uri' => $uri]);
1132 $class = bluesky_get_uri_class($uri);
1133 $fetch_uri = $class->uri;
1135 $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getPosts?uris=' . urlencode($fetch_uri), HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
1141 $cdata = Contact::getPublicAndUserContactID($causer, $uid);
1144 foreach ($data->posts as $post) {
1145 $uri = bluesky_get_uri($post);
1146 $item = bluesky_get_header($post, $uri, $uid, $uid);
1147 $item = bluesky_get_content($item, $post->record, $uid);
1149 $item['post-reason'] = Item::PR_FETCHED;
1151 if (!empty($cdata['public'])) {
1152 $item['causer-id'] = $cdata['public'];
1155 if (!empty($post->embed)) {
1156 $item = bluesky_add_media($post->embed, $item, $uid);
1158 $id = Item::insert($item);
1159 Logger::debug('Stored item', ['id' => $id, 'uri' => $uri]);
1165 function bluesky_get_contact(stdClass $author, int $uid, int $fetch_uid): array
1167 $condition = ['network' => Protocol::BLUESKY, 'uid' => 0, 'url' => $author->did];
1168 $contact = Contact::selectFirst(['id', 'updated'], $condition);
1170 $update = empty($contact) || $contact['updated'] < DateTimeFormat::utc('now -24 hours');
1172 $public_fields = $fields = bluesky_get_contact_fields($author, $fetch_uid, $update);
1174 $public_fields['uid'] = 0;
1175 $public_fields['rel'] = Contact::NOTHING;
1177 if (empty($contact)) {
1178 $cid = Contact::insert($public_fields);
1180 $cid = $contact['id'];
1181 Contact::update($public_fields, ['id' => $cid], true);
1185 $condition = ['network' => Protocol::BLUESKY, 'uid' => $uid, 'url' => $author->did];
1187 $contact = Contact::selectFirst(['id', 'rel', 'uid'], $condition);
1188 if (!isset($fields['rel']) && isset($contact['rel'])) {
1189 $fields['rel'] = $contact['rel'];
1190 } elseif (!isset($fields['rel'])) {
1191 $fields['rel'] = Contact::NOTHING;
1195 if (($uid != 0) && ($fields['rel'] != Contact::NOTHING)) {
1196 if (empty($contact)) {
1197 $cid = Contact::insert($fields);
1199 $cid = $contact['id'];
1200 Contact::update($fields, ['id' => $cid], true);
1202 Logger::debug('Get user contact', ['id' => $cid, 'uid' => $uid, 'update' => $update]);
1204 Logger::debug('Get public contact', ['id' => $cid, 'uid' => $uid, 'update' => $update]);
1206 if (!empty($author->avatar)) {
1207 Contact::updateAvatar($cid, $author->avatar);
1210 return Contact::getById($cid);
1213 function bluesky_get_contact_fields(stdClass $author, int $uid, bool $update): array
1217 'network' => Protocol::BLUESKY,
1221 'readonly' => false,
1223 'url' => $author->did,
1224 'nurl' => $author->did,
1225 'alias' => BLUESKY_HOST . '/profile/' . $author->handle,
1226 'name' => $author->displayName,
1227 'nick' => $author->handle,
1228 'addr' => $author->handle,
1232 Logger::debug('Got contact fields', ['uid' => $uid, 'url' => $fields['url']]);
1236 $data = bluesky_get($uid, '/xrpc/app.bsky.actor.getProfile?actor=' . $author->did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
1238 Logger::debug('Error fetching contact fields', ['uid' => $uid, 'url' => $fields['url']]);
1242 $fields['updated'] = DateTimeFormat::utcNow(DateTimeFormat::MYSQL);
1244 if (!empty($data->description)) {
1245 $fields['about'] = HTML::toBBCode($data->description);
1248 if (!empty($data->banner)) {
1249 $fields['header'] = $data->banner;
1252 if (!empty($data->viewer)) {
1253 if (!empty($data->viewer->following) && !empty($data->viewer->followedBy)) {
1254 $fields['rel'] = Contact::FRIEND;
1255 } elseif (!empty($data->viewer->following) && empty($data->viewer->followedBy)) {
1256 $fields['rel'] = Contact::SHARING;
1257 } elseif (empty($data->viewer->following) && !empty($data->viewer->followedBy)) {
1258 $fields['rel'] = Contact::FOLLOWER;
1260 $fields['rel'] = Contact::NOTHING;
1264 Logger::debug('Got updated contact fields', ['uid' => $uid, 'url' => $fields['url']]);
1268 function bluesky_get_did(int $uid, string $handle): string
1270 $data = bluesky_get($uid, '/xrpc/com.atproto.identity.resolveHandle?handle=' . $handle);
1274 Logger::debug('Got DID', ['return' => $data]);
1278 function bluesky_get_token(int $uid): string
1280 $token = DI::pConfig()->get($uid, 'bluesky', 'access_token');
1281 $created = DI::pConfig()->get($uid, 'bluesky', 'token_created');
1282 if (empty($token)) {
1286 if ($created + 300 < time()) {
1287 return bluesky_refresh_token($uid);
1292 function bluesky_refresh_token(int $uid): string
1294 $token = DI::pConfig()->get($uid, 'bluesky', 'refresh_token');
1296 $data = bluesky_post($uid, '/xrpc/com.atproto.server.refreshSession', '', ['Authorization' => ['Bearer ' . $token]]);
1301 Logger::debug('Refreshed token', ['return' => $data]);
1302 DI::pConfig()->set($uid, 'bluesky', 'access_token', $data->accessJwt);
1303 DI::pConfig()->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt);
1304 DI::pConfig()->set($uid, 'bluesky', 'token_created', time());
1305 return $data->accessJwt;
1308 function bluesky_create_token(int $uid, string $password): string
1310 $did = DI::pConfig()->get($uid, 'bluesky', 'did');
1312 $data = bluesky_post($uid, '/xrpc/com.atproto.server.createSession', json_encode(['identifier' => $did, 'password' => $password]), ['Content-type' => 'application/json']);
1317 Logger::debug('Created token', ['return' => $data]);
1318 DI::pConfig()->set($uid, 'bluesky', 'access_token', $data->accessJwt);
1319 DI::pConfig()->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt);
1320 DI::pConfig()->set($uid, 'bluesky', 'token_created', time());
1321 return $data->accessJwt;
1324 function bluesky_post(int $uid, string $url, string $params, array $headers): ?stdClass
1327 $curlResult = DI::httpClient()->post(DI::pConfig()->get($uid, 'bluesky', 'host') . $url, $params, $headers);
1328 } catch (\Exception $e) {
1329 Logger::notice('Exception on post', ['exception' => $e]);
1333 if (!$curlResult->isSuccess()) {
1334 Logger::notice('API Error', ['error' => json_decode($curlResult->getBody()) ?: $curlResult->getBody()]);
1338 return json_decode($curlResult->getBody());
1341 function bluesky_get(int $uid, string $url, string $accept_content = HttpClientAccept::DEFAULT, array $opts = []): ?stdClass
1344 $curlResult = DI::httpClient()->get(DI::pConfig()->get($uid, 'bluesky', 'host') . $url, $accept_content, $opts);
1345 } catch (\Exception $e) {
1346 Logger::notice('Exception on get', ['exception' => $e]);
1350 if (!$curlResult->isSuccess()) {
1351 Logger::notice('API Error', ['error' => json_decode($curlResult->getBody()) ?: $curlResult->getBody()]);
1355 return json_decode($curlResult->getBody());