]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - classes/Notice.php
fix call of common_find_mentions() in Notice::saveReplies()
[quix0rs-gnu-social.git] / classes / Notice.php
index 6b364fb5ced82c1acd438489f0bd038d25f75a63..3702dbcfa8e59010b1ee07aba9983eb8b1f1e3a8 100644 (file)
@@ -121,6 +121,9 @@ class Notice extends Memcached_DataObject
         $result = parent::delete();
     }
 
+    /**
+     * Extract #hashtags from this notice's content and save them to the database.
+     */
     function saveTags()
     {
         /* extract all #hastags */
@@ -129,22 +132,34 @@ class Notice extends Memcached_DataObject
             return true;
         }
 
+        /* Add them to the database */
+        return $this->saveKnownTags($match[1]);
+    }
+
+    /**
+     * Record the given set of hash tags in the db for this notice.
+     * Given tag strings will be normalized and checked for dupes.
+     */
+    function saveKnownTags($hashtags)
+    {
         //turn each into their canonical tag
         //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
-        $hashtags = array();
-        for($i=0; $i<count($match[1]); $i++) {
-            $hashtags[] = common_canonical_tag($match[1][$i]);
+        for($i=0; $i<count($hashtags); $i++) {
+            $hashtags[$i] = common_canonical_tag($hashtags[$i]);
         }
 
-        /* Add them to the database */
         foreach(array_unique($hashtags) as $hashtag) {
             /* elide characters we don't want in the tag */
             $this->saveTag($hashtag);
-            self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $tag->tag);
+            self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
         }
         return true;
     }
 
+    /**
+     * Record a single hash tag as associated with this notice.
+     * Tag format and uniqueness must be validated by caller.
+     */
     function saveTag($hashtag)
     {
         $tag = new Notice_tag();
@@ -187,13 +202,23 @@ class Notice extends Memcached_DataObject
      *              int 'location_ns' geoname namespace to interpret location_id
      *              int 'reply_to'; notice ID this is a reply to
      *              int 'repeat_of'; notice ID this is a repeat of
-     *              string 'uri' permalink to notice; defaults to local notice URL
+     *              string 'uri' unique ID for notice; defaults to local notice URL
+     *              string 'url' permalink to notice; defaults to local notice URL
+     *              string 'rendered' rendered HTML version of content
+     *              array 'replies' list of profile URIs for reply delivery in
+     *                              place of extracting @-replies from content.
+     *              array 'groups' list of group IDs to deliver to, in place of
+     *                              extracting ! tags from content
+     *              array 'tags' list of hashtag strings to save with the notice
+     *                           in place of extracting # tags from content
+     * @fixme tag override
      *
      * @return Notice
      * @throws ClientException
      */
     static function saveNew($profile_id, $content, $source, $options=null) {
         $defaults = array('uri' => null,
+                          'url' => null,
                           'reply_to' => null,
                           'repeat_of' => null);
 
@@ -256,9 +281,10 @@ class Notice extends Memcached_DataObject
         }
 
         $notice->content = $final;
-        $notice->rendered = common_render_content($final, $notice);
+
         $notice->source = $source;
         $notice->uri = $uri;
+        $notice->url = $url;
 
         // Handle repeat case
 
@@ -283,6 +309,12 @@ class Notice extends Memcached_DataObject
             $notice->location_ns = $location_ns;
         }
 
