3 * @file src/Protocol/ActivityPub/Processor.php
5 namespace Friendica\Protocol\ActivityPub;
7 use Friendica\Database\DBA;
8 use Friendica\Core\Protocol;
9 use Friendica\Model\Conversation;
10 use Friendica\Model\Contact;
11 use Friendica\Model\APContact;
12 use Friendica\Model\Item;
13 use Friendica\Model\Event;
14 use Friendica\Model\User;
15 use Friendica\Content\Text\HTML;
16 use Friendica\Util\JsonLD;
17 use Friendica\Core\Config;
18 use Friendica\Protocol\ActivityPub;
21 * ActivityPub Processor Protocol class
26 * Converts mentions from Pleroma into the Friendica format
30 * @return converted body
32 private static function convertMentions($body)
34 $URLSearchString = "^\[\]";
35 $body = preg_replace("/\[url\=([$URLSearchString]*)\]([#@!])(.*?)\[\/url\]/ism", '$2[url=$1]$3[/url]', $body);
41 * Constructs a string with tags for a given tag array
44 * @param boolean $sensitive
46 * @return string with tags
48 private static function constructTagList($tags, $sensitive)
55 foreach ($tags as $tag) {
56 if (in_array(defaults($tag, 'type', ''), ['Mention', 'Hashtag'])) {
57 if (!empty($tag_text)) {
61 $tag_text .= substr($tag['name'], 0, 1) . '[url=' . $tag['href'] . ']' . substr($tag['name'], 1) . '[/url]';
65 /// @todo add nsfw for $sensitive
71 * Add attachment data to the item array
73 * @param array $attachments
78 private static function constructAttachList($attachments, $item)
80 if (empty($attachments)) {
84 foreach ($attachments as $attach) {
85 $filetype = strtolower(substr($attach['mediaType'], 0, strpos($attach['mediaType'], '/')));
86 if ($filetype == 'image') {
87 $item['body'] .= "\n[img]" . $attach['url'] . '[/img]';
89 if (!empty($item["attach"])) {
90 $item["attach"] .= ',';
94 if (!isset($attach['length'])) {
95 $attach['length'] = "0";
97 $item["attach"] .= '[attach]href="'.$attach['url'].'" length="'.$attach['length'].'" type="'.$attach['mediaType'].'" title="'.defaults($attach, 'name', '').'"[/attach]';
105 * Prepares data for a message
107 * @param array $activity Activity array
109 public static function createItem($activity)
112 $item['verb'] = ACTIVITY_POST;
113 $item['parent-uri'] = $activity['reply-to-id'];
115 if ($activity['reply-to-id'] == $activity['id']) {
116 $item['gravity'] = GRAVITY_PARENT;
117 $item['object-type'] = ACTIVITY_OBJ_NOTE;
119 $item['gravity'] = GRAVITY_COMMENT;
120 $item['object-type'] = ACTIVITY_OBJ_COMMENT;
123 if (($activity['id'] != $activity['reply-to-id']) && !Item::exists(['uri' => $activity['reply-to-id']])) {
124 logger('Parent ' . $activity['reply-to-id'] . ' not found. Try to refetch it.');
125 self::fetchMissingActivity($activity['reply-to-id'], $activity);
128 self::postItem($activity, $item);
132 * Prepare the item array for a "like"
134 * @param array $activity Activity array
136 public static function likeItem($activity)
139 $item['verb'] = ACTIVITY_LIKE;
140 $item['parent-uri'] = $activity['object_id'];
141 $item['gravity'] = GRAVITY_ACTIVITY;
142 $item['object-type'] = ACTIVITY_OBJ_NOTE;
144 self::postItem($activity, $item);
150 * @param array $activity
152 public static function deleteItem($activity)
154 $owner = Contact::getIdForURL($activity['actor']);
156 logger('Deleting item ' . $activity['object_id'] . ' from ' . $owner, LOGGER_DEBUG);
157 Item::delete(['uri' => $activity['object_id'], 'owner-id' => $owner]);
161 * Prepare the item array for a "dislike"
163 * @param array $activity Activity array
165 public static function dislikeItem($activity)
168 $item['verb'] = ACTIVITY_DISLIKE;
169 $item['parent-uri'] = $activity['object_id'];
170 $item['gravity'] = GRAVITY_ACTIVITY;
171 $item['object-type'] = ACTIVITY_OBJ_NOTE;
173 self::postItem($activity, $item);
179 * @param array $activity Activity array
181 public static function createEvent($activity, $item)
183 $event['summary'] = $activity['name'];
184 $event['desc'] = $activity['content'];
185 $event['start'] = $activity['start-time'];
186 $event['finish'] = $activity['end-time'];
187 $event['nofinish'] = empty($event['finish']);
188 $event['location'] = $activity['location'];
189 $event['adjust'] = true;
190 $event['cid'] = $item['contact-id'];
191 $event['uid'] = $item['uid'];
192 $event['uri'] = $item['uri'];
193 $event['edited'] = $item['edited'];
194 $event['private'] = $item['private'];
195 $event['guid'] = $item['guid'];
196 $event['plink'] = $item['plink'];
198 $condition = ['uri' => $item['uri'], 'uid' => $item['uid']];
199 $ev = DBA::selectFirst('event', ['id'], $condition);
200 if (DBA::isResult($ev)) {
201 $event['id'] = $ev['id'];
204 $event_id = Event::store($event);
205 logger('Event '.$event_id.' was stored', LOGGER_DEBUG);
209 * Creates an item post
211 * @param array $activity Activity data
212 * @param array $item item array
214 private static function postItem($activity, $item)
216 /// @todo What to do with $activity['context']?
218 if (($item['gravity'] != GRAVITY_PARENT) && !Item::exists(['uri' => $item['parent-uri']])) {
219 logger('Parent ' . $item['parent-uri'] . ' not found, message will be discarded.', LOGGER_DEBUG);
223 $item['network'] = Protocol::ACTIVITYPUB;
224 $item['private'] = !in_array(0, $activity['receiver']);
225 $item['author-link'] = $activity['author'];
226 $item['author-id'] = Contact::getIdForURL($activity['author'], 0, true);
228 if (empty($activity['thread-completion'])) {
229 $item['owner-link'] = $activity['actor'];
230 $item['owner-id'] = Contact::getIdForURL($activity['actor'], 0, true);
232 logger('Ignoring actor because of thread completion.', LOGGER_DEBUG);
233 $item['owner-link'] = $item['author-link'];
234 $item['owner-id'] = $item['author-id'];
237 $item['uri'] = $activity['id'];
238 $item['created'] = $activity['published'];
239 $item['edited'] = $activity['updated'];
240 $item['guid'] = $activity['diaspora:guid'];
241 $item['title'] = HTML::toBBCode($activity['name']);
242 $item['content-warning'] = HTML::toBBCode($activity['summary']);
243 $item['body'] = self::convertMentions(HTML::toBBCode($activity['content']));
245 if (($activity['object_type'] == 'as:Video') && !empty($activity['alternate-url'])) {
246 $item['body'] .= "\n[video]" . $activity['alternate-url'] . '[/video]';
249 $item['location'] = $activity['location'];
251 if (!empty($item['latitude']) && !empty($item['longitude'])) {
252 $item['coord'] = $item['latitude'] . ' ' . $item['longitude'];
255 $item['tag'] = self::constructTagList($activity['tags'], $activity['sensitive']);
256 $item['app'] = $activity['generator'];
257 $item['plink'] = defaults($activity, 'alternate-url', $item['uri']);
258 $item['diaspora_signed_text'] = defaults($activity, 'diaspora:comment', '');
260 $item = self::constructAttachList($activity['attachments'], $item);
262 if (!empty($activity['source'])) {
263 $item['body'] = $activity['source'];
266 foreach ($activity['receiver'] as $receiver) {
267 $item['uid'] = $receiver;
268 $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver, true);
270 if (($receiver != 0) && empty($item['contact-id'])) {
271 $item['contact-id'] = Contact::getIdForURL($activity['author'], 0, true);
274 if ($activity['object_type'] == 'as:Event') {
275 self::createEvent($activity, $item);
278 $item_id = Item::insert($item);
279 logger('Storing for user ' . $item['uid'] . ': ' . $item_id);
284 * Fetches missing posts
289 private static function fetchMissingActivity($url, $child)
291 if (Config::get('system', 'ostatus_full_threads')) {
295 $object = ActivityPub::fetchContent($url);
296 if (empty($object)) {
297 logger('Activity ' . $url . ' was not fetchable, aborting.');
302 $activity['@context'] = $object['@context'];
303 unset($object['@context']);
304 $activity['id'] = $object['id'];
305 $activity['to'] = defaults($object, 'to', []);
306 $activity['cc'] = defaults($object, 'cc', []);
307 $activity['actor'] = $child['author'];
308 $activity['object'] = $object;
309 $activity['published'] = defaults($object, 'published', $child['published']);
310 $activity['type'] = 'Create';
312 $ldactivity = JsonLD::compact($activity);
314 $ldactivity['thread-completion'] = true;
316 ActivityPub\Receiver::processActivity($ldactivity);
317 logger('Activity ' . $url . ' had been fetched and processed.');
321 * perform a "follow" request
323 * @param array $activity
325 public static function followUser($activity)
327 $uid = User::getIdForURL($activity['object_id']);
332 $owner = User::getOwnerDataById($uid);
334 $cid = Contact::getIdForURL($activity['actor'], $uid);
336 self::switchContact($cid);
337 $contact = DBA::selectFirst('contact', [], ['id' => $cid, 'network' => Protocol::NATIVE_SUPPORT]);
342 $item = ['author-id' => Contact::getIdForURL($activity['actor']),
343 'author-link' => $activity['actor']];
345 // Ensure that the contact has got the right network type
346 self::switchContact($item['author-id']);
348 Contact::addRelationship($owner, $contact, $item);
349 $cid = Contact::getIdForURL($activity['actor'], $uid);
354 DBA::update('contact', ['hub-verify' => $activity['id']], ['id' => $cid]);
355 logger('Follow user ' . $uid . ' from contact ' . $cid . ' with id ' . $activity['id']);
359 * Update the given profile
361 * @param array $activity
363 public static function updatePerson($activity)
365 if (empty($activity['object_id'])) {
369 logger('Updating profile for ' . $activity['object_id'], LOGGER_DEBUG);
370 APContact::getByURL($activity['object_id'], true);
374 * Delete the given profile
376 * @param array $activity
378 public static function deletePerson($activity)
380 if (empty($activity['object_id']) || empty($activity['actor'])) {
381 logger('Empty object id or actor.', LOGGER_DEBUG);
385 if ($activity['object_id'] != $activity['actor']) {
386 logger('Object id does not match actor.', LOGGER_DEBUG);
390 $contacts = DBA::select('contact', ['id'], ['nurl' => normalise_link($activity['object_id'])]);
391 while ($contact = DBA::fetch($contacts)) {
392 Contact::remove($contact['id']);
394 DBA::close($contacts);
396 logger('Deleted contact ' . $activity['object_id'], LOGGER_DEBUG);
400 * Accept a follow request
402 * @param array $activity
404 public static function acceptFollowUser($activity)
406 $uid = User::getIdForURL($activity['object_actor']);
411 $owner = User::getOwnerDataById($uid);
413 $cid = Contact::getIdForURL($activity['actor'], $uid);
415 logger('No contact found for ' . $activity['actor'], LOGGER_DEBUG);
419 self::switchContact($cid);
421 $fields = ['pending' => false];
423 $contact = DBA::selectFirst('contact', ['rel'], ['id' => $cid]);
424 if ($contact['rel'] == Contact::FOLLOWER) {
425 $fields['rel'] = Contact::FRIEND;
428 $condition = ['id' => $cid];
429 DBA::update('contact', $fields, $condition);
430 logger('Accept contact request from contact ' . $cid . ' for user ' . $uid, LOGGER_DEBUG);
434 * Reject a follow request
436 * @param array $activity
438 public static function rejectFollowUser($activity)
440 $uid = User::getIdForURL($activity['object_actor']);
445 $owner = User::getOwnerDataById($uid);
447 $cid = Contact::getIdForURL($activity['actor'], $uid);
449 logger('No contact found for ' . $activity['actor'], LOGGER_DEBUG);
453 self::switchContact($cid);
455 if (DBA::exists('contact', ['id' => $cid, 'rel' => Contact::SHARING, 'pending' => true])) {
456 Contact::remove($cid);
457 logger('Rejected contact request from contact ' . $cid . ' for user ' . $uid . ' - contact had been removed.', LOGGER_DEBUG);
459 logger('Rejected contact request from contact ' . $cid . ' for user ' . $uid . '.', LOGGER_DEBUG);
464 * Undo activity like "like" or "dislike"
466 * @param array $activity
468 public static function undoActivity($activity)
470 if (empty($activity['object_id'])) {
474 if (empty($activity['object_actor'])) {
478 $author_id = Contact::getIdForURL($activity['object_actor']);
479 if (empty($author_id)) {
483 Item::delete(['uri' => $activity['object_id'], 'author-id' => $author_id, 'gravity' => GRAVITY_ACTIVITY]);
487 * Activity to remove a follower
489 * @param array $activity
491 public static function undoFollowUser($activity)
493 $uid = User::getIdForURL($activity['object_object']);
498 $owner = User::getOwnerDataById($uid);
500 $cid = Contact::getIdForURL($activity['actor'], $uid);
502 logger('No contact found for ' . $activity['actor'], LOGGER_DEBUG);
506 self::switchContact($cid);
508 $contact = DBA::selectFirst('contact', [], ['id' => $cid]);
509 if (!DBA::isResult($contact)) {
513 Contact::removeFollower($owner, $contact);
514 logger('Undo following request from contact ' . $cid . ' for user ' . $uid, LOGGER_DEBUG);
518 * Switches a contact to AP if needed
520 * @param integer $cid Contact ID
522 private static function switchContact($cid)
524 $contact = DBA::selectFirst('contact', ['network'], ['id' => $cid, 'network' => Protocol::NATIVE_SUPPORT]);
525 if (!DBA::isResult($contact) || ($contact['network'] == Protocol::ACTIVITYPUB)) {
529 logger('Change existing contact ' . $cid . ' from ' . $contact['network'] . ' to ActivityPub.');
530 Contact::updateFromProbe($cid, Protocol::ACTIVITYPUB);