]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Processor.php
92aa3b783334decb05b2aee759d66bd595d57f79
[friendica.git] / src / Protocol / ActivityPub / Processor.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2022, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\Protocol\ActivityPub;
23
24 use Friendica\Content\Text\BBCode;
25 use Friendica\Content\Text\HTML;
26 use Friendica\Content\Text\Markdown;
27 use Friendica\Core\Cache\Enum\Duration;
28 use Friendica\Core\Logger;
29 use Friendica\Core\Protocol;
30 use Friendica\Core\System;
31 use Friendica\Core\Worker;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Model\APContact;
35 use Friendica\Model\Contact;
36 use Friendica\Model\Conversation;
37 use Friendica\Model\Event;
38 use Friendica\Model\GServer;
39 use Friendica\Model\Item;
40 use Friendica\Model\ItemURI;
41 use Friendica\Model\Mail;
42 use Friendica\Model\Tag;
43 use Friendica\Model\User;
44 use Friendica\Model\Post;
45 use Friendica\Protocol\Activity;
46 use Friendica\Protocol\ActivityPub;
47 use Friendica\Protocol\Relay;
48 use Friendica\Util\DateTimeFormat;
49 use Friendica\Util\HTTPSignature;
50 use Friendica\Util\JsonLD;
51 use Friendica\Util\Network;
52 use Friendica\Util\Strings;
53 use Friendica\Worker\Delivery;
54
55 /**
56  * ActivityPub Processor Protocol class
57  */
58 class Processor
59 {
60         const CACHEKEY_FETCH_ACTIVITY = 'processor:fetchMissingActivity:';
61         const CACHEKEY_JUST_FETCHED   = 'processor:isJustFetched:';
62
63         static $processed = [];
64
65         public static function addActivityId(string $id)
66         {
67                 self::$processed[] = $id;
68                 if (count(self::$processed) > 100) {
69                         self::$processed = array_slice(self::$processed, 1);
70                 }
71                 print_r(self::$processed);
72         }
73
74         public static function isProcessed(string $id): bool
75         {
76                 return in_array($id, self::$processed);
77         }
78
79         /**
80          * Extracts the tag character (#, @, !) from mention links
81          *
82          * @param string $body
83          * @return string
84          */
85         protected static function normalizeMentionLinks(string $body): string
86         {
87                 return preg_replace('%\[url=([^\[\]]*)]([#@!])(.*?)\[/url]%ism', '$2[url=$1]$3[/url]', $body);
88         }
89
90         /**
91          * Convert the language array into a language JSON
92          *
93          * @param array $languages
94          * @return string language JSON
95          */
96         private static function processLanguages(array $languages): string
97         {
98                 $codes = array_keys($languages);
99                 $lang = [];
100                 foreach ($codes as $code) {
101                         $lang[$code] = 1;
102                 }
103
104                 if (empty($lang)) {
105                         return '';
106                 }
107
108                 return json_encode($lang);
109         }
110         /**
111          * Replaces emojis in the body
112          *
113          * @param int $uri_id
114          * @param string $body
115          * @param array $emojis
116          *
117          * @return string with replaced emojis
118          */
119         private static function replaceEmojis(int $uri_id, string $body, array $emojis): string
120         {
121                 $body = strtr($body,
122                         array_combine(
123                                 array_column($emojis, 'name'),
124                                 array_map(function ($emoji) {
125                                         return '[emoji=' . $emoji['href'] . ']' . $emoji['name'] . '[/emoji]';
126                                 }, $emojis)
127                         )
128                 );
129
130                 // We store the emoji here to be able to avoid storing it in the media
131                 foreach ($emojis as $emoji) {
132                         Post\Link::getByLink($uri_id, $emoji['href']);
133                 }
134                 return $body;
135         }
136
137         /**
138          * Store attached media files in the post-media table
139          *
140          * @param int $uriid
141          * @param array $attachment
142          * @return void
143          */
144         private static function storeAttachmentAsMedia(int $uriid, array $attachment)
145         {
146                 if (empty($attachment['url'])) {
147                         return;
148                 }
149
150                 $data = ['uri-id' => $uriid];
151                 $data['type'] = Post\Media::UNKNOWN;
152                 $data['url'] = $attachment['url'];
153                 $data['mimetype'] = $attachment['mediaType'] ?? null;
154                 $data['height'] = $attachment['height'] ?? null;
155                 $data['width'] = $attachment['width'] ?? null;
156                 $data['size'] = $attachment['size'] ?? null;
157                 $data['preview'] = $attachment['image'] ?? null;
158                 $data['description'] = $attachment['name'] ?? null;
159
160                 Post\Media::insert($data);
161         }
162
163         /**
164          * Stire attachment data
165          *
166          * @param array   $activity
167          * @param array   $item
168          */
169         private static function storeAttachments(array $activity, array $item)
170         {
171                 if (empty($activity['attachments'])) {
172                         return;
173                 }
174
175                 foreach ($activity['attachments'] as $attach) {
176                         self::storeAttachmentAsMedia($item['uri-id'], $attach);
177                 }
178         }
179
180         /**
181          * Store attachment data
182          *
183          * @param array   $activity
184          * @param array   $item
185          */
186         private static function storeQuestion(array $activity, array $item)
187         {
188                 if (empty($activity['question'])) {
189                         return;
190                 }
191                 $question = ['multiple' => $activity['question']['multiple']];
192
193                 if (!empty($activity['question']['voters'])) {
194                         $question['voters'] = $activity['question']['voters'];
195                 }
196
197                 if (!empty($activity['question']['end-time'])) {
198                         $question['end-time'] = DateTimeFormat::utc($activity['question']['end-time']);
199                 }
200
201                 Post\Question::update($item['uri-id'], $question);
202
203                 foreach ($activity['question']['options'] as $key => $option) {
204                         $option = ['name' => $option['name'], 'replies' => $option['replies']];
205                         Post\QuestionOption::update($item['uri-id'], $key, $option);
206                 }
207
208                 Logger::debug('Storing incoming question', ['type' => $activity['type'], 'uri-id' => $item['uri-id'], 'question' => $activity['question']]);
209         }
210
211         /**
212          * Updates a message
213          *
214          * @param array      $activity   Activity array
215          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
216          * @throws \ImagickException
217          */
218         public static function updateItem(array $activity)
219         {
220                 $item = Post::selectFirst(['uri', 'uri-id', 'thr-parent', 'gravity', 'post-type'], ['uri' => $activity['id']]);
221                 if (!DBA::isResult($item)) {
222                         Logger::warning('No existing item, item will be created', ['uri' => $activity['id']]);
223                         $item = self::createItem($activity);
224                         if (empty($item)) {
225                                 return;
226                         }
227
228                         self::postItem($activity, $item);
229                         return;
230                 }
231
232                 $item['changed'] = DateTimeFormat::utcNow();
233                 $item['edited'] = DateTimeFormat::utc($activity['updated']);
234
235                 $item = self::processContent($activity, $item);
236                 if (empty($item)) {
237                         return;
238                 }
239
240                 self::storeAttachments($activity, $item);
241                 self::storeQuestion($activity, $item);
242
243                 Post\History::add($item['uri-id'], $item);
244                 Item::update($item, ['uri' => $activity['id']]);
245
246                 Queue::remove($activity);
247
248                 if ($activity['object_type'] == 'as:Event') {
249                         $posts = Post::select(['event-id', 'uid'], ["`uri` = ? AND `event-id` > ?", $activity['id'], 0]);
250                         while ($post = DBA::fetch($posts)) {
251                                 self::updateEvent($post['event-id'], $activity);
252                         }
253                 }
254         }
255
256         /**
257          * Update an existing event
258          *
259          * @param int $event_id
260          * @param array $activity
261          */
262         private static function updateEvent(int $event_id, array $activity)
263         {
264                 $event = DBA::selectFirst('event', [], ['id' => $event_id]);
265
266                 $event['edited']   = DateTimeFormat::utc($activity['updated']);
267                 $event['summary']  = HTML::toBBCode($activity['name']);
268                 $event['desc']     = HTML::toBBCode($activity['content']);
269                 if (!empty($activity['start-time'])) {
270                         $event['start']  = DateTimeFormat::utc($activity['start-time']);
271                 }
272                 if (!empty($activity['end-time'])) {
273                         $event['finish'] = DateTimeFormat::utc($activity['end-time']);
274                 }
275                 $event['nofinish'] = empty($event['finish']);
276                 $event['location'] = $activity['location'];
277
278                 Logger::info('Updating event', ['uri' => $activity['id'], 'id' => $event_id]);
279                 Event::store($event);
280         }
281
282         /**
283          * Prepares data for a message
284          *
285          * @param array $activity      Activity array
286          * @param bool  $fetch_parents
287          *
288          * @return array Internal item
289          *
290          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
291          * @throws \ImagickException
292          */
293         public static function createItem(array $activity, bool $fetch_parents = true): array
294         {
295                 if (self::isProcessed($activity['id'])) {
296                         Logger::info('Id is already processed', ['id' => $activity['id']]);
297                         return [];
298                 }
299
300                 self::addActivityId($activity['id']);
301
302                 $item = [];
303                 $item['verb'] = Activity::POST;
304                 $item['thr-parent'] = $activity['reply-to-id'];
305
306                 if ($activity['reply-to-id'] == $activity['id']) {
307                         $item['gravity'] = GRAVITY_PARENT;
308                         $item['object-type'] = Activity\ObjectType::NOTE;
309                 } else {
310                         $item['gravity'] = GRAVITY_COMMENT;
311                         $item['object-type'] = Activity\ObjectType::COMMENT;
312                 }
313
314                 if (!empty($activity['context'])) {
315                         $item['conversation'] = $activity['context'];
316                 } elseif (!empty($activity['conversation'])) {
317                         $item['conversation'] = $activity['conversation'];
318                 }
319
320                 if (!empty($item['conversation'])) {
321                         $conversation = Post::selectFirstThread(['uri'], ['conversation' => $item['conversation']]);
322                         if (!empty($conversation)) {
323                                 Logger::debug('Got conversation', ['conversation' => $item['conversation'], 'parent' => $conversation]);
324                                 $item['parent-uri'] = $conversation['uri'];
325                         }
326                 } else {
327                         $conversation = [];
328                 }
329
330                 if (empty($activity['author']) && empty($activity['actor'])) {
331                         Logger::notice('Missing author and actor. We quit here.', ['activity' => $activity]);
332                         return [];
333                 }
334
335                 if (!DI::config()->get('system', 'fetch_parents')) {
336                         $fetch_parents = false;
337                 }
338
339                 if ($fetch_parents && empty($activity['directmessage']) && ($activity['id'] != $activity['reply-to-id']) && !Post::exists(['uri' => $activity['reply-to-id']])) {
340                         $result = self::fetchParent($activity);
341                         if (!empty($result)) {
342                                 if (($item['thr-parent'] != $result) && Post::exists(['uri' => $result])) {
343                                         $item['thr-parent'] = $result;
344                                 }
345                         } elseif (empty($conversation)) {
346                                 return [];
347                         }
348                 }
349
350                 $item['diaspora_signed_text'] = $activity['diaspora:comment'] ?? '';
351
352                 if (empty($conversation) && empty($activity['directmessage']) && ($item['gravity'] != GRAVITY_PARENT) && !Post::exists(['uri' => $item['thr-parent']])) {
353                         Logger::info('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]);
354                         return [];
355                 }
356
357                 $item['network'] = Protocol::ACTIVITYPUB;
358                 $item['author-link'] = $activity['author'];
359                 $item['author-id'] = Contact::getIdForURL($activity['author']);
360                 $item['owner-link'] = $activity['actor'];
361                 $item['owner-id'] = Contact::getIdForURL($activity['actor']);
362
363                 if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) {
364                         $item['private'] = Item::UNLISTED;
365                 } elseif (in_array(0, $activity['receiver'])) {
366                         $item['private'] = Item::PUBLIC;
367                 } else {
368                         $item['private'] = Item::PRIVATE;
369                 }
370
371                 if (!empty($activity['raw'])) {
372                         $item['source'] = $activity['raw'];
373                 }
374
375                 $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB;
376
377                 if (isset($activity['push'])) {
378                         $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL;
379                 }
380
381                 if (!empty($activity['from-relay'])) {
382                         $item['direction'] = Conversation::RELAY;
383                 }
384
385                 if ($activity['object_type'] == 'as:Article') {
386                         $item['post-type'] = Item::PT_ARTICLE;
387                 } elseif ($activity['object_type'] == 'as:Audio') {
388                         $item['post-type'] = Item::PT_AUDIO;
389                 } elseif ($activity['object_type'] == 'as:Document') {
390                         $item['post-type'] = Item::PT_DOCUMENT;
391                 } elseif ($activity['object_type'] == 'as:Event') {
392                         $item['post-type'] = Item::PT_EVENT;
393                 } elseif ($activity['object_type'] == 'as:Image') {
394                         $item['post-type'] = Item::PT_IMAGE;
395                 } elseif ($activity['object_type'] == 'as:Page') {
396                         $item['post-type'] = Item::PT_PAGE;
397                 } elseif ($activity['object_type'] == 'as:Question') {
398                         $item['post-type'] = Item::PT_POLL;
399                 } elseif ($activity['object_type'] == 'as:Video') {
400                         $item['post-type'] = Item::PT_VIDEO;
401                 } else {
402                         $item['post-type'] = Item::PT_NOTE;
403                 }
404
405                 $item['isForum'] = false;
406
407                 if (!empty($activity['thread-completion'])) {
408                         if ($activity['thread-completion'] != $item['owner-id']) {
409                                 $actor = Contact::getById($activity['thread-completion'], ['url']);
410                                 $item['causer-link'] = $actor['url'];
411                                 $item['causer-id'] = $activity['thread-completion'];
412                                 Logger::info('Use inherited actor as causer.', ['id' => $item['owner-id'], 'activity' => $activity['thread-completion'], 'owner' => $item['owner-link'], 'actor' => $actor['url']]);
413                         } else {
414                                 // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts
415                                 $item['causer-link'] = $item['owner-link'];
416                                 $item['causer-id']   = $item['owner-id'];
417                                 Logger::info('Use actor as causer.', ['id' => $item['owner-id'], 'actor' => $item['owner-link']]);
418                         }
419
420                         $item['owner-link'] = $item['author-link'];
421                         $item['owner-id'] = $item['author-id'];
422                 } else {
423                         $actor = APContact::getByURL($item['owner-link'], false);
424                         $item['isForum'] = ($actor['type'] == 'Group');
425                 }
426
427                 $item['uri'] = $activity['id'];
428
429                 if (empty($activity['published']) || empty($activity['updated'])) {
430                         DI::logger()->notice('published or updated keys are empty for activity', ['activity' => $activity, 'callstack' => System::callstack(10)]);
431                 }
432
433                 $item['created'] = DateTimeFormat::utc($activity['published'] ?? 'now');
434                 $item['edited'] = DateTimeFormat::utc($activity['updated'] ?? 'now');
435                 $guid = $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']);
436                 $item['guid'] = $activity['diaspora:guid'] ?: $guid;
437
438                 $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]);
439                 if (empty($item['uri-id'])) {
440                         Logger::warning('Unable to get a uri-id for an item uri', ['uri' => $item['uri'], 'guid' => $item['guid']]);
441                         return [];
442                 }
443
444                 $item = self::processContent($activity, $item);
445                 if (empty($item)) {
446                         Logger::info('Message was not processed');
447                         return [];
448                 }
449
450                 $item['plink'] = $activity['alternate-url'] ?? $item['uri'];
451
452                 self::storeAttachments($activity, $item);
453                 self::storeQuestion($activity, $item);
454
455                 // We received the post via AP, so we set the protocol of the server to AP
456                 $contact = Contact::getById($item['author-id'], ['gsid']);
457                 if (!empty($contact['gsid'])) {
458                         GServer::setProtocol($contact['gsid'], Post\DeliveryData::ACTIVITYPUB);
459                 }
460
461                 if ($item['author-id'] != $item['owner-id']) {
462                         $contact = Contact::getById($item['owner-id'], ['gsid']);
463                         if (!empty($contact['gsid'])) {
464                                 GServer::setProtocol($contact['gsid'], Post\DeliveryData::ACTIVITYPUB);
465                         }
466                 }
467
468                 return $item;
469         }
470
471         /**
472          * Fetch and process parent posts for the given activity
473          *
474          * @param array $activity
475          *
476          * @return string
477          */
478         private static function fetchParent(array $activity): string
479         {
480                 if (self::hasJustBeenFetched($activity['reply-to-id'])) {
481                         Logger::notice('We just have tried to fetch this activity. We don\'t try it again.', ['parent' => $activity['reply-to-id']]);
482                         return '';
483                 } 
484
485                 $recursion_depth = $activity['recursion-depth'] ?? 0;
486
487                 if ($recursion_depth < DI::config()->get('system', 'max_recursion_depth')) {
488                         Logger::notice('Parent not found. Try to refetch it.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
489                         $result = self::fetchMissingActivity($activity['reply-to-id'], $activity, '', Receiver::COMPLETION_AUTO);
490                         if (empty($result) && self::isActivityGone($activity['reply-to-id'])) {
491                                 Logger::notice('The activity is gone, the queue entry will be deleted', ['parent' => $activity['reply-to-id']]);
492                                 if (!empty($activity['entry-id'])) {
493                                         Queue::deleteById($activity['entry-id']);
494                                 }
495                                 return '';
496                         } elseif (!empty($result)) {
497                                 $exists = Post::exists(['uri' => [$result, $activity['reply-to-id']]]);
498                                 if ($exists) {
499                                         Logger::notice('The activity has been fetched and created.', ['parent' => $result]);
500                                         return $result;
501                                 } elseif (DI::config()->get('system', 'fetch_by_worker') || DI::config()->get('system', 'decoupled_receiver')) {
502                                         Logger::notice('The activity has been fetched and will hopefully be created later.', ['parent' => $result]);
503                                 } else {
504                                         Logger::notice('The activity exists but has not been created, the queue entry will be deleted.', ['parent' => $result]);
505                                         if (!empty($activity['entry-id'])) {
506                                                 Queue::deleteById($activity['entry-id']);
507                                         }
508                                 }
509                                 return '';
510                         }
511                         if (empty($result) && !DI::config()->get('system', 'fetch_by_worker')) {
512                                 return '';
513                         }
514                 } elseif (self::isActivityGone($activity['reply-to-id'])) {
515                         Logger::notice('The activity is gone. We will not spawn a worker. The queue entry will be deleted', ['parent' => $activity['reply-to-id']]);
516                         if (!empty($activity['entry-id'])) {
517                                 Queue::deleteById($activity['entry-id']);
518                         }
519                         return '';
520                 } else {
521                         Logger::notice('Recursion level is too high.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
522                 }
523
524                 if (Queue::hasWorker($activity['worker-id'] ?? 0)) {
525                         Logger::notice('There is already a worker task to fetch the post.', ['id' => $activity['id'], 'parent' => $activity['reply-to-id']]);
526                         return '';
527                 }
528
529                 if (!Fetch::hasWorker($activity['reply-to-id'])) {
530                         Logger::notice('Fetching is done by worker.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
531                         Fetch::add($activity['reply-to-id']);
532                         $activity['recursion-depth'] = 0;
533                         $wid = Worker::add(PRIORITY_HIGH, 'FetchMissingActivity', $activity['reply-to-id'], $activity, '', Receiver::COMPLETION_AUTO);
534                         Fetch::setWorkerId($activity['reply-to-id'], $wid);
535                 } else {
536                         Logger::debug('Activity will already be fetched via a worker.', ['url' => $activity['reply-to-id']]);
537                 }
538
539                 return '';
540         }
541
542         /**
543          * Check if a given activity has recently been fetched
544          *
545          * @param string $url
546          * @return boolean
547          */
548         private static function hasJustBeenFetched(string $url): bool
549         {
550                 $cachekey = self::CACHEKEY_JUST_FETCHED . $url;
551                 $time = DI::cache()->get($cachekey);
552                 if (is_null($time)) {
553                         DI::cache()->set($cachekey, time(), Duration::FIVE_MINUTES);
554                         return false;
555                 }
556                 return ($time + 300) > time();
557         }
558
559         /**
560          * Check if a given activity is no longer available
561          *
562          * @param string $url
563          *
564          * @return boolean
565          */
566         public static function isActivityGone(string $url): bool
567         {
568                 $curlResult = HTTPSignature::fetchRaw($url, 0);
569
570                 if (Network::isUrlBlocked($url)) {
571                         return true;
572                 }
573
574                 // @todo To ensure that the remote system is working correctly, we can check if the "Content-Type" contains JSON
575                 if (in_array($curlResult->getReturnCode(), [404])) {
576                         return true;
577                 }
578
579                 $object = json_decode($curlResult->getBody(), true);
580                 if (!empty($object)) {
581                         $activity = JsonLD::compact($object);
582                         if (JsonLD::fetchElement($activity, '@type') == 'as:Tombstone') {
583                                 return true;
584                         }                       
585                 }
586
587                 return false;
588         }
589         /**
590          * Delete items
591          *
592          * @param array $activity
593          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
594          * @throws \ImagickException
595          */
596         public static function deleteItem(array $activity)
597         {
598                 $owner = Contact::getIdForURL($activity['actor']);
599
600                 Logger::info('Deleting item', ['object' => $activity['object_id'], 'owner'  => $owner]);
601                 Item::markForDeletion(['uri' => $activity['object_id'], 'owner-id' => $owner]);
602                 Queue::remove($activity);
603         }
604
605         /**
606          * Prepare the item array for an activity
607          *
608          * @param array $activity Activity array
609          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
610          * @throws \ImagickException
611          */
612         public static function addTag(array $activity)
613         {
614                 if (empty($activity['object_content']) || empty($activity['object_id'])) {
615                         return;
616                 }
617
618                 foreach ($activity['receiver'] as $receiver) {
619                         $item = Post::selectFirst(['id', 'uri-id', 'origin', 'author-link'], ['uri' => $activity['target_id'], 'uid' => $receiver]);
620                         if (!DBA::isResult($item)) {
621                                 // We don't fetch missing content for this purpose
622                                 continue;
623                         }
624
625                         if (($item['author-link'] != $activity['actor']) && !$item['origin']) {
626                                 Logger::info('Not origin, not from the author, skipping update', ['id' => $item['id'], 'author' => $item['author-link'], 'actor' => $activity['actor']]);
627                                 continue;
628                         }
629
630                         Tag::store($item['uri-id'], Tag::HASHTAG, $activity['object_content'], $activity['object_id']);
631                         Logger::info('Tagged item', ['id' => $item['id'], 'tag' => $activity['object_content'], 'uri' => $activity['target_id'], 'actor' => $activity['actor']]);
632                 }
633         }
634
635         /**
636          * Prepare the item array for an activity
637          *
638          * @param array      $activity   Activity array
639          * @param string     $verb       Activity verb
640          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
641          * @throws \ImagickException
642          */
643         public static function createActivity(array $activity, string $verb)
644         {
645                 $activity['reply-to-id'] = $activity['object_id'];
646                 $item = self::createItem($activity);
647                 if (empty($item)) {
648                         return;
649                 }
650
651                 $item['verb'] = $verb;
652                 $item['thr-parent'] = $activity['object_id'];
653                 $item['gravity'] = GRAVITY_ACTIVITY;
654                 unset($item['post-type']);
655                 $item['object-type'] = Activity\ObjectType::NOTE;
656
657                 if (!empty($activity['content'])) {
658                         $item['body'] = HTML::toBBCode($activity['content']);
659                 }
660
661                 $item['diaspora_signed_text'] = $activity['diaspora:like'] ?? '';
662
663                 self::postItem($activity, $item);
664         }
665
666         /**
667          * Fetch the Uri-Id of a post for the "featured" collection
668          *
669          * @param array $activity
670          * @return null|int
671          */
672         private static function getUriIdForFeaturedCollection(array $activity)
673         {
674                 $actor = APContact::getByURL($activity['actor']);
675                 if (empty($actor)) {
676                         return null;
677                 }
678
679                 // Refetch the account when the "featured" collection is missing.
680                 // This can be removed in a future version (end of 2022 should be good).
681                 if (empty($actor['featured'])) {
682                         $actor = APContact::getByURL($activity['actor'], true);
683                         if (empty($actor)) {
684                                 return null;
685                         }
686                 }
687
688                 $parent = Post::selectFirst(['uri-id'], ['uri' => $activity['object_id']]);
689                 if (empty($parent['uri-id'])) {
690                         if (self::fetchMissingActivity($activity['object_id'], $activity, '', Receiver::COMPLETION_AUTO)) {
691                                 $parent = Post::selectFirst(['uri-id'], ['uri' => $activity['object_id']]);
692                         }
693                 }
694
695                 if (!empty($parent['uri-id'])) {
696                         return $parent['uri-id'];
697                 }
698
699                 return null;
700         }
701
702         /**
703          * Add a post to the "Featured" collection
704          *
705          * @param array $activity
706          */
707         public static function addToFeaturedCollection(array $activity)
708         {
709                 $uriid = self::getUriIdForFeaturedCollection($activity);
710                 if (empty($uriid)) {
711                         return;
712                 }
713
714                 Logger::debug('Add post to featured collection', ['uri-id' => $uriid]);
715
716                 Post\Collection::add($uriid, Post\Collection::FEATURED);
717                 Queue::remove($activity);
718         }
719
720         /**
721          * Remove a post to the "Featured" collection
722          *
723          * @param array $activity
724          */
725         public static function removeFromFeaturedCollection(array $activity)
726         {
727                 $uriid = self::getUriIdForFeaturedCollection($activity);
728                 if (empty($uriid)) {
729                         return;
730                 }
731
732                 Logger::debug('Remove post from featured collection', ['uri-id' => $uriid]);
733
734                 Post\Collection::remove($uriid, Post\Collection::FEATURED);
735                 Queue::remove($activity);
736         }
737
738         /**
739          * Create an event
740          *
741          * @param array $activity Activity array
742          * @param array $item
743          *
744          * @return int event id
745          * @throws \Exception
746          */
747         public static function createEvent(array $activity, array $item): int
748         {
749                 $event['summary']   = HTML::toBBCode($activity['name'] ?: $activity['summary']);
750                 $event['desc']      = HTML::toBBCode($activity['content']);
751                 if (!empty($activity['start-time'])) {
752                         $event['start']  = DateTimeFormat::utc($activity['start-time']);
753                 }
754                 if (!empty($activity['end-time'])) {
755                         $event['finish'] = DateTimeFormat::utc($activity['end-time']);
756                 }
757                 $event['nofinish']  = empty($event['finish']);
758                 $event['location']  = $activity['location'];
759                 $event['cid']       = $item['contact-id'];
760                 $event['uid']       = $item['uid'];
761                 $event['uri']       = $item['uri'];
762                 $event['edited']    = $item['edited'];
763                 $event['private']   = $item['private'];
764                 $event['guid']      = $item['guid'];
765                 $event['plink']     = $item['plink'];
766                 $event['network']   = $item['network'];
767                 $event['protocol']  = $item['protocol'];
768                 $event['direction'] = $item['direction'];
769                 $event['source']    = $item['source'];
770
771                 $ev = DBA::selectFirst('event', ['id'], ['uri' => $item['uri'], 'uid' => $item['uid']]);
772                 if (DBA::isResult($ev)) {
773                         $event['id'] = $ev['id'];
774                 }
775
776                 $event_id = Event::store($event);
777
778                 Logger::info('Event was stored', ['id' => $event_id]);
779
780                 return $event_id;
781         }
782
783         /**
784          * Process the content
785          *
786          * @param array $activity Activity array
787          * @param array $item
788          * @return array|bool Returns the item array or false if there was an unexpected occurrence
789          * @throws \Exception
790          */
791         private static function processContent(array $activity, array $item)
792         {
793                 if (!empty($activity['mediatype']) && ($activity['mediatype'] == 'text/markdown')) {
794                         $item['title'] = strip_tags($activity['name']);
795                         $content = Markdown::toBBCode($activity['content']);
796                 } elseif (!empty($activity['mediatype']) && ($activity['mediatype'] == 'text/bbcode')) {
797                         $item['title'] = $activity['name'];
798                         $content = $activity['content'];
799                 } else {
800                         // By default assume "text/html"
801                         $item['title'] = HTML::toBBCode($activity['name'] ?? '');
802                         $content = HTML::toBBCode($activity['content'] ?? '');
803                 }
804
805                 $item['title'] = trim(BBCode::toPlaintext($item['title']));
806
807                 if (!empty($activity['languages'])) {
808                         $item['language'] = self::processLanguages($activity['languages']);
809                 }
810
811                 if (!empty($activity['emojis'])) {
812                         $content = self::replaceEmojis($item['uri-id'], $content, $activity['emojis']);
813                 }
814
815                 $content = self::addMentionLinks($content, $activity['tags']);
816
817                 if (!empty($activity['source'])) {
818                         $item['body'] = $activity['source'];
819                         $item['raw-body'] = $content;
820                         $item['body'] = Item::improveSharedDataInBody($item);
821                 } else {
822                         $parent_uri = $item['parent-uri'] ?? $item['thr-parent'];
823                         if (empty($activity['directmessage']) && ($parent_uri != $item['uri']) && ($item['gravity'] == GRAVITY_COMMENT)) {
824                                 $parent = Post::selectFirst(['id', 'uri-id', 'private', 'author-link', 'alias'], ['uri' => $parent_uri]);
825                                 if (!DBA::isResult($parent)) {
826                                         Logger::warning('Unknown parent item.', ['uri' => $parent_uri]);
827                                         return false;
828                                 }
829                                 if (($item['private'] == Item::PRIVATE) && ($parent['private'] != Item::PRIVATE)) {
830                                         Logger::warning('Item is private but the parent is not. Dropping.', ['item-uri' => $item['uri'], 'thr-parent' => $item['thr-parent']]);
831                                         return false;
832                                 }
833
834                                 $content = self::removeImplicitMentionsFromBody($content, $parent);
835                         }
836                         $item['content-warning'] = HTML::toBBCode($activity['summary'] ?? '');
837                         $item['raw-body'] = $item['body'] = $content;
838                 }
839
840                 self::storeFromBody($item);
841                 self::storeTags($item['uri-id'], $activity['tags']);
842
843                 self::storeReceivers($item['uri-id'], $activity['receiver_urls'] ?? []);
844
845                 $item['location'] = $activity['location'];
846
847                 if (!empty($activity['latitude']) && !empty($activity['longitude'])) {
848                         $item['coord'] = $activity['latitude'] . ' ' . $activity['longitude'];
849                 }
850
851                 $item['app'] = $activity['generator'];
852
853                 return $item;
854         }
855
856         /**
857          * Store hashtags and mentions
858          *
859          * @param array $item
860          */
861         private static function storeFromBody(array $item)
862         {
863                 // Make sure to delete all existing tags (can happen when called via the update functionality)
864                 DBA::delete('post-tag', ['uri-id' => $item['uri-id']]);
865
866                 Tag::storeFromBody($item['uri-id'], $item['body'], '@!');
867         }
868
869         /**
870          * Generate a GUID out of an URL of an ActivityPub post.
871          *
872          * @param string $url message URL
873          * @return string with GUID
874          */
875         private static function getGUIDByURL(string $url): string
876         {
877                 $parsed = parse_url($url);
878
879                 $host_hash = hash('crc32', $parsed['host']);
880
881                 unset($parsed["scheme"]);
882                 unset($parsed["host"]);
883
884                 $path = implode("/", $parsed);
885
886                 return $host_hash . '-'. hash('fnv164', $path) . '-'. hash('joaat', $path);
887         }
888
889         /**
890          * Checks if an incoming message is wanted
891          *
892          * @param array $activity
893          * @param array $item
894          * @return boolean Is the message wanted?
895          */
896         private static function isSolicitedMessage(array $activity, array $item): bool
897         {
898                 // The checks are split to improve the support when searching why a message was accepted.
899                 if (count($activity['receiver']) != 1) {
900                         // The message has more than one receiver, so it is wanted.
901                         Logger::debug('Message has got several receivers - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
902                         return true;
903                 }
904
905                 if ($item['private'] == Item::PRIVATE) {
906                         // We only look at public posts here. Private posts are expected to be intentionally posted to the single receiver.
907                         Logger::debug('Message is private - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
908                         return true;
909                 }
910
911                 if (!empty($activity['from-relay'])) {
912                         // We check relay posts at another place. When it arrived here, the message is already checked.
913                         Logger::debug('Message is a relay post that is already checked - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
914                         return true;
915                 }
916
917                 if (in_array($activity['completion-mode'] ?? Receiver::COMPLETION_NONE, [Receiver::COMPLETION_MANUAL, Receiver::COMPLETION_ANNOUCE])) {
918                         // Manual completions and completions caused by reshares are allowed without any further checks.
919                         Logger::debug('Message is in completion mode - accepted', ['mode' => $activity['completion-mode'], 'uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
920                         return true;
921                 }
922
923                 if ($item['gravity'] != GRAVITY_PARENT) {
924                         // We cannot reliably check at this point if a comment or activity belongs to an accepted post or needs to be fetched
925                         // This can possibly be improved in the future.
926                         Logger::debug('Message is no parent - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
927                         return true;
928                 }
929
930                 $tags = array_column(Tag::getByURIId($item['uri-id'], [Tag::HASHTAG]), 'name');
931                 if (Relay::isSolicitedPost($tags, $item['body'], $item['author-id'], $item['uri'], Protocol::ACTIVITYPUB)) {
932                         Logger::debug('Post is accepted because of the relay settings', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
933                         return true;
934                 } else {
935                         return false;
936                 }
937         }
938
939         /**
940          * Creates an item post
941          *
942          * @param array $activity Activity data
943          * @param array $item     item array
944          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
945          * @throws \ImagickException
946          */
947         public static function postItem(array $activity, array $item)
948         {
949                 if (empty($item)) {
950                         return;
951                 }
952
953                 $stored = false;
954                 $success = false;
955                 ksort($activity['receiver']);
956
957                 if (!self::isSolicitedMessage($activity, $item)) {
958                         DBA::delete('item-uri', ['id' => $item['uri-id']]);
959                         return;
960                 }
961
962                 foreach ($activity['receiver'] as $receiver) {
963                         if ($receiver == -1) {
964                                 continue;
965                         }
966
967                         $item['uid'] = $receiver;
968
969                         $type = $activity['reception_type'][$receiver] ?? Receiver::TARGET_UNKNOWN;
970                         switch($type) {
971                                 case Receiver::TARGET_TO:
972                                         $item['post-reason'] = Item::PR_TO;
973                                         break;
974                                 case Receiver::TARGET_CC:
975                                         $item['post-reason'] = Item::PR_CC;
976                                         break;
977                                 case Receiver::TARGET_BTO:
978                                         $item['post-reason'] = Item::PR_BTO;
979                                         break;
980                                 case Receiver::TARGET_BCC:
981                                         $item['post-reason'] = Item::PR_BCC;
982                                         break;
983                                 case Receiver::TARGET_FOLLOWER:
984                                         $item['post-reason'] = Item::PR_FOLLOWER;
985                                         break;
986                                 case Receiver::TARGET_ANSWER:
987                                         $item['post-reason'] = Item::PR_COMMENT;
988                                         break;
989                                 case Receiver::TARGET_GLOBAL:
990                                         $item['post-reason'] = Item::PR_GLOBAL;
991                                         break;
992                                 default:
993                                         $item['post-reason'] = Item::PR_NONE;
994                         }
995
996                         if (!empty($activity['from-relay'])) {
997                                 $item['post-reason'] = Item::PR_RELAY;
998                         } elseif (!empty($activity['thread-completion'])) {
999                                 $item['post-reason'] = Item::PR_FETCHED;
1000                         } elseif (in_array($item['post-reason'], [Item::PR_GLOBAL, Item::PR_NONE]) && !empty($activity['push'])) {
1001                                 $item['post-reason'] = Item::PR_PUSHED;
1002                         }
1003
1004                         if ($item['isForum'] ?? false) {
1005                                 $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver);
1006                         } else {
1007                                 $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver);
1008                         }
1009
1010                         if (($receiver != 0) && empty($item['contact-id'])) {
1011                                 $item['contact-id'] = Contact::getIdForURL($activity['author']);
1012                         }
1013
1014                         if (!empty($activity['directmessage'])) {
1015                                 self::postMail($activity, $item);
1016                                 continue;
1017                         }
1018
1019                         if (!($item['isForum'] ?? false) && ($receiver != 0) && ($item['gravity'] == GRAVITY_PARENT) && !Contact::isSharingByURL($activity['author'], $receiver)) {
1020                                 if ($item['post-reason'] == Item::PR_BCC) {
1021                                         Logger::info('Top level post via BCC from a non sharer, ignoring', ['uid' => $receiver, 'contact' => $item['contact-id']]);
1022                                         continue;
1023                                 }
1024
1025                                 if (
1026                                         !empty($activity['thread-children-type'])
1027                                         && in_array($activity['thread-children-type'], Receiver::ACTIVITY_TYPES)
1028                                         && DI::pConfig()->get($receiver, 'system', 'accept_only_sharer') != Item::COMPLETION_LIKE
1029                                 ) {
1030                                         Logger::info('Top level post from thread completion from a non sharer had been initiated via an activity, ignoring',
1031                                                 ['type' => $activity['thread-children-type'], 'user' => $item['uid'], 'causer' => $item['causer-link'], 'author' => $activity['author'], 'url' => $item['uri']]);
1032                                         continue;
1033                                 }
1034                         }
1035
1036                         $is_forum = false;
1037
1038                         if ($receiver != 0) {
1039                                 $user = User::getById($receiver, ['account-type']);
1040                                 if (!empty($user['account-type'])) {
1041                                         $is_forum = ($user['account-type'] == User::ACCOUNT_TYPE_COMMUNITY);
1042                                 }
1043                         }
1044
1045                         if (!$is_forum && DI::pConfig()->get($receiver, 'system', 'accept_only_sharer') == Item::COMPLETION_NONE && ($receiver != 0) && ($item['gravity'] == GRAVITY_PARENT)) {
1046                                 $skip = !Contact::isSharingByURL($activity['author'], $receiver);
1047
1048                                 if ($skip && (($activity['type'] == 'as:Announce') || ($item['isForum'] ?? false))) {
1049                                         $skip = !Contact::isSharingByURL($activity['actor'], $receiver);
1050                                 }
1051
1052                                 if ($skip) {
1053                                         Logger::info('Skipping post', ['uid' => $receiver, 'url' => $item['uri']]);
1054                                         continue;
1055                                 }
1056
1057                                 Logger::info('Accepting post', ['uid' => $receiver, 'url' => $item['uri']]);
1058                         }
1059
1060                         if (($item['gravity'] != GRAVITY_ACTIVITY) && ($activity['object_type'] == 'as:Event')) {
1061                                 $event_id = self::createEvent($activity, $item);
1062
1063                                 $item = Event::getItemArrayForImportedId($event_id, $item);
1064                         }
1065
1066                         $item_id = Item::insert($item);
1067                         if ($item_id) {
1068                                 Logger::info('Item insertion successful', ['user' => $item['uid'], 'item_id' => $item_id]);
1069                                 $success = true;
1070                         } else {
1071                                 Logger::notice('Item insertion aborted', ['uri' => $item['uri'], 'uid' => $item['uid']]);
1072                         }
1073
1074                         if ($item['uid'] == 0) {
1075                                 $stored = $item_id;
1076                         }
1077                 }
1078
1079                 Queue::remove($activity);
1080
1081                 if ($success && Queue::hasChildren($item['uri']) && Post::exists(['uri' => $item['uri']])) {
1082                         Queue::processReplyByUri($item['uri']);
1083                 }
1084
1085                 // Store send a follow request for every reshare - but only when the item had been stored
1086                 if ($stored && ($item['private'] != Item::PRIVATE) && ($item['gravity'] == GRAVITY_PARENT) && !empty($item['author-link']) && ($item['author-link'] != $item['owner-link'])) {
1087                         $author = APContact::getByURL($item['owner-link'], false);
1088                         // We send automatic follow requests for reshared messages. (We don't need though for forum posts)
1089                         if ($author['type'] != 'Group') {
1090                                 Logger::info('Send follow request', ['uri' => $item['uri'], 'stored' => $stored, 'to' => $item['author-link']]);
1091                                 ActivityPub\Transmitter::sendFollowObject($item['uri'], $item['author-link']);
1092                         }
1093                 }
1094         }
1095
1096         /**
1097          * Store tags and mentions into the tag table
1098          *
1099          * @param integer $uriid
1100          * @param array $tags
1101          */
1102         private static function storeTags(int $uriid, array $tags = null)
1103         {
1104                 foreach ($tags as $tag) {
1105                         if (empty($tag['name']) || empty($tag['type']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
1106                                 continue;
1107                         }
1108
1109                         $hash = substr($tag['name'], 0, 1);
1110
1111                         if ($tag['type'] == 'Mention') {
1112                                 if (in_array($hash, [Tag::TAG_CHARACTER[Tag::MENTION],
1113                                         Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION],
1114                                         Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION]])) {
1115                                         $tag['name'] = substr($tag['name'], 1);
1116                                 }
1117                                 $type = Tag::IMPLICIT_MENTION;
1118
1119                                 if (!empty($tag['href'])) {
1120                                         $apcontact = APContact::getByURL($tag['href']);
1121                                         if (!empty($apcontact['name']) || !empty($apcontact['nick'])) {
1122                                                 $tag['name'] = $apcontact['name'] ?: $apcontact['nick'];
1123                                         }
1124                                 }
1125                         } elseif ($tag['type'] == 'Hashtag') {
1126                                 if ($hash == Tag::TAG_CHARACTER[Tag::HASHTAG]) {
1127                                         $tag['name'] = substr($tag['name'], 1);
1128                                 }
1129                                 $type = Tag::HASHTAG;
1130                         }
1131
1132                         if (empty($tag['name'])) {
1133                                 continue;
1134                         }
1135
1136                         Tag::store($uriid, $type, $tag['name'], $tag['href']);
1137                 }
1138         }
1139
1140         public static function storeReceivers(int $uriid, array $receivers)
1141         {
1142                 foreach (['as:to' => Tag::TO, 'as:cc' => Tag::CC, 'as:bto' => Tag::BTO, 'as:bcc' => Tag::BCC] as $element => $type) {
1143                         if (!empty($receivers[$element])) {
1144                                 foreach ($receivers[$element] as $receiver) {
1145                                         if ($receiver == ActivityPub::PUBLIC_COLLECTION) {
1146                                                 $name = Receiver::PUBLIC_COLLECTION;
1147                                         } else {
1148                                                 $name = trim(parse_url($receiver, PHP_URL_PATH), '/');
1149                                         }
1150
1151                                         $target = Tag::getTargetType($receiver);
1152                                         Logger::debug('Got target type', ['type' => $target, 'url' => $receiver]);
1153                                         Tag::store($uriid, $type, $name, $receiver, $target);
1154                                 }
1155                         }
1156                 }
1157         }
1158
1159         /**
1160          * Creates an mail post
1161          *
1162          * @param array $activity Activity data
1163          * @param array $item     item array
1164          * @return int|bool New mail table row id or false on error
1165          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1166          */
1167         private static function postMail(array $activity, array $item)
1168         {
1169                 if (($item['gravity'] != GRAVITY_PARENT) && !DBA::exists('mail', ['uri' => $item['thr-parent'], 'uid' => $item['uid']])) {
1170                         Logger::info('Parent not found, mail will be discarded.', ['uid' => $item['uid'], 'uri' => $item['thr-parent']]);
1171                         return false;
1172                 }
1173
1174                 Logger::info('Direct Message', $item);
1175
1176                 $msg = [];
1177                 $msg['uid'] = $item['uid'];
1178
1179                 $msg['contact-id'] = $item['contact-id'];
1180
1181                 $contact = Contact::getById($item['contact-id'], ['name', 'url', 'photo']);
1182                 $msg['from-name'] = $contact['name'];
1183                 $msg['from-url'] = $contact['url'];
1184                 $msg['from-photo'] = $contact['photo'];
1185
1186                 $msg['uri'] = $item['uri'];
1187                 $msg['created'] = $item['created'];
1188
1189                 $parent = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uri' => $item['thr-parent']]);
1190                 if (DBA::isResult($parent)) {
1191                         $msg['parent-uri'] = $parent['parent-uri'];
1192                         $msg['title'] = $parent['title'];
1193                 } else {
1194                         $msg['parent-uri'] = $item['thr-parent'];
1195
1196                         if (!empty($item['title'])) {
1197                                 $msg['title'] = $item['title'];
1198                         } elseif (!empty($item['content-warning'])) {
1199                                 $msg['title'] = $item['content-warning'];
1200                         } else {
1201                                 // Trying to generate a title out of the body
1202                                 $title = $item['body'];
1203
1204                                 while (preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $title, $matches)) {
1205                                         $title = $matches[3];
1206                                 }
1207
1208                                 $title = trim(BBCode::toPlaintext($title));
1209
1210                                 if (strlen($title) > 20) {
1211                                         $title = substr($title, 0, 20) . '...';
1212                                 }
1213
1214                                 $msg['title'] = $title;
1215                         }
1216                 }
1217                 $msg['body'] = $item['body'];
1218
1219                 return Mail::insert($msg);
1220         }
1221
1222         /**
1223          * Fetch featured posts from a contact with the given url
1224          *
1225          * @param string $url
1226          * @return void
1227          */
1228         public static function fetchFeaturedPosts(string $url)
1229         {
1230                 Logger::info('Fetch featured posts', ['contact' => $url]);
1231
1232                 $apcontact = APContact::getByURL($url);
1233                 if (empty($apcontact['featured'])) {
1234                         Logger::info('Contact does not have a featured collection', ['contact' => $url]);
1235                         return;
1236                 }
1237
1238                 $pcid = Contact::getIdForURL($url, 0, false);
1239                 if (empty($pcid)) {
1240                         Logger::info('Contact not found', ['contact' => $url]);
1241                         return;
1242                 }
1243
1244                 $posts = Post\Collection::selectToArrayForContact($pcid, Post\Collection::FEATURED);
1245                 if (!empty($posts)) {
1246                         $old_featured = array_column($posts, 'uri-id');
1247                 } else {
1248                         $old_featured = [];
1249                 }
1250
1251                 $featured = ActivityPub::fetchItems($apcontact['featured']);
1252                 if (empty($featured)) {
1253                         Logger::info('Contact does not have featured posts', ['contact' => $url]);
1254
1255                         foreach ($old_featured as $uri_id) {
1256                                 Post\Collection::remove($uri_id, Post\Collection::FEATURED);
1257                                 Logger::debug('Removed no longer featured post', ['uri-id' => $uri_id, 'contact' => $url]);
1258                         }
1259                         return;
1260                 }
1261
1262                 $new = 0;
1263                 $old = 0;
1264
1265                 foreach ($featured as $post) {
1266                         if (empty($post['id'])) {
1267                                 continue;
1268                         }
1269                         $id = Item::fetchByLink($post['id']);
1270                         if (!empty($id)) {
1271                                 $item = Post::selectFirst(['uri-id', 'featured'], ['id' => $id]);
1272                                 if (!empty($item['uri-id'])) {
1273                                         if (!$item['featured']) {
1274                                                 Post\Collection::add($item['uri-id'], Post\Collection::FEATURED);
1275                                                 Logger::debug('Added featured post', ['uri-id' => $item['uri-id'], 'contact' => $url]);
1276                                                 $new++;
1277                                         } else {
1278                                                 Logger::debug('Post already had been featured', ['uri-id' => $item['uri-id'], 'contact' => $url]);
1279                                                 $old++;
1280                                         }
1281
1282                                         $index = array_search($item['uri-id'], $old_featured);
1283                                         if (!($index === false)) {
1284                                                 unset($old_featured[$index]);
1285                                         }
1286                                 }
1287                         }
1288                 }
1289
1290                 foreach ($old_featured as $uri_id) {
1291                         Post\Collection::remove($uri_id, Post\Collection::FEATURED);
1292                         Logger::debug('Removed no longer featured post', ['uri-id' => $uri_id, 'contact' => $url]);
1293                 }
1294
1295                 Logger::info('Fetched featured posts', ['new' => $new, 'old' => $old, 'contact' => $url]);
1296         }
1297
1298         public static function fetchCachedActivity(string $url, int $uid): array
1299         {
1300                 $cachekey = self::CACHEKEY_FETCH_ACTIVITY . $uid . ':' . $url;
1301                 $object = DI::cache()->get($cachekey);
1302
1303                 if (!is_null($object)) {
1304                         if (!empty($object)) {
1305                                 Logger::debug('Fetch from cache', ['url' => $url, 'uid' => $uid]);
1306                         } else {
1307                                 Logger::debug('Fetch from negative cache', ['url' => $url, 'uid' => $uid]);
1308                         }
1309                         return $object;
1310                 }
1311
1312                 $object = ActivityPub::fetchContent($url, $uid);
1313                 if (empty($object)) {
1314                         Logger::notice('Activity was not fetchable, aborting.', ['url' => $url, 'uid' => $uid]);
1315                         // We perform negative caching.
1316                         DI::cache()->set($cachekey, [], Duration::FIVE_MINUTES);
1317                         return [];
1318                 }
1319
1320                 if (empty($object['id'])) {
1321                         Logger::notice('Activity has got not id, aborting. ', ['url' => $url, 'object' => $object]);
1322                         return [];
1323                 }
1324                 DI::cache()->set($cachekey, $object, Duration::FIVE_MINUTES);
1325
1326                 Logger::debug('Activity was fetched successfully', ['url' => $url, 'uid' => $uid]);
1327
1328                 return $object;
1329         }
1330
1331         /**
1332          * Fetches missing posts
1333          *
1334          * @param string     $url         message URL
1335          * @param array      $child       activity array with the child of this message
1336          * @param string     $relay_actor Relay actor
1337          * @param int        $completion  Completion mode, see Receiver::COMPLETION_*
1338          * @return string fetched message URL
1339          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1340          * @throws \ImagickException
1341          */
1342         public static function fetchMissingActivity(string $url, array $child = [], string $relay_actor = '', int $completion = Receiver::COMPLETION_MANUAL): string
1343         {
1344                 if (!empty($child['receiver'])) {
1345                         $uid = ActivityPub\Receiver::getFirstUserFromReceivers($child['receiver']);
1346                 } else {
1347                         $uid = 0;
1348                 }
1349
1350                 $object = self::fetchCachedActivity($url, $uid);
1351                 if (empty($object)) {
1352                         return '';
1353                 }
1354
1355                 $signer = [];
1356
1357                 if (!empty($object['attributedTo'])) {
1358                         $attributed_to = $object['attributedTo'];
1359                         if (is_array($attributed_to)) {
1360                                 $compacted = JsonLD::compact($object);
1361                                 $attributed_to = JsonLD::fetchElement($compacted, 'as:attributedTo', '@id');
1362                         }
1363                         $signer[] = $attributed_to;     
1364                 }
1365
1366                 if (!empty($object['actor'])) {
1367                         $object_actor = $object['actor'];
1368                 } elseif (!empty($attributed_to)) {
1369                         $object_actor = $attributed_to;
1370                 } else {
1371                         // Shouldn't happen
1372                         $object_actor = '';
1373                 }
1374
1375                 $signer[] = $object_actor;
1376
1377                 if (!empty($child['author'])) {
1378                         $actor = $child['author'];
1379                         $signer[] = $actor;
1380                 } else {
1381                         $actor = $object_actor;
1382                 }
1383
1384                 if (!empty($object['published'])) {
1385                         $published = $object['published'];
1386                 } elseif (!empty($child['published'])) {
1387                         $published = $child['published'];
1388                 } else {
1389                         $published = DateTimeFormat::utcNow();
1390                 }
1391
1392                 $activity = [];
1393                 $activity['@context'] = $object['@context'] ?? ActivityPub::CONTEXT;
1394                 unset($object['@context']);
1395                 $activity['id'] = $object['id'];
1396                 $activity['to'] = $object['to'] ?? [];
1397                 $activity['cc'] = $object['cc'] ?? [];
1398                 $activity['actor'] = $actor;
1399                 $activity['object'] = $object;
1400                 $activity['published'] = $published;
1401                 $activity['type'] = 'Create';
1402
1403                 $ldactivity = JsonLD::compact($activity);
1404
1405                 $ldactivity['recursion-depth'] = !empty($child['recursion-depth']) ? $child['recursion-depth'] + 1 : 1;
1406
1407                 if (!empty($relay_actor)) {
1408                         $ldactivity['thread-completion'] = $ldactivity['from-relay'] = Contact::getIdForURL($relay_actor);
1409                         $ldactivity['completion-mode']   = Receiver::COMPLETION_RELAY;
1410                 } elseif (!empty($child['thread-completion'])) {
1411                         $ldactivity['thread-completion'] = $child['thread-completion'];
1412                         $ldactivity['completion-mode']   = $child['completion-mode'] ?? Receiver::COMPLETION_NONE;
1413                 } else {
1414                         $ldactivity['thread-completion'] = Contact::getIdForURL($actor);
1415                         $ldactivity['completion-mode']   = $completion;
1416                 }
1417
1418                 if (!empty($child['type'])) {
1419                         $ldactivity['thread-children-type'] = $child['type'];
1420                 }
1421
1422                 if (!empty($relay_actor) && !self::acceptIncomingMessage($ldactivity, $object['id'])) {
1423                         return '';
1424                 }
1425
1426                 if (($completion == Receiver::COMPLETION_RELAY) && Queue::exists($url, 'as:Create')) {
1427                         Logger::notice('Activity has already been queued.', ['url' => $url, 'object' => $activity['id']]);
1428                 } elseif (ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity), $uid, true, false, $signer, '', $completion)) {
1429                         Logger::notice('Activity had been fetched and processed.', ['url' => $url, 'entry' => $child['entry-id'] ?? 0, 'completion' => $completion, 'object' => $activity['id']]);
1430                 } else {
1431                         Logger::notice('Activity had been fetched and will be processed later.', ['url' => $url, 'entry' => $child['entry-id'] ?? 0, 'completion' => $completion, 'object' => $activity['id']]);
1432                 }
1433
1434                 return $activity['id'];
1435         }
1436
1437         /**
1438          * Test if incoming relay messages should be accepted
1439          *
1440          * @param array $activity activity array
1441          * @param string $id      object ID
1442          * @return boolean true if message is accepted
1443          */
1444         private static function acceptIncomingMessage(array $activity, string $id): bool
1445         {
1446                 if (empty($activity['as:object'])) {
1447                         Logger::info('No object field in activity - accepted', ['id' => $id]);
1448                         return true;
1449                 }
1450
1451                 $replyto = JsonLD::fetchElement($activity['as:object'], 'as:inReplyTo', '@id');
1452                 $uriid = ItemURI::getIdByURI($replyto ?? '');
1453                 if (Post::exists(['uri-id' => $uriid])) {
1454                         Logger::info('Post is a reply to an existing post - accepted', ['id' => $id, 'uri-id' => $uriid, 'replyto' => $replyto]);
1455                         return true;
1456                 }
1457
1458                 $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id');
1459                 $authorid = Contact::getIdForURL($attributed_to);
1460
1461                 $body = HTML::toBBCode(JsonLD::fetchElement($activity['as:object'], 'as:content', '@value') ?? '');
1462
1463                 $messageTags = [];
1464                 $tags = Receiver::processTags(JsonLD::fetchElementArray($activity['as:object'], 'as:tag') ?? []);
1465                 if (!empty($tags)) {
1466                         foreach ($tags as $tag) {
1467                                 if ($tag['type'] != 'Hashtag') {
1468                                         continue;
1469                                 }
1470                                 $messageTags[] = ltrim(mb_strtolower($tag['name']), '#');
1471                         }
1472                 }
1473
1474                 return Relay::isSolicitedPost($messageTags, $body, $authorid, $id, Protocol::ACTIVITYPUB);
1475         }
1476
1477         /**
1478          * perform a "follow" request
1479          *
1480          * @param array $activity
1481          * @return void
1482          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1483          * @throws \ImagickException
1484          */
1485         public static function followUser(array $activity)
1486         {
1487                 $uid = User::getIdForURL($activity['object_id']);
1488                 if (empty($uid)) {
1489                         Queue::remove($activity);
1490                         return;
1491                 }
1492
1493                 $owner = User::getOwnerDataById($uid);
1494                 if (empty($owner)) {
1495                         return;
1496                 }
1497
1498                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1499                 if (!empty($cid)) {
1500                         self::switchContact($cid);
1501                         Contact::update(['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
1502                 }
1503
1504                 $item = [
1505                         'author-id' => Contact::getIdForURL($activity['actor']),
1506                         'author-link' => $activity['actor'],
1507                 ];
1508
1509                 // Ensure that the contact has got the right network type
1510                 self::switchContact($item['author-id']);
1511
1512                 $result = Contact::addRelationship($owner, [], $item, false, $activity['content'] ?? '');
1513                 if ($result === true) {
1514                         ActivityPub\Transmitter::sendContactAccept($item['author-link'], $activity['id'], $owner['uid']);
1515                 }
1516
1517                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1518                 if (empty($cid)) {
1519                         return;
1520                 }
1521
1522                 if ($result && DI::config()->get('system', 'transmit_pending_events') && ($owner['contact-type'] == Contact::TYPE_COMMUNITY)) {
1523                         self::transmitPendingEvents($cid, $owner['uid']);
1524                 }
1525
1526                 if (empty($contact)) {
1527                         Contact::update(['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
1528                 }
1529                 Logger::notice('Follow user ' . $uid . ' from contact ' . $cid . ' with id ' . $activity['id']);
1530                 Queue::remove($activity);
1531         }
1532
1533         /**
1534          * Transmit pending events to the new follower
1535          *
1536          * @param integer $cid Contact id
1537          * @param integer $uid User id
1538          * @return void
1539          */
1540         private static function transmitPendingEvents(int $cid, int $uid)
1541         {
1542                 $account = DBA::selectFirst('account-user-view', ['ap-inbox', 'ap-sharedinbox'], ['id' => $cid]);
1543                 $inbox = $account['ap-sharedinbox'] ?: $account['ap-inbox'];
1544
1545                 $events = DBA::select('event', ['id'], ["`uid` = ? AND `start` > ? AND `type` != ?", $uid, DateTimeFormat::utcNow(), 'birthday']);
1546                 while ($event = DBA::fetch($events)) {
1547                         $post = Post::selectFirst(['id', 'uri-id', 'created'], ['event-id' => $event['id']]);
1548                         if (empty($post)) {
1549                                 continue;
1550                         }
1551                         if (DI::config()->get('system', 'bulk_delivery')) {
1552                                 Post\Delivery::add($post['uri-id'], $uid, $inbox, $post['created'], Delivery::POST, [$cid]);
1553                                 Worker::add(PRIORITY_HIGH, 'APDelivery', '', 0, $inbox, 0);
1554                         } else {
1555                                 Worker::add(PRIORITY_HIGH, 'APDelivery', Delivery::POST, $post['id'], $inbox, $uid, [$cid], $post['uri-id']);
1556                         }
1557                 }
1558         }
1559
1560         /**
1561          * Update the given profile
1562          *
1563          * @param array $activity
1564          * @throws \Exception
1565          */
1566         public static function updatePerson(array $activity)
1567         {
1568                 if (empty($activity['object_id'])) {
1569                         return;
1570                 }
1571
1572                 Logger::info('Updating profile', ['object' => $activity['object_id']]);
1573                 Contact::updateFromProbeByURL($activity['object_id']);
1574                 Queue::remove($activity);
1575         }
1576
1577         /**
1578          * Delete the given profile
1579          *
1580          * @param array $activity
1581          * @return void
1582          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1583          */
1584         public static function deletePerson(array $activity)
1585         {
1586                 if (empty($activity['object_id']) || empty($activity['actor'])) {
1587                         Logger::info('Empty object id or actor.');
1588                         return;
1589                 }
1590
1591                 if ($activity['object_id'] != $activity['actor']) {
1592                         Logger::info('Object id does not match actor.');
1593                         return;
1594                 }
1595
1596                 $contacts = DBA::select('contact', ['id'], ['nurl' => Strings::normaliseLink($activity['object_id'])]);
1597                 while ($contact = DBA::fetch($contacts)) {
1598                         Contact::remove($contact['id']);
1599                 }
1600                 DBA::close($contacts);
1601
1602                 Logger::info('Deleted contact', ['object' => $activity['object_id']]);
1603                 Queue::remove($activity);
1604         }
1605
1606         /**
1607          * Blocks the user by the contact
1608          *
1609          * @param array $activity
1610          * @return void
1611          * @throws \Exception
1612          */
1613         public static function blockAccount(array $activity)
1614         {
1615                 $cid = Contact::getIdForURL($activity['actor']);
1616                 if (empty($cid)) {
1617                         return;
1618                 }
1619
1620                 $uid = User::getIdForURL($activity['object_id']);
1621                 if (empty($uid)) {
1622                         return;
1623                 }
1624
1625                 Contact\User::setIsBlocked($cid, $uid, true);
1626
1627                 Logger::info('Contact blocked user', ['contact' => $cid, 'user' => $uid]);
1628                 Queue::remove($activity);
1629         }
1630
1631         /**
1632          * Unblocks the user by the contact
1633          *
1634          * @param array $activity
1635          * @return void
1636          * @throws \Exception
1637          */
1638         public static function unblockAccount(array $activity)
1639         {
1640                 $cid = Contact::getIdForURL($activity['actor']);
1641                 if (empty($cid)) {
1642                         return;
1643                 }
1644
1645                 $uid = User::getIdForURL($activity['object_object']);
1646                 if (empty($uid)) {
1647                         return;
1648                 }
1649
1650                 Contact\User::setIsBlocked($cid, $uid, false);
1651
1652                 Logger::info('Contact unblocked user', ['contact' => $cid, 'user' => $uid]);
1653                 Queue::remove($activity);
1654         }
1655
1656         /**
1657          * Accept a follow request
1658          *
1659          * @param array $activity
1660          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1661          * @throws \ImagickException
1662          */
1663         public static function acceptFollowUser(array $activity)
1664         {
1665                 $uid = User::getIdForURL($activity['object_actor']);
1666                 if (empty($uid)) {
1667                         return;
1668                 }
1669
1670                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1671                 if (empty($cid)) {
1672                         Logger::info('No contact found', ['actor' => $activity['actor']]);
1673                         return;
1674                 }
1675
1676                 self::switchContact($cid);
1677
1678                 $fields = ['pending' => false];
1679
1680                 $contact = DBA::selectFirst('contact', ['rel'], ['id' => $cid]);
1681                 if ($contact['rel'] == Contact::FOLLOWER) {
1682                         $fields['rel'] = Contact::FRIEND;
1683                 }
1684
1685                 $condition = ['id' => $cid];
1686                 Contact::update($fields, $condition);
1687                 Logger::info('Accept contact request', ['contact' => $cid, 'user' => $uid]);
1688                 Queue::remove($activity);
1689         }
1690
1691         /**
1692          * Reject a follow request
1693          *
1694          * @param array $activity
1695          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1696          * @throws \ImagickException
1697          */
1698         public static function rejectFollowUser(array $activity)
1699         {
1700                 $uid = User::getIdForURL($activity['object_actor']);
1701                 if (empty($uid)) {
1702                         return;
1703                 }
1704
1705                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1706                 if (empty($cid)) {
1707                         Logger::info('No contact found', ['actor' => $activity['actor']]);
1708                         return;
1709                 }
1710
1711                 self::switchContact($cid);
1712
1713                 $contact = Contact::getById($cid, ['rel']);
1714                 if ($contact['rel'] == Contact::SHARING) {
1715                         Contact::remove($cid);
1716                         Logger::info('Rejected contact request - contact removed', ['contact' => $cid, 'user' => $uid]);
1717                 } elseif ($contact['rel'] == Contact::FRIEND) {
1718                         Contact::update(['rel' => Contact::FOLLOWER], ['id' => $cid]);
1719                 } else {
1720                         Logger::info('Rejected contact request', ['contact' => $cid, 'user' => $uid]);
1721                 }
1722                 Queue::remove($activity);
1723         }
1724
1725         /**
1726          * Undo activity like "like" or "dislike"
1727          *
1728          * @param array $activity
1729          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1730          * @throws \ImagickException
1731          */
1732         public static function undoActivity(array $activity)
1733         {
1734                 if (empty($activity['object_id'])) {
1735                         return;
1736                 }
1737
1738                 if (empty($activity['object_actor'])) {
1739                         return;
1740                 }
1741
1742                 $author_id = Contact::getIdForURL($activity['object_actor']);
1743                 if (empty($author_id)) {
1744                         return;
1745                 }
1746
1747                 Item::markForDeletion(['uri' => $activity['object_id'], 'author-id' => $author_id, 'gravity' => GRAVITY_ACTIVITY]);
1748                 Queue::remove($activity);
1749         }
1750
1751         /**
1752          * Activity to remove a follower
1753          *
1754          * @param array $activity
1755          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1756          * @throws \ImagickException
1757          */
1758         public static function undoFollowUser(array $activity)
1759         {
1760                 $uid = User::getIdForURL($activity['object_object']);
1761                 if (empty($uid)) {
1762                         return;
1763                 }
1764
1765                 $owner = User::getOwnerDataById($uid);
1766                 if (empty($owner)) {
1767                         return;
1768                 }
1769
1770                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1771                 if (empty($cid)) {
1772                         Logger::info('No contact found', ['actor' => $activity['actor']]);
1773                         return;
1774                 }
1775
1776                 self::switchContact($cid);
1777
1778                 $contact = DBA::selectFirst('contact', [], ['id' => $cid]);
1779                 if (!DBA::isResult($contact)) {
1780                         return;
1781                 }
1782
1783                 Contact::removeFollower($contact);
1784                 Logger::info('Undo following request', ['contact' => $cid, 'user' => $uid]);
1785                 Queue::remove($activity);
1786         }
1787
1788         /**
1789          * Switches a contact to AP if needed
1790          *
1791          * @param integer $cid Contact ID
1792          * @return void
1793          * @throws \Exception
1794          */
1795         private static function switchContact(int $cid)
1796         {
1797                 $contact = DBA::selectFirst('contact', ['network', 'url'], ['id' => $cid]);
1798                 if (!DBA::isResult($contact) || in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN]) || Contact::isLocal($contact['url'])) {
1799                         return;
1800                 }
1801
1802                 Logger::info('Change existing contact', ['cid' => $cid, 'previous' => $contact['network']]);
1803                 Contact::updateFromProbe($cid);
1804         }
1805
1806         /**
1807          * Collects implicit mentions like:
1808          * - the author of the parent item
1809          * - all the mentioned conversants in the parent item
1810          *
1811          * @param array $parent Item array with at least ['id', 'author-link', 'alias']
1812          * @return array
1813          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1814          */
1815         private static function getImplicitMentionList(array $parent): array
1816         {
1817                 $parent_terms = Tag::getByURIId($parent['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]);
1818
1819                 $parent_author = Contact::getByURL($parent['author-link'], false, ['url', 'nurl', 'alias']);
1820
1821                 $implicit_mentions = [];
1822                 if (empty($parent_author['url'])) {
1823                         Logger::notice('Author public contact unknown.', ['author-link' => $parent['author-link'], 'parent-id' => $parent['id']]);
1824                 } else {
1825                         $implicit_mentions[] = $parent_author['url'];
1826                         $implicit_mentions[] = $parent_author['nurl'];
1827                         $implicit_mentions[] = $parent_author['alias'];
1828                 }
1829
1830                 if (!empty($parent['alias'])) {
1831                         $implicit_mentions[] = $parent['alias'];
1832                 }
1833
1834                 foreach ($parent_terms as $term) {
1835                         $contact = Contact::getByURL($term['url'], false, ['url', 'nurl', 'alias']);
1836                         if (!empty($contact['url'])) {
1837                                 $implicit_mentions[] = $contact['url'];
1838                                 $implicit_mentions[] = $contact['nurl'];
1839                                 $implicit_mentions[] = $contact['alias'];
1840                         }
1841                 }
1842
1843                 return $implicit_mentions;
1844         }
1845
1846         /**
1847          * Strips from the body prepended implicit mentions
1848          *
1849          * @param string $body
1850          * @param array $parent
1851          * @return string
1852          */
1853         private static function removeImplicitMentionsFromBody(string $body, array $parent): string
1854         {
1855                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
1856                         return $body;
1857                 }
1858
1859                 $potential_mentions = self::getImplicitMentionList($parent);
1860
1861                 $kept_mentions = [];
1862
1863                 // Extract one prepended mention at a time from the body
1864                 while(preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $body, $matches)) {
1865                         if (!in_array($matches[2], $potential_mentions)) {
1866                                 $kept_mentions[] = $matches[1];
1867                         }
1868
1869                         $body = $matches[3];
1870                 }
1871
1872                 // Re-appending the kept mentions to the body after extraction
1873                 $kept_mentions[] = $body;
1874
1875                 return implode('', $kept_mentions);
1876         }
1877
1878         /**
1879          * Adds links to string mentions
1880          *
1881          * @param string $body
1882          * @param array  $tags
1883          * @return string
1884          */
1885         protected static function addMentionLinks(string $body, array $tags): string
1886         {
1887                 // This prevents links to be added again to Pleroma-style mention links
1888                 $body = self::normalizeMentionLinks($body);
1889
1890                 $body = BBCode::performWithEscapedTags($body, ['url'], function ($body) use ($tags) {
1891                         foreach ($tags as $tag) {
1892                                 if (empty($tag['name']) || empty($tag['type']) || empty($tag['href']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
1893                                         continue;
1894                                 }
1895
1896                                 $hash = substr($tag['name'], 0, 1);
1897                                 $name = substr($tag['name'], 1);
1898                                 if (!in_array($hash, Tag::TAG_CHARACTER)) {
1899                                         $hash = '';
1900                                         $name = $tag['name'];
1901                                 }
1902
1903                                 $body = str_replace($tag['name'], $hash . '[url=' . $tag['href'] . ']' . $name . '[/url]', $body);
1904                         }
1905
1906                         return $body;
1907                 });
1908
1909                 return $body;
1910         }
1911 }