+        if (!empty($rendered)) {
+            $notice->rendered = $rendered;
+        } else {
+            $notice->rendered = common_render_content($final, $notice);
+        }
+
         if (Event::handle('StartNoticeSave', array(&$notice))) {
 
             // XXX: some of these functions write to the DB
@@ -309,7 +341,8 @@ class Notice extends Memcached_DataObject
             // the beginning of a new conversation.
 
             if (empty($notice->conversation)) {
-                $notice->conversation = $notice->id;
+                $conv = Conversation::create();
+                $notice->conversation = $conv->id;
                 $changed = true;
             }
 
@@ -324,27 +357,47 @@ class Notice extends Memcached_DataObject
 
         # Clear the cache for subscribed users, so they'll update at next request
         # XXX: someone clever could prepend instead of clearing the cache
+
         $notice->blowOnInsert();
 
-        if (common_config('queue', 'inboxes')) {
-            $qm = QueueManager::get();
-            $qm->enqueue($notice, 'distrib');
+        // Save per-notice metadata...
+
+        if (isset($replies)) {
+            $notice->saveKnownReplies($replies);
         } else {
-            $handler = new DistribQueueHandler();
-            $handler->handle($notice);
+            $notice->saveReplies();
+        }
+
+        if (isset($groups)) {
+            $notice->saveKnownGroups($groups);
+        } else {
+            $notice->saveGroups();
+        }
+
+        if (isset($tags)) {
+            $notice->saveKnownTags($tags);
+        } else {
+            $notice->saveTags();
         }
 
+        // @fixme pass in data for URLs too?
+        $notice->saveUrls();
+
+        // Prepare inbox delivery, may be queued to background.
+        $notice->distribute();
+
         return $notice;
     }
 
-    function blowOnInsert()
+    function blowOnInsert($conversation = false)
     {
         self::blow('profile:notice_ids:%d', $this->profile_id);
         self::blow('public');
 
-        if ($this->conversation != $this->id) {
-            self::blow('notice:conversation_ids:%d', $this->conversation);
-        }
+        // XXX: Before we were blowing the casche only if the notice id
+        // was not the root of the conversation.  What to do now?
+
+        self::blow('notice:conversation_ids:%d', $this->conversation);
 
         if (!empty($this->repeat_of)) {
             self::blow('notice:repeats:%d', $this->repeat_of);
@@ -681,11 +734,39 @@ class Notice extends Memcached_DataObject
         return $ni;
     }
 
-    function addToInboxes($groups, $recipients)
+    /**
+     * Adds this notice to the inboxes of each local user who should receive
+     * it, based on author subscriptions, group memberships, and @-replies.
+     *
+     * Warning: running a second time currently will make items appear
+     * multiple times in users' inboxes.
+     *
+     * @fixme make more robust against errors
+     * @fixme break up massive deliveries to smaller background tasks
+     *
+     * @param array $groups optional list of Group objects;
+     *              if left empty, will be loaded from group_inbox records
+     * @param array $recipient optional list of reply profile ids
+     *              if left empty, will be loaded from reply records
+     */
+    function addToInboxes($groups=null, $recipients=null)
     {
         $ni = $this->whoGets($groups, $recipients);
 
-        Inbox::bulkInsert($this->id, array_keys($ni));
+        $ids = array_keys($ni);
+
+        // We remove the author (if they're a local user),
+        // since we'll have already done this in distribute()
+
+        $i = array_search($this->profile_id, $ids);
+
+        if ($i !== false) {
+            unset($ids[$i]);
+        }
+
+        // Bulk insert
+
+        Inbox::bulkInsert($this->id, $ids);
 
         return;
     }
@@ -718,6 +799,42 @@ class Notice extends Memcached_DataObject
     }
 
     /**
+     * Record this notice to the given group inboxes for delivery.
+     * Overrides the regular parsing of !group markup.
+     *
+     * @param string $group_ids
+     * @fixme might prefer URIs as identifiers, as for replies?
+     *        best with generalizations on user_group to support
+     *        remote groups better.
+     */
+    function saveKnownGroups($group_ids)
+    {
+        if (!is_array($group_ids)) {
+            throw new ServerException("Bad type provided to saveKnownGroups");
+        }
+
+        $groups = array();
+        foreach ($group_ids as $id) {
+            $group = User_group::staticGet('id', $id);
+            if ($group) {
+                common_log(LOG_ERR, "Local delivery to group id $id, $group->nickname");
+                $result = $this->addToGroupInbox($group);
+                if (!$result) {
+                    common_log_db_error($gi, 'INSERT', __FILE__);
+                }
+
+                // @fixme should we save the tags here or not?
+                $groups[] = clone($group);
+            } else {
+                common_log(LOG_ERR, "Local delivery to group id $id skipped, doesn't exist");
+            }
+        }
+
+        return $groups;
+    }
+
+    /**
+     * Parse !group delivery and record targets into group_inbox.
      * @return array of Group objects
      */
     function saveGroups()
@@ -789,7 +906,7 @@ class Notice extends Memcached_DataObject
 
             $result = $gi->insert();
 
-            if (!result) {
+            if (!$result) {
                 common_log_db_error($gi, 'INSERT', __FILE__);
                 throw new ServerException(_('Problem saving group inbox.'));
             }
@@ -801,8 +918,51 @@ class Notice extends Memcached_DataObject
     }
 
     /**
+     * Save reply records indicating that this notice needs to be
+     * delivered to the local users with the given URIs.
+     *
+     * Since this is expected to be used when saving foreign-sourced
+     * messages, we won't deliver to any remote targets as that's the
+     * source service's responsibility.
+     *
+     * @fixme Unlike saveReplies() there's no mail notification here.
+     *        Move that to distrib queue handler?
+     *
+     * @param array of unique identifier URIs for recipients
+     */
+    function saveKnownReplies($uris)
+    {
+        foreach ($uris as $uri) {
+
+            $user = User::staticGet('uri', $uri);
+
+            if (!empty($user)) {
+
+                $reply = new Reply();
+
+                $reply->notice_id  = $this->id;
+                $reply->profile_id = $user->id;
+
+                $id = $reply->insert();
+
+                self::blow('reply:stream:%d', $user->id);
+            }
+        }
+
+        return;
+    }
+
+    /**
+     * Pull @-replies from this message's content in StatusNet markup format
+     * and save reply records indicating that this message needs to be
+     * delivered to those users.
+     *
+     * Side effect: local recipients get e-mail notifications here.
+     * @fixme move mail notifications to distrib?
+     *
      * @return array of integer profile IDs
      */
+
     function saveReplies()
     {
         // Don't save reply data for repeats
@@ -811,76 +971,47 @@ class Notice extends Memcached_DataObject
             return array();
         }
 
-        // Alternative reply format
-        $tname = false;
-        if (preg_match('/^T ([A-Z0-9]{1,64}) /', $this->content, $match)) {
-            $tname = $match[1];
-        }
-        // extract all @messages
-        $cnt = preg_match_all('/(?:^|\s)@([a-z0-9]{1,64})/', $this->content, $match);
-
-        $names = array();
+        $sender = Profile::staticGet($this->profile_id);
 
-        if ($cnt || $tname) {
-            // XXX: is there another way to make an array copy?
-            $names = ($tname) ? array_unique(array_merge(array(strtolower($tname)), $match[1])) : array_unique($match[1]);
-        }
+        // @todo ideally this parser information would only
+        // be calculated once.
 
-        $sender = Profile::staticGet($this->profile_id);
+        $mentions = common_find_mentions($this->content, $this);
 
         $replied = array();
 
         // store replied only for first @ (what user/notice what the reply directed,
         // we assume first @ is it)
 
-        for ($i=0; $i<count($names); $i++) {
-            $nickname = $names[$i];
-            $recipient = common_relative_profile($sender, $nickname, $this->created);
-            if (empty($recipient)) {
-                continue;
-            }
-            // Don't save replies from blocked profile to local user
-            $recipient_user = User::staticGet('id', $recipient->id);
-            if (!empty($recipient_user) && $recipient_user->hasBlocked($sender)) {
-                continue;
-            }
-            $reply = new Reply();
-            $reply->notice_id = $this->id;
-            $reply->profile_id = $recipient->id;
-            $id = $reply->insert();
-            if (!$id) {
-                $last_error = &PEAR::getStaticProperty('DB_DataObject','lastError');
-                common_log(LOG_ERR, 'DB error inserting reply: ' . $last_error->message);
-                common_server_error(sprintf(_('DB error inserting reply: %s'), $last_error->message));
-                return array();
-            } else {
-                $replied[$recipient->id] = 1;
-            }
-        }
+        foreach ($mentions as $mention) {
 
-        // Hash format replies, too
-        $cnt = preg_match_all('/(?:^|\s)@#([a-z0-9]{1,64})/', $this->content, $match);
-        if ($cnt) {
-            foreach ($match[1] as $tag) {
-                $tagged = Profile_tag::getTagged($sender->id, $tag);
-                foreach ($tagged as $t) {
-                    if (!$replied[$t->id]) {
-                        // Don't save replies from blocked profile to local user
-                        $t_user = User::staticGet('id', $t->id);
-                        if ($t_user && $t_user->hasBlocked($sender)) {
-                            continue;
-                        }
-                        $reply = new Reply();
-                        $reply->notice_id = $this->id;
-                        $reply->profile_id = $t->id;
-                        $id = $reply->insert();
-                        if (!$id) {
-                            common_log_db_error($reply, 'INSERT', __FILE__);
-                            return array();
-                        } else {
-                            $replied[$recipient->id] = 1;
-                        }
-                    }
+            foreach ($mention['mentioned'] as $mentioned) {
+
+                // skip if they're already covered
+
+                if (!empty($replied[$mentioned->id])) {
+                    continue;
+                }
+
+                // Don't save replies from blocked profile to local user
+
+                $mentioned_user = User::staticGet('id', $mentioned->id);
+                if (!empty($mentioned_user) && $mentioned_user->hasBlocked($sender)) {
+                    continue;
+                }
+
+                $reply = new Reply();
+
+                $reply->notice_id  = $this->id;
+                $reply->profile_id = $mentioned->id;
+
+                $id = $reply->insert();
+
+                if (!$id) {
+                    common_log_db_error($reply, 'INSERT', __FILE__);
+                    throw new ServerException("Couldn't save reply for {$this->id}, {$mentioned->id}");
+                } else {
+                    $replied[$mentioned->id] = 1;
                 }
             }
         }
@@ -921,8 +1052,9 @@ class Notice extends Memcached_DataObject
     }
 
     /**
-     * Same calculation as saveGroups but without the saving
-     * @fixme merge the functions
+     * Pull list of groups this notice needs to be delivered to,
+     * as previously recorded by saveGroups() or saveKnownGroups().
+     *
      * @return array of Group objects
      */
     function getGroups()
@@ -946,7 +1078,10 @@ class Notice extends Memcached_DataObject
 
         if ($gi->find()) {
             while ($gi->fetch()) {
-                $groups[] = clone($gi);
+                $group = User_group::staticGet('id', $gi->group_id);
+                if ($group) {
+                    $groups[] = $group;
+                }
             }
         }
 
@@ -963,7 +1098,12 @@ class Notice extends Memcached_DataObject
 
         if ($namespace) {
             $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
-                           'xmlns:thr' => 'http://purl.org/syndication/thread/1.0');
+                           'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
+                           'xmlns:georss' => 'http://www.georss.org/georss',
+                           'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
+                           'xmlns:media' => 'http://purl.org/syndication/atommedia',
+                           'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
+                           'xmlns:ostatus' => 'http://ostatus.org/schema/1.0');
         } else {
             $attrs = array();
         }
@@ -989,11 +1129,6 @@ class Notice extends Memcached_DataObject
             $xs->element('icon', null, $profile->avatarUrl(AVATAR_PROFILE_SIZE));
         }
 
