]> git.mxchange.org Git - friendica-addons.git/blob - bluesky/bluesky.php
Merge pull request 'Bluesky: Improved import and export' (#1390) from heluecht/friend...
[friendica-addons.git] / bluesky / bluesky.php
1 <?php
2 /**
3  * Name: Bluesky Connector
4  * Description: Post to Bluesky
5  * Version: 1.1
6  * Author: Michael Vogel <https://pirati.ca/profile/heluecht>
7  *
8  * @todo
9  * Nice to have:
10  * - Probing for contacts
11  *
12  * Need more information:
13  * - only fetch new posts
14  * - detect contact relations
15  * - receive likes
16  * - follow contacts
17  * - unfollow contacts
18  *
19  * Possible but less important:
20  * - Block contacts
21  * - unblock contacts
22  * - mute contacts
23  * - unmute contacts
24  */
25
26 use Friendica\Content\Text\BBCode;
27 use Friendica\Content\Text\HTML;
28 use Friendica\Content\Text\Plaintext;
29 use Friendica\Core\Config\Util\ConfigFileManager;
30 use Friendica\Core\Hook;
31 use Friendica\Core\Logger;
32 use Friendica\Core\Protocol;
33 use Friendica\Core\Renderer;
34 use Friendica\Database\DBA;
35 use Friendica\DI;
36 use Friendica\Model\Contact;
37 use Friendica\Model\Item;
38 use Friendica\Model\ItemURI;
39 use Friendica\Model\Photo;
40 use Friendica\Model\Post;
41 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
42 use Friendica\Network\HTTPClient\Client\HttpClientOptions;
43 use Friendica\Protocol\Activity;
44 use Friendica\Util\DateTimeFormat;
45 use Friendica\Util\Strings;
46
47 const BLUESKY_DEFAULT_POLL_INTERVAL = 10; // given in minutes
48 const BLUESKY_HOST = 'https://bsky.app'; // Hard wired until Bluesky will run on multiple systems
49
50 function bluesky_install()
51 {
52         Hook::register('load_config',             __FILE__, 'bluesky_load_config');
53         Hook::register('hook_fork',               __FILE__, 'bluesky_hook_fork');
54         Hook::register('post_local',              __FILE__, 'bluesky_post_local');
55         Hook::register('notifier_normal',         __FILE__, 'bluesky_send');
56         Hook::register('jot_networks',            __FILE__, 'bluesky_jot_nets');
57         Hook::register('connector_settings',      __FILE__, 'bluesky_settings');
58         Hook::register('connector_settings_post', __FILE__, 'bluesky_settings_post');
59         Hook::register('cron',                    __FILE__, 'bluesky_cron');
60         // Hook::register('support_follow',          __FILE__, 'bluesky_support_follow');
61         // Hook::register('support_probe',           __FILE__, 'bluesky_support_probe');
62         // Hook::register('follow',                  __FILE__, 'bluesky_follow');
63         // Hook::register('unfollow',                __FILE__, 'bluesky_unfollow');
64         // Hook::register('block',                   __FILE__, 'bluesky_block');
65         // Hook::register('unblock',                 __FILE__, 'bluesky_unblock');
66         Hook::register('check_item_notification', __FILE__, 'bluesky_check_item_notification');
67         // Hook::register('probe_detect',            __FILE__, 'bluesky_probe_detect');
68         Hook::register('item_by_link',            __FILE__, 'bluesky_item_by_link');
69 }
70
71 function bluesky_load_config(ConfigFileManager $loader)
72 {
73         DI::app()->getConfigCache()->load($loader->loadAddonConfig('bluesky'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
74 }
75
76 function bluesky_check_item_notification(array &$notification_data)
77 {
78         $handle = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'handle');
79         $did    = DI::pConfig()->get($notification_data['uid'], 'bluesky', 'did');
80
81         if (!empty($handle) && !empty($did)) {
82                 $notification_data['profiles'][] = $handle;
83                 $notification_data['profiles'][] = $did;
84         }
85 }
86
87 function bluesky_item_by_link(array &$hookData)
88 {
89         // Don't overwrite an existing result
90         if (isset($hookData['item_id'])) {
91                 return;
92         }
93
94         $token = bluesky_get_token($hookData['uid']);
95         if (empty($token)) {
96                 return;
97         }
98
99         if (!preg_match('#^' . BLUESKY_HOST . '/profile/(.+)/post/(.+)#', $hookData['uri'], $matches)) {
100                 return;
101         }
102
103         $did = bluesky_get_did($hookData['uid'], $matches[1]);
104         if (empty($did)) {
105                 return;
106         }
107
108         Logger::debug('Found bluesky post', ['url' => $hookData['uri'], 'handle' => $matches[1], 'did' => $did, 'cid' => $matches[2]]);
109
110         $uri = 'at://' . $did . '/app.bsky.feed.post/' . $matches[2];
111
112         $uri = bluesky_fetch_missing_post($uri, $hookData['uid'], 0, true);
113         Logger::debug('Got post', ['profile' => $matches[1], 'cid' => $matches[2], 'result' => $uri]);
114         if (!empty($uri)) {
115                 $item = Post::selectFirst(['id'], ['uri' => $uri, 'uid' => $hookData['uid']]);
116                 if (!empty($item['id'])) {
117                         $hookData['item_id'] = $item['id'];
118                 }
119         }
120 }
121
122 function bluesky_settings(array &$data)
123 {
124         if (!DI::userSession()->getLocalUserId()) {
125                 return;
126         }
127
128         $enabled     = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false;
129         $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false;
130         $host        = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host') ?: 'https://bsky.social';
131         $handle      = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
132         $did         = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
133         $token       = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token');
134         $import      = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false;
135
136         $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.');
137
138         $t    = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/bluesky/');
139         $html = Renderer::replaceMacros($t, [
140                 '$enable'    => ['bluesky', DI::l10n()->t('Enable Bluesky Post Addon'), $enabled],
141                 '$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post to Bluesky by default'), $def_enabled],
142                 '$import'    => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import],
143                 '$host'      => ['bluesky_host', DI::l10n()->t('Bluesky host'), $host, '', '', 'readonly'],
144                 '$handle'    => ['bluesky_handle', DI::l10n()->t('Bluesky handle'), $handle],
145                 '$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'],
146                 '$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.")],
147                 '$status'    => $status
148         ]);
149
150         $data = [
151                 'connector' => 'bluesky',
152                 'title'     => DI::l10n()->t('Bluesky Import/Export'),
153                 'image'     => 'images/bluesky.jpg',
154                 'enabled'   => $enabled,
155                 'html'      => $html,
156         ];
157 }
158
159 function bluesky_settings_post(array &$b)
160 {
161         if (empty($_POST['bluesky-submit'])) {
162                 return;
163         }
164
165         $old_host   = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'host');
166         $old_handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
167         $old_did    = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
168
169         $host   = $_POST['bluesky_host'];
170         $handle = $_POST['bluesky_handle'];
171
172         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'post',            intval($_POST['bluesky']));
173         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default', intval($_POST['bluesky_bydefault']));
174         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'host',            $host);
175         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'handle',          $handle);
176         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import',          intval($_POST['bluesky_import']));
177
178         if (!empty($host) && !empty($handle)) {
179                 if (empty($old_did) || $old_host != $host || $old_handle != $handle) {
180                         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'did', bluesky_get_did(DI::userSession()->getLocalUserId(), DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle')));
181                 }
182         } else {
183                 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
184         }
185
186         if (!empty($_POST['bluesky_password'])) {
187                 bluesky_create_token(DI::userSession()->getLocalUserId(), $_POST['bluesky_password']);
188         }
189 }
190
191 function bluesky_jot_nets(array &$jotnets_fields)
192 {
193         if (!DI::userSession()->getLocalUserId()) {
194                 return;
195         }
196
197         if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post')) {
198                 $jotnets_fields[] = [
199                         'type'  => 'checkbox',
200                         'field' => [
201                                 'bluesky_enable',
202                                 DI::l10n()->t('Post to Bluesky'),
203                                 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default')
204                         ]
205                 ];
206         }
207 }
208
209 function bluesky_cron()
210 {
211         $last = DI::keyValue()->get('bluesky_last_poll');
212
213         $poll_interval = intval(DI::config()->get('bluesky', 'poll_interval'));
214         if (!$poll_interval) {
215                 $poll_interval = BLUESKY_DEFAULT_POLL_INTERVAL;
216         }
217
218         if ($last) {
219                 $next = $last + ($poll_interval * 60);
220                 if ($next > time()) {
221                         Logger::notice('poll interval not reached');
222                         return;
223                 }
224         }
225         Logger::notice('cron_start');
226
227         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
228         if ($abandon_days < 1) {
229                 $abandon_days = 0;
230         }
231
232         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
233
234         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'bluesky', 'k' => 'import', 'v' => true]);
235         foreach ($pconfigs as $pconfig) {
236                 if ($abandon_days != 0) {
237                         if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $pconfig['uid'], $abandon_limit])) {
238                                 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $pconfig['uid']]);
239                                 continue;
240                         }
241                 }
242
243                 Logger::notice('importing timeline - start', ['user' => $pconfig['uid']]);
244                 bluesky_fetch_timeline($pconfig['uid']);
245                 Logger::notice('importing timeline - done', ['user' => $pconfig['uid']]);
246         }
247
248         Logger::notice('cron_end');
249
250         DI::keyValue()->set('bluesky_last_poll', time());
251 }
252
253 function bluesky_hook_fork(array &$b)
254 {
255         if ($b['name'] != 'notifier_normal') {
256                 return;
257         }
258
259         $post = $b['data'];
260
261         if (($post['created'] !== $post['edited']) && !$post['deleted']) {
262                 DI::logger()->info('Editing is not supported by the addon');
263                 $b['execute'] = false;
264                 return;
265         }
266
267         if (DI::pConfig()->get($post['uid'], 'bluesky', 'import')) {
268                 // Don't post if it isn't a reply to a bluesky post
269                 if (($post['parent'] != $post['id']) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::BLUESKY])) {
270                         Logger::notice('No bluesky parent found', ['item' => $post['id']]);
271                         $b['execute'] = false;
272                         return;
273                 }
274         } elseif (!strstr($post['postopts'] ?? '', 'bluesky') || ($post['parent'] != $post['id']) || $post['private']) {
275                 DI::logger()->info('Activities are never exported when we don\'t import the bluesky timeline', ['uid' => $post['uid']]);
276                 $b['execute'] = false;
277                 return;
278         }
279 }
280
281 function bluesky_post_local(array &$b)
282 {
283         if ($b['edit']) {
284                 return;
285         }
286
287         if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
288                 return;
289         }
290
291         if ($b['private'] || $b['parent']) {
292                 return;
293         }
294
295         $bluesky_post   = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post'));
296         $bluesky_enable = (($bluesky_post && !empty($_REQUEST['bluesky_enable'])) ? intval($_REQUEST['bluesky_enable']) : 0);
297
298         // if API is used, default to the chosen settings
299         if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default'))) {
300                 $bluesky_enable = 1;
301         }
302
303         if (!$bluesky_enable) {
304                 return;
305         }
306
307         if (strlen($b['postopts'])) {
308                 $b['postopts'] .= ',';
309         }
310
311         $b['postopts'] .= 'bluesky';
312 }
313
314 function bluesky_send(array &$b)
315 {
316         if (($b['created'] !== $b['edited']) && !$b['deleted']) {
317                 return;
318         }
319
320         if ($b['gravity'] != Item::GRAVITY_PARENT) {
321                 Logger::debug('Got comment', ['item' => $b]);
322
323                 if ($b['deleted']) {
324                         $uri = bluesky_get_uri_class($b['uri']);
325                         if (empty($uri)) {
326                                 Logger::debug('Not a bluesky post', ['uri' => $b['uri']]);
327                                 return;
328                         }
329                         bluesky_delete_post($b['uri'], $b['uid']);
330                         return;
331                 }
332
333                 $root   = bluesky_get_uri_class($b['parent-uri']);
334                 $parent = bluesky_get_uri_class($b['thr-parent']);
335
336                 if (empty($root) || empty($parent)) {
337                         Logger::debug('No bluesky post', ['parent' => $b['parent'], 'thr-parent' => $b['thr-parent']]);
338                         return;
339                 }
340
341                 if ($b['gravity'] == Item::GRAVITY_COMMENT) {
342                         Logger::debug('Posting comment', ['root' => $root, 'parent' => $parent]);
343                         bluesky_create_post($b, $root, $parent);
344                         return;
345                 } elseif (in_array($b['verb'], [Activity::LIKE, Activity::ANNOUNCE])) {
346                         bluesky_create_activity($b, $parent);
347                 }
348                 return;
349         } elseif ($b['private'] || !strstr($b['postopts'], 'bluesky')) {
350                 return;
351         }
352
353         bluesky_create_post($b);
354 }
355
356 function bluesky_create_activity(array $item, stdClass $parent = null)
357 {
358         $uid = $item['uid'];
359         $token = bluesky_get_token($uid);
360         if (empty($token)) {
361                 return;
362         }
363
364         $did  = DI::pConfig()->get($uid, 'bluesky', 'did');
365
366         if ($item['verb'] == Activity::LIKE) {
367                 $record = [
368                         'subject'   => $parent,
369                         'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
370                         '$type'     => 'app.bsky.feed.like'
371                 ];
372
373                 $post = [
374                         'collection' => 'app.bsky.feed.like',
375                         'repo'       => $did,
376                         'record'     => $record
377                 ];
378         } elseif ($item['verb'] == Activity::ANNOUNCE) {
379                 $record = [
380                         'subject'   => $parent,
381                         'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
382                         '$type'     => 'app.bsky.feed.repost'
383                 ];
384
385                 $post = [
386                         'collection' => 'app.bsky.feed.repost',
387                         'repo'       => $did,
388                         'record'     => $record
389                 ];
390         }
391
392         $activity = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
393         if (empty($activity)) {
394                 return;
395         }
396         Logger::debug('Activity done', ['return' => $activity]);
397         $uri = bluesky_get_uri($activity);
398         Item::update(['extid' => $uri], ['id' => $item['id']]);
399         Logger::debug('Set extid', ['id' => $item['id'], 'extid' => $activity]);
400 }
401
402 function bluesky_create_post(array $item, stdClass $root = null, stdClass $parent = null)
403 {
404         $uid = $item['uid'];
405         $token = bluesky_get_token($uid);
406         if (empty($token)) {
407                 return;
408         }
409
410         $did  = DI::pConfig()->get($uid, 'bluesky', 'did');
411         $urls = bluesky_get_urls($item['body']);
412
413         $msg = Plaintext::getPost($item, 300, false, BBCode::CONNECTORS);
414         foreach ($msg['parts'] as $key => $part) {
415
416                 $facets = bluesky_get_facets($part, $urls);
417
418                 $record = [
419                         'text'      => $facets['body'],
420                         'createdAt' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
421                         '$type'     => 'app.bsky.feed.post'
422                 ];
423
424                 if (!empty($facets['facets'])) {
425                         $record['facets'] = $facets['facets'];
426                 }
427
428                 if (!empty($root)) {
429                         $record['reply'] = ['root' => $root, 'parent' => $parent];
430                 }
431
432                 if ($key == count($msg['parts']) - 1) {
433                         $record = bluesky_add_embed($uid, $msg, $record);
434                 }
435
436                 $post = [
437                         'collection' => 'app.bsky.feed.post',
438                         'repo'       => $did,
439                         'record'     => $record
440                 ];
441
442                 $parent = bluesky_post($uid, '/xrpc/com.atproto.repo.createRecord', json_encode($post), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
443                 if (empty($parent)) {
444                         return;
445                 }
446                 Logger::debug('Posting done', ['return' => $parent]);
447                 if (empty($root)) {
448                         $root = $parent;
449                 }
450                 if (($key == 0) && ($item['gravity'] != Item::GRAVITY_PARENT)) {
451                         $uri = bluesky_get_uri($parent);
452                         Item::update(['extid' => $uri], ['id' => $item['id']]);
453                         Logger::debug('Set extid', ['id' => $item['id'], 'extid' => $uri]);
454                 }
455         }
456 }
457
458 function bluesky_get_urls(string $body): array
459 {
460         // Remove all hashtags and mentions
461         $body = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '', $body);
462
463         $urls = [];
464
465         // Search for pure links
466         if (preg_match_all("/\[url\](https?:.*?)\[\/url\]/ism", $body, $matches)) {
467                 foreach ($matches[1] as $url) {
468                         $urls[] = $url;
469                 }
470         }
471
472         // Search for links with descriptions
473         if (preg_match_all("/\[url\=(https?:.*?)\].*?\[\/url\]/ism", $body, $matches)) {
474                 foreach ($matches[1] as $url) {
475                         $urls[] = $url;
476                 }
477         }
478         return $urls;
479 }
480
481 function bluesky_get_facets(string $body, array $urls): array
482 {
483         $facets = [];
484
485         foreach ($urls as $url) {
486                 $pos = strpos($body, $url);
487                 if ($pos === false) {
488                         continue;
489                 }
490                 if ($pos > 0) {
491                         $prefix = substr($body, 0, $pos);
492                 } else {
493                         $prefix = '';
494                 }
495                 $linktext = Strings::getStyledURL($url);
496                 $body = $prefix . $linktext . substr($body, $pos + strlen($url));
497
498                 $facet = new stdClass;
499                 $facet->index = new stdClass;
500                 $facet->index->byteEnd   = $pos + strlen($linktext);
501                 $facet->index->byteStart = $pos;
502
503                 $feature = new stdClass;
504                 $feature->uri = $url;
505                 $type = '$type';
506                 $feature->$type = 'app.bsky.richtext.facet#link';
507
508                 $facet->features = [$feature];
509                 $facets[] = $facet;
510         }
511
512         return ['facets' => $facets, 'body' => $body];
513 }
514
515 function bluesky_add_embed(int $uid, array $msg, array $record): array
516 {
517         if (($msg['type'] != 'link') && !empty($msg['images'])) {
518                 $images = [];
519                 foreach ($msg['images'] as $image) {
520                         $photo = Photo::selectFirst(['resource-id'], ['id' => $image['id']]);
521                         $photo = Photo::selectFirst([], ["`resource-id` = ? AND `scale` > ?", $photo['resource-id'], 0], ['order' => ['scale']]);
522                         $blob = bluesky_upload_blob($uid, $photo);
523                         if (!empty($blob) && count($images) < 4) {
524                                 $images[] = ['alt' => $image['description'], 'image' => $blob];
525                         }
526                 }
527                 if (!empty($images)) {
528                         $record['embed'] = ['$type' => 'app.bsky.embed.images', 'images' => $images];
529                 }
530         } elseif ($msg['type'] == 'link') {
531                 $record['embed'] = [
532                         '$type'    => 'app.bsky.embed.external',
533                         'external' => [
534                                 'uri'         => $msg['url'],
535                                 'title'       => $msg['title'],
536                                 'description' => $msg['description'],
537                         ]
538                 ];
539                 if (!empty($msg['image'])) {
540                         $photo = Photo::createPhotoForExternalResource($msg['image']);
541                         $blob = bluesky_upload_blob($uid, $photo);
542                         if (!empty($blob)) {
543                                 $record['embed']['external']['thumb'] = $blob;
544                         }
545                 }
546         }
547         return $record;
548 }
549
550 function bluesky_upload_blob(int $uid, array $photo): ?stdClass
551 {
552         $content = Photo::getImageForPhoto($photo);
553         $data = bluesky_post($uid, '/xrpc/com.atproto.repo.uploadBlob', $content, ['Content-type' => $photo['type'], 'Authorization' => ['Bearer ' . bluesky_get_token($uid)]]);
554         if (empty($data)) {
555                 return null;
556         }
557
558         Logger::debug('Uploaded blob', ['return' => $data]);
559         return $data->blob;
560 }
561
562 function bluesky_delete_post(string $uri, int $uid)
563 {
564         $token = bluesky_get_token($uid);
565         $parts = bluesky_get_uri_parts($uri);
566         if (empty($parts)) {
567                 Logger::debug('No uri delected', ['uri' => $uri]);
568                 return;
569         }
570         bluesky_post($uid, '/xrpc/com.atproto.repo.deleteRecord', json_encode($parts), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $token]]);
571         Logger::debug('Deleted', ['parts' => $parts]);
572 }
573
574 function bluesky_fetch_timeline(int $uid)
575 {
576         $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getTimeline', HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
577         if (empty($data)) {
578                 return;
579         }
580
581         if (empty($data->feed)) {
582                 return;
583         }
584
585         foreach (array_reverse($data->feed) as $entry) {
586                 bluesky_process_post($entry->post, $uid);
587                 if (!empty($entry->reason)) {
588                         bluesky_process_reason($entry->reason, bluesky_get_uri($entry->post), $uid);
589                 }
590         }
591
592         // @todo Support paging
593         // [cursor] => 1684670516000::bafyreidq3ilwslmlx72jf5vrk367xcc63s6lrhzlyup2bi3zwcvso6w2vi
594 }
595
596 function bluesky_process_reason(stdClass $reason, string $uri, int $uid)
597 {
598         $type = '$type';
599         if ($reason->$type != 'app.bsky.feed.defs#reasonRepost') {
600                 return;
601         }
602
603         $contact = bluesky_get_contact($reason->by, $uid);
604
605         $item = [
606                 'network'       => Protocol::BLUESKY,
607                 'uid'           => $uid,
608                 'wall'          => false,
609                 'uri'           => $reason->by->did . '/app.bsky.feed.repost/' . $reason->indexedAt,
610                 'private'       => Item::UNLISTED,
611                 'verb'          => Activity::POST,
612                 'contact-id'    => $contact['id'],
613                 'author-name'   => $contact['name'],
614                 'author-link'   => $contact['url'],
615                 'author-avatar' => $contact['avatar'],
616                 'verb'          => Activity::ANNOUNCE,
617                 'body'          => Activity::ANNOUNCE,
618                 'gravity'       => Item::GRAVITY_ACTIVITY,
619                 'object-type'   => Activity\ObjectType::NOTE,
620                 'thr-parent'    => $uri,
621         ];
622
623         if (Post::exists(['uri' => $item['uri'], 'uid' => $uid])) {
624                 return;
625         }
626
627         $item['owner-name']   = $item['author-name'];
628         $item['owner-link']   = $item['author-link'];
629         $item['owner-avatar'] = $item['author-avatar'];
630         if (Item::insert($item)) {
631                 $cdata = Contact::getPublicAndUserContactID($contact['id'], $uid);
632                 Item::update(['post-reason' => Item::PR_ANNOUNCEMENT, 'causer-id' => $cdata['public']], ['uri' => $uri, 'uid' => $uid]);
633         }
634 }
635
636 function bluesky_process_post(stdClass $post, int $uid): int
637 {
638         $uri = bluesky_get_uri($post);
639
640         if (Post::exists(['uri' => $uri, 'uid' => $uid]) || Post::exists(['extid' => $uri, 'uid' => $uid])) {
641                 return 0;
642         }
643
644         Logger::debug('Importing post', ['uid' => $uid, 'indexedAt' => $post->indexedAt, 'uri' => $post->uri, 'cid' => $post->cid]);
645
646         $item = bluesky_get_header($post, $uri, $uid);
647
648         $item = bluesky_get_content($item, $post->record, $uid);
649
650         if (!empty($post->embed)) {
651                 $item = bluesky_add_media($post->embed, $item);
652         }
653         return item::insert($item);
654 }
655
656 function bluesky_get_header(stdClass $post, string $uri, int $uid): array
657 {
658         $parts = bluesky_get_uri_parts($uri);
659         if (empty($post->author)) {
660                 return [];
661         }
662         $contact = bluesky_get_contact($post->author, $uid);
663         $item = [
664                 'network'       => Protocol::BLUESKY,
665                 'uid'           => $uid,
666                 'wall'          => false,
667                 'uri'           => $uri,
668                 'guid'          => $post->cid,
669                 'private'       => Item::UNLISTED,
670                 'verb'          => Activity::POST,
671                 'contact-id'    => $contact['id'],
672                 'author-name'   => $contact['name'],
673                 'author-link'   => $contact['url'],
674                 'author-avatar' => $contact['avatar'],
675                 'plink'         => $contact['alias'] . '/post/' . $parts->rkey,
676         ];
677
678         $item['uri-id']       = ItemURI::getIdByURI($uri);
679         $item['owner-name']   = $item['author-name'];
680         $item['owner-link']   = $item['author-link'];
681         $item['owner-avatar'] = $item['author-avatar'];
682
683         return $item;
684 }
685
686 function bluesky_get_content(array $item, stdClass $record, int $uid): array
687 {
688         if (!empty($record->reply)) {
689                 $item['parent-uri'] = bluesky_get_uri($record->reply->root);
690                 $item['parent-uri'] = bluesky_fetch_missing_post($item['parent-uri'], $uid, $item['contact-id']);
691                 $item['thr-parent'] = bluesky_get_uri($record->reply->parent);
692                 $item['thr-parent'] = bluesky_fetch_missing_post($item['thr-parent'], $uid, $item['contact-id']);
693         }
694
695         $item['body']    = bluesky_get_text($record, $uid);
696         $item['created'] = DateTimeFormat::utc($record->createdAt, DateTimeFormat::MYSQL);
697         return $item;
698 }
699
700 function bluesky_get_text(stdClass $record, int $uid): string
701 {
702         $text = $record->text;
703
704         if (empty($record->facets)) {
705                 return $text;
706         }
707
708         $facets = [];
709         foreach ($record->facets as $facet) {
710                 $facets[$facet->index->byteStart] = $facet;
711         }
712         krsort($facets);
713
714         foreach ($facets as $facet) {
715                 $prefix   = substr($text, 0, $facet->index->byteStart);
716                 $linktext = substr($text, $facet->index->byteStart, $facet->index->byteEnd - $facet->index->byteStart);
717                 $suffix   = substr($text, $facet->index->byteEnd);
718
719                 $url = '';
720
721                 foreach ($facet->features as $feature) {
722                         if (!empty($feature->uri)) {
723                                 $url = $feature->uri;
724                         }
725                         if (!empty($feature->did)) {
726                                 $contact = Contact::selectFirst(['id'], ['nurl' => $feature->did, 'uid' => [0, $uid]]);
727                                 if (!empty($contact['id'])) {
728                                         $url = DI::baseUrl() . '/contact/' . $contact['id'];
729                                         if (substr($linktext, 0, 1) == '@') {
730                                                 $prefix .= '@';
731                                                 $linktext = substr($linktext, 1);
732                                         }                                       
733                                 }
734                         }
735                 }
736                 if (!empty($url)) {
737                         $text = $prefix . '[url=' . $url . ']' . $linktext . '[/url]' . $suffix;
738                 }
739         }
740         return $text;
741 }
742
743 function bluesky_add_media(stdClass $embed, array $item): array
744 {
745         if (!empty($embed->images)) {
746                 foreach ($embed->images as $image) {
747                         $media = [
748                                 'uri-id'      => $item['uri-id'],
749                                 'type'        => Post\Media::IMAGE,
750                                 'url'         => $image->fullsize,
751                                 'preview'     => $image->thumb,
752                                 'description' => $image->alt,
753                         ];
754                         Post\Media::insert($media);
755                 }
756         } elseif (!empty($embed->external)) {
757                 $media = [
758                         'uri-id' => $item['uri-id'],
759                         'type'        => Post\Media::HTML,
760                         'url'         => $embed->external->uri,
761                         'name'        => $embed->external->title,
762                         'description' => $embed->external->description,
763                 ];
764                 Post\Media::insert($media);
765         } elseif (!empty($embed->record)) {
766                 $uri = bluesky_get_uri($embed->record);
767                 $shared = Post::selectFirst(['uri-id'], ['uri' => $uri, 'uid' => $item['uid']]);
768                 if (empty($shared)) {
769                         $shared = bluesky_get_header($embed->record, $uri, 0);
770                         if (!empty($shared)) {
771                                 $shared = bluesky_get_content($shared, $embed->record->value, $item['uid']);
772
773                                 if (!empty($embed->record->embeds)) {
774                                         foreach ($embed->record->embeds as $single) {
775                                                 $shared = bluesky_add_media($single, $shared);
776                                         }
777                                 }
778                                 $id = Item::insert($shared);
779                                 $shared = Post::selectFirst(['uri-id'], ['id' => $id]);
780                         }
781                 }
782                 if (!empty($shared)) {
783                         $item['quote-uri-id'] = $shared['uri-id'];
784                 }
785         } else {
786                 Logger::debug('Unsupported embed', ['embed' => $embed, 'item' => $item]);
787         }
788         return $item;
789 }
790
791 function bluesky_get_uri(stdClass $post): string
792 {
793         return $post->uri . ':' . $post->cid;
794 }
795
796 function bluesky_get_uri_class(string $uri): ?stdClass
797 {
798         if (empty($uri)) {
799                 return null;
800         }
801
802         $elements = explode(':', $uri);
803         if (empty($elements) || ($elements[0] != 'at')) {
804                 $post = Post::selectFirstPost(['extid'], ['uri' => $uri]);
805                 return bluesky_get_uri_class($post['extid'] ?? '');
806         }
807
808         $class = new stdClass;
809
810         $class->cid = array_pop($elements);
811         $class->uri = implode(':', $elements);
812
813         return $class;
814 }
815
816 function bluesky_get_uri_parts(string $uri): ?stdClass
817 {
818         $class = bluesky_get_uri_class($uri);
819         if (empty($class)) {
820                 return null;
821         }
822
823         $parts = explode('/', substr($class->uri, 5));
824
825         $class = new stdClass;
826
827         $class->repo       = $parts[0];
828         $class->collection = $parts[1];
829         $class->rkey       = $parts[2];
830
831         return $class;
832 }
833
834 function bluesky_fetch_missing_post(string $uri, int $uid, int $causer, bool $original = false): string
835 {
836         if (Post::exists(['uri' => $uri, 'uid' => [$uid, 0]])) {
837                 Logger::debug('Post exists', ['uri' => $uri]);
838                 return $uri;
839         }
840
841         $reply = Post::selectFirst(['uri'], ['extid' => $uri, 'uid' => [$uid, 0]]);
842         if (!empty($reply['uri'])) {
843                 return $reply['uri'];
844         }
845
846         Logger::debug('Fetch missing post', ['uri' => $uri]);
847         if (!$original) {
848                 $class = bluesky_get_uri_class($uri);
849                 $fetch_uri = $class->uri;
850         } else {
851                 $fetch_uri = $uri;
852         }
853
854         $data = bluesky_get($uid, '/xrpc/app.bsky.feed.getPosts?uris=' . urlencode($fetch_uri), HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
855         if (empty($data)) {
856                 return '';
857         }
858
859         if ($causer != 0) {
860                 $cdata = Contact::getPublicAndUserContactID($causer, $uid);
861         }
862
863         foreach ($data->posts as $post) {
864                 $uri = bluesky_get_uri($post);
865                 $item = bluesky_get_header($post, $uri, $uid);
866                 $item = bluesky_get_content($item, $post->record, $uid);
867
868                 $item['post-reason'] = Item::PR_FETCHED;
869
870                 if (!empty($cdata['public'])) {
871                         $item['causer-id']   = $cdata['public'];
872                 }
873
874                 if (!empty($post->embed)) {
875                         $item = bluesky_add_media($post->embed, $item);
876                 }
877                 $id = Item::insert($item);
878                 Logger::debug('Stored item', ['id' => $id, 'uri' => $uri]);
879         }
880
881         return $uri;
882 }
883
884 function bluesky_get_contact(stdClass $author, int $uid): array
885 {
886         $condition = ['network' => Protocol::BLUESKY, 'uid' => $uid, 'url' => $author->did];
887
888         $fields = [
889                 'alias' => BLUESKY_HOST . '/profile/' . $author->handle,
890                 'name'  => $author->displayName,
891                 'nick'  => $author->handle,
892                 'addr'  => $author->handle,
893         ];
894
895         $contact = Contact::selectFirst([], $condition);
896
897         if (empty($contact)) {
898                 $cid = bluesky_insert_contact($author, $uid);
899         } else {
900                 $cid = $contact['id'];
901                 if ($fields['alias'] != $contact['alias'] || $fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) {
902                         Contact::update($fields, ['id' => $cid]);
903                 }
904         }
905
906         $condition['uid'] = 0;
907
908         $contact = Contact::selectFirst([], $condition);
909         if (empty($contact)) {
910                 $pcid = bluesky_insert_contact($author, 0);
911         } else {
912                 $pcid = $contact['id'];
913                 if ($fields['alias'] != $contact['alias'] || $fields['name'] != $contact['name'] || $fields['nick'] != $contact['nick'] || $fields['addr'] != $contact['addr']) {
914                         Contact::update($fields, ['id' => $pcid]);
915                 }
916         }
917
918         if (!empty($author->avatar)) {
919                 Contact::updateAvatar($cid, $author->avatar);
920         }
921
922         if (empty($contact) || $contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
923                 bluesky_update_contact($author, $uid, $cid, $pcid);
924         }
925
926         return Contact::getById($cid);
927 }
928
929 function bluesky_insert_contact(stdClass $author, int $uid)
930 {
931         $fields = [
932                 'uid'      => $uid,
933                 'network'  => Protocol::BLUESKY,
934                 'priority' => 1,
935                 'writable' => true,
936                 'blocked'  => false,
937                 'readonly' => false,
938                 'pending'  => false,
939                 'url'      => $author->did,
940                 'nurl'     => $author->did,
941                 'alias'    => BLUESKY_HOST . '/profile/' . $author->handle,
942                 'name'     => $author->displayName,
943                 'nick'     => $author->handle,
944                 'addr'     => $author->handle,
945         ];
946         return Contact::insert($fields);
947 }
948
949 function bluesky_update_contact(stdClass $author, int $uid, int $cid, int $pcid)
950 {
951         $data = bluesky_get($uid, '/xrpc/app.bsky.actor.getProfile?actor=' . $author->did, HttpClientAccept::JSON, [HttpClientOptions::HEADERS => ['Authorization' => ['Bearer ' . bluesky_get_token($uid)]]]);
952         if (empty($data)) {
953                 return;
954         }
955
956         $fields = [
957                 'alias'   => BLUESKY_HOST . '/profile/' . $data->handle,
958                 'name'    => $data->displayName,
959                 'nick'    => $data->handle,
960                 'addr'    => $data->handle,
961                 'updated' => DateTimeFormat::utcNow(DateTimeFormat::MYSQL),
962         ];
963
964         if (!empty($data->description)) {
965                 $fields['about'] = HTML::toBBCode($data->description);
966         }
967
968         if (!empty($data->banner)) {
969                 $fields['header'] = $data->banner;
970         }
971
972         Contact::update($fields, ['id' => $cid]);
973         Contact::update($fields, ['id' => $pcid]);
974 }
975
976 function bluesky_get_did(int $uid, string $handle): string
977 {
978         $data = bluesky_get($uid, '/xrpc/com.atproto.identity.resolveHandle?handle=' . $handle);
979         if (empty($data)) {
980                 return '';
981         }
982         Logger::debug('Got DID', ['return' => $data]);
983         return $data->did;
984 }
985
986 function bluesky_get_token(int $uid): string
987 {
988         $token   = DI::pConfig()->get($uid, 'bluesky', 'access_token');
989         $created = DI::pConfig()->get($uid, 'bluesky', 'token_created');
990         if (empty($token)) {
991                 return '';
992         }
993
994         if ($created + 300 < time()) {
995                 return bluesky_refresh_token($uid);
996         }
997         return $token;
998 }
999
1000 function bluesky_refresh_token(int $uid): string
1001 {
1002         $token = DI::pConfig()->get($uid, 'bluesky', 'refresh_token');
1003
1004         $data = bluesky_post($uid, '/xrpc/com.atproto.server.refreshSession', '', ['Authorization' => ['Bearer ' . $token]]);
1005         if (empty($data)) {
1006                 return '';
1007         }
1008
1009         Logger::debug('Refreshed token', ['return' => $data]);
1010         DI::pConfig()->set($uid, 'bluesky', 'access_token', $data->accessJwt);
1011         DI::pConfig()->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt);
1012         DI::pConfig()->set($uid, 'bluesky', 'token_created', time());
1013         return $data->accessJwt;
1014 }
1015
1016 function bluesky_create_token(int $uid, string $password): string
1017 {
1018         $did = DI::pConfig()->get($uid, 'bluesky', 'did');
1019
1020         $data = bluesky_post($uid, '/xrpc/com.atproto.server.createSession', json_encode(['identifier' => $did, 'password' => $password]), ['Content-type' => 'application/json']);
1021         if (empty($data)) {
1022                 return '';
1023         }
1024
1025         Logger::debug('Created token', ['return' => $data]);
1026         DI::pConfig()->set($uid, 'bluesky', 'access_token', $data->accessJwt);
1027         DI::pConfig()->set($uid, 'bluesky', 'refresh_token', $data->refreshJwt);
1028         DI::pConfig()->set($uid, 'bluesky', 'token_created', time());
1029         return $data->accessJwt;
1030 }
1031
1032 function bluesky_post(int $uid, string $url, string $params, array $headers): ?stdClass
1033 {
1034         try {
1035                 $curlResult = DI::httpClient()->post(DI::pConfig()->get($uid, 'bluesky', 'host') . $url, $params, $headers);
1036         } catch (\Exception $e) {
1037                 Logger::notice('Exception on post', ['exception' => $e]);
1038                 return null;
1039         }
1040
1041         if (!$curlResult->isSuccess()) {
1042                 Logger::notice('API Error', ['error' => json_decode($curlResult->getBody()) ?: $curlResult->getBody()]);
1043                 return null;
1044         }
1045
1046         return json_decode($curlResult->getBody());
1047 }
1048
1049 function bluesky_get(int $uid, string $url, string $accept_content = HttpClientAccept::DEFAULT, array $opts = []): ?stdClass
1050 {
1051         try {
1052                 $curlResult = DI::httpClient()->get(DI::pConfig()->get($uid, 'bluesky', 'host') . $url, $accept_content, $opts);
1053         } catch (\Exception $e) {
1054                 Logger::notice('Exception on get', ['exception' => $e]);
1055                 return null;
1056         }
1057
1058         if (!$curlResult->isSuccess()) {
1059                 Logger::notice('API Error', ['error' => json_decode($curlResult->getBody()) ?: $curlResult->getBody()]);
1060                 return null;
1061         }
1062
1063         return json_decode($curlResult->getBody());
1064 }