-        $xs->elementStart('author');
-        $xs->element('name', null, $profile->nickname);
-        $xs->element('uri', null, $profile->profileurl);
-        $xs->elementEnd('author');
-
         if ($source) {
             $xs->elementEnd('source');
         }
@@ -1001,7 +1136,11 @@ class Notice extends Memcached_DataObject
         $xs->element('title', null, $this->content);
         $xs->element('summary', null, $this->content);
 
+        $xs->raw($profile->asAtomAuthor());
+        $xs->raw($profile->asActivityActor());
+
         $xs->element('link', array('rel' => 'alternate',
+                                   'type' => 'text/html',
                                    'href' => $this->bestUrl()));
 
         $xs->element('id', null, $this->uri);
@@ -1020,6 +1159,55 @@ class Notice extends Memcached_DataObject
             }
         }
 
+        if (!empty($this->conversation)) {
+
+            $conv = Conversation::staticGet('id', $this->conversation);
+
+            if (!empty($conv)) {
+                $xs->element(
+                    'link', array(
+                        'rel' => 'ostatus:conversation',
+                        'href' => $conv->uri
+                    )
+                );
+            }
+        }
+
+        $reply_ids = $this->getReplies();
+
+        foreach ($reply_ids as $id) {
+            $profile = Profile::staticGet('id', $id);
+           if (!empty($profile)) {
+                $xs->element(
+                    'link', array(
+                        'rel' => 'ostatus:attention',
+                        'href' => $profile->getUri()
+                    )
+                );
+            }
+        }
+
+        $groups = $this->getGroups();
+
+        foreach ($groups as $group) {
+            $xs->element(
+                'link', array(
+                    'rel' => 'ostatus:attention',
+                    'href' => $group->permalink()
+                )
+            );
+        }
+
+        if (!empty($this->repeat_of)) {
+            $repeat = Notice::staticGet('id', $this->repeat_of);
+            if (!empty($repeat)) {
+                $xs->element(
+                    'ostatus:forward',
+                     array('ref' => $repeat->uri, 'href' => $repeat->bestUrl())
+                );
+            }
+        }
+
         $xs->element('content', array('type' => 'html'), $this->rendered);
 
         $tag = new Notice_tag();
@@ -1047,9 +1235,7 @@ class Notice extends Memcached_DataObject
         }
 
         if (!empty($this->lat) && !empty($this->lon)) {
-            $xs->elementStart('geo', array('xmlns:georss' => 'http://www.georss.org/georss'));
             $xs->element('georss:point', null, $this->lat . ' ' . $this->lon);
-            $xs->elementEnd('geo');
         }
 
         $xs->elementEnd('entry');
@@ -1057,6 +1243,21 @@ class Notice extends Memcached_DataObject
         return $xs->getString();
     }
 
+    /**
+     * Returns an XML string fragment with a reference to a notice as an
+     * Activity Streams noun object with the given element type.
+     *
+     * Assumes that 'activity' namespace has been previously defined.
+     *
+     * @param string $element one of 'subject', 'object', 'target'
+     * @return string
+     */
+    function asActivityNoun($element)
+    {
+        $noun = ActivityObject::fromNotice($this);
+        return $noun->asString('activity:' . $element);
+    }
+
     function bestUrl()
     {
         if (!empty($this->url)) {
@@ -1182,6 +1383,10 @@ class Notice extends Memcached_DataObject
         // Figure out who that is.
 
         $sender = Profile::staticGet('id', $profile_id);
+        if (empty($sender)) {
+            return null;
+        }
+
         $recipient = common_relative_profile($sender, $nickname, common_sql_now());
 
         if (empty($recipient)) {
@@ -1378,8 +1583,6 @@ class Notice extends Memcached_DataObject
         }
 
         $reply->free();
-
-        return $ids;
     }
 
     function clearRepeats()
@@ -1449,4 +1652,60 @@ class Notice extends Memcached_DataObject
 
         $gi->free();
     }
+
+    function distribute()
+    {
+        // We always insert for the author so they don't
+        // have to wait
+
+        $user = User::staticGet('id', $this->profile_id);
+        if (!empty($user)) {
+            Inbox::insertNotice($user->id, $this->id);
+        }
+
+        if (common_config('queue', 'inboxes')) {
+            // If there's a failure, we want to _force_
+            // distribution at this point.
+            try {
+                $qm = QueueManager::get();
+                $qm->enqueue($this, 'distrib');
+            } catch (Exception $e) {
+                // If the exception isn't transient, this
+                // may throw more exceptions as DQH does
+                // its own enqueueing. So, we ignore them!
+                try {
+                    $handler = new DistribQueueHandler();
+                    $handler->handle($this);
+                } catch (Exception $e) {
+                    common_log(LOG_ERR, "emergency redistribution resulted in " . $e->getMessage());
+                }
+                // Re-throw so somebody smarter can handle it.
+                throw $e;
+            }
+        } else {
+            $handler = new DistribQueueHandler();
+            $handler->handle($this);
+        }
+    }
+
+    function insert()
+    {
+        $result = parent::insert();
+
+        if ($result) {
+            // Profile::hasRepeated() abuses pkeyGet(), so we
+            // have to clear manually
+            if (!empty($this->repeat_of)) {
+                $c = self::memcache();
+                if (!empty($c)) {
+                    $ck = self::multicacheKey('Notice',
+                                              array('profile_id' => $this->profile_id,
+                                                    'repeat_of' => $this->repeat_of));
+                    $c->delete($ck);
+                }
+            }
+        }
+
+        return $result;
+    }
 }