]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - plugins/TwitterBridge/daemons/twitterstatusfetcher.php
Merge remote branch 'gitorious/0.9.x' into 0.9.x
[quix0rs-gnu-social.git] / plugins / TwitterBridge / daemons / twitterstatusfetcher.php
index b4ca12be23bee66796c2d186db7329058ee20517..590fa2954d1ac467c65d2f23a97917a9b1ef59e6 100755 (executable)
@@ -2,7 +2,7 @@
 <?php
 /**
  * StatusNet - the distributed open-source microblogging tool
- * Copyright (C) 2008, 2009, StatusNet, Inc.
+ * Copyright (C) 2008-2010, StatusNet, Inc.
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as published by
@@ -40,14 +40,20 @@ require_once INSTALLDIR . '/scripts/commandline.inc';
 require_once INSTALLDIR . '/lib/common.php';
 require_once INSTALLDIR . '/lib/daemon.php';
 require_once INSTALLDIR . '/plugins/TwitterBridge/twitter.php';
-require_once INSTALLDIR . '/plugins/TwitterBridge/twitterbasicauthclient.php';
 require_once INSTALLDIR . '/plugins/TwitterBridge/twitteroauthclient.php';
 
 /**
- * Fetcher for statuses from Twitter
+ * Fetch statuses from Twitter
  *
- * Fetches statuses from Twitter and inserts them as notices in local
- * system.
+ * Fetches statuses from Twitter and inserts them as notices
+ *
+ * NOTE: an Avatar path MUST be set in config.php for this
+ * script to work, e.g.:
+ *     $config['avatar']['path'] = $config['site']['path'] . '/avatar/';
+ *
+ * @todo @fixme @gar Fix the above. For some reason $_path is always empty when
+ * this script is run, so the default avatar path is always set wrong in
+ * default.php. Therefore it must be set explicitly in config.php. --Z
  *
  * @category Twitter
  * @package  StatusNet
@@ -56,10 +62,6 @@ require_once INSTALLDIR . '/plugins/TwitterBridge/twitteroauthclient.php';
  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  * @link     http://status.net/
  */
-
-// NOTE: an Avatar path MUST be set in config.php for this
-// script to work: e.g.: $config['avatar']['path'] = '/statusnet/avatar';
-
 class TwitterStatusFetcher extends ParallelizingDaemon
 {
     /**
@@ -84,7 +86,6 @@ class TwitterStatusFetcher extends ParallelizingDaemon
      *
      * @return string Name of the daemon.
      */
-
     function name()
     {
         return ('twitterstatusfetcher.'.$this->_id);
@@ -96,11 +97,9 @@ class TwitterStatusFetcher extends ParallelizingDaemon
      *
      * @return array flinks an array of Foreign_link objects
      */
-
     function getObjects()
     {
         global $_DB_DATAOBJECT;
-
         $flink = new Foreign_link();
         $conn = &$flink->getDatabaseConnection();
 
@@ -131,12 +130,10 @@ class TwitterStatusFetcher extends ParallelizingDaemon
     }
 
     function childTask($flink) {
-
         // Each child ps needs its own DB connection
 
         // Note: DataObject::getDatabaseConnection() creates
         // a new connection if there isn't one already
-
         $conn = &$flink->getDatabaseConnection();
 
         $this->getTimeline($flink);
@@ -148,7 +145,6 @@ class TwitterStatusFetcher extends ParallelizingDaemon
 
         // XXX: Couldn't find a less brutal way to blow
         // away a cached connection
-
         global $_DB_DATAOBJECT;
         unset($_DB_DATAOBJECT['CONNECTIONS']);
     }
@@ -164,10 +160,6 @@ class TwitterStatusFetcher extends ParallelizingDaemon
         common_debug($this->name() . ' - Trying to get timeline for Twitter user ' .
                      $flink->foreign_id);
 
-        // XXX: Biggest remaining issue - How do we know at which status
-        // to start importing?  How many statuses?  Right now I'm going
-        // with the default last 20.
-
         $client = null;
 
         if (TwitterOAuthClient::isPackedToken($flink->credentials)) {
@@ -175,14 +167,17 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             $client = new TwitterOAuthClient($token->key, $token->secret);
             common_debug($this->name() . ' - Grabbing friends timeline with OAuth.');
         } else {
-            $client = new TwitterBasicAuthClient($flink);
-            common_debug($this->name() . ' - Grabbing friends timeline with basic auth.');
+            common_debug("Skipping friends timeline for $flink->foreign_id since not OAuth.");
         }
 
         $timeline = null;
 
+        $lastId = Twitter_synch_status::getLastId($flink->foreign_id, 'home_timeline');
+
+        common_debug("Got lastId value '{$lastId}' for foreign id '{$flink->foreign_id}' and timeline 'home_timeline'");
+
         try {
-            $timeline = $client->statusesFriendsTimeline();
+            $timeline = $client->statusesHomeTimeline($lastId);
         } catch (Exception $e) {
             common_log(LOG_WARNING, $this->name() .
                        ' - Twitter client unable to get friends timeline for user ' .
@@ -195,12 +190,12 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             return;
         }
 
+        common_debug(LOG_INFO, $this->name() . ' - Retrieved ' . sizeof($timeline) . ' statuses from Twitter.');
+
         // Reverse to preserve order
 
         foreach (array_reverse($timeline) as $status) {
-
             // Hacktastic: filter out stuff coming from this StatusNet
-
             $source = mb_strtolower(common_config('integration', 'source'));
 
             if (preg_match("/$source/", mb_strtolower($status->source))) {
@@ -209,26 +204,32 @@ class TwitterStatusFetcher extends ParallelizingDaemon
                 continue;
             }
 
-            $notice = null;
+            // Don't save it if the user is protected
+            // FIXME: save it but treat it as private
+            if ($status->user->protected) {
+                continue;
+            }
 
-            $notice = $this->saveStatus($status, $flink);
+            $notice = $this->saveStatus($status);
 
             if (!empty($notice)) {
-                common_broadcast_notice($notice);
+                Inbox::insertNotice($flink->user_id, $notice->id);
             }
         }
 
-        // Okay, record the time we synced with Twitter for posterity
+        if (!empty($timeline)) {
+            Twitter_synch_status::setLastId($flink->foreign_id, 'home_timeline', $timeline[0]->id);
+            common_debug("Set lastId value '{$timeline[0]->id}' for foreign id '{$flink->foreign_id}' and timeline 'home_timeline'");
+        }
 
+        // Okay, record the time we synced with Twitter for posterity
         $flink->last_noticesync = common_sql_now();
         $flink->update();
     }
 
-    function saveStatus($status, $flink)
+    function saveStatus($status)
     {
-        $id = $this->ensureProfile($status->user);
-
-        $profile = Profile::staticGet($id);
+        $profile = $this->ensureProfile($status->user);
 
         if (empty($profile)) {
             common_log(LOG_ERR, $this->name() .
@@ -236,63 +237,182 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             return null;
         }
 
-        // XXX: change of screen name?
-
-        $uri = 'http://twitter.com/' . $status->user->screen_name .
-            '/status/' . $status->id;
+        $statusUri = $this->makeStatusURI($status->user->screen_name, $status->id);
 
         // check to see if we've already imported the status
+        $n2s = Notice_to_status::staticGet('status_id', $status->id);
+
+        if (!empty($n2s)) {
+            common_log(
+                LOG_INFO,
+                $this->name() .
+                " - Ignoring duplicate import: {$status->id}"
+            );
+            return Notice::staticGet('id', $n2s->notice_id);
+        }
 
-        $notice = Notice::staticGet('uri', $uri);
+        // If it's a retweet, save it as a repeat!
+        if (!empty($status->retweeted_status)) {
+            common_log(LOG_INFO, "Status {$status->id} is a retweet of {$status->retweeted_status->id}.");
+            $original = $this->saveStatus($status->retweeted_status);
+            if (empty($original)) {
+                return null;
+            } else {
+                $author = $original->getProfile();
+                // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
+                // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
+                $content = sprintf(_m('RT @%1$s %2$s'),
+                                   $author->nickname,
+                                   $original->content);
+
+                if (Notice::contentTooLong($content)) {
+                    $contentlimit = Notice::maxContent();
+                    $content = mb_substr($content, 0, $contentlimit - 4) . ' ...';
+                }
+
+                $repeat = Notice::saveNew($profile->id,
+                                          $content,
+                                          'twitter',
+                                          array('repeat_of' => $original->id,
+                                                'uri' => $statusUri,
+                                                'is_local' => Notice::GATEWAY));
+                common_log(LOG_INFO, "Saved {$repeat->id} as a repeat of {$original->id}");
+                Notice_to_status::saveNew($repeat->id, $status->id);
+                return $repeat;
+            }
+        }
 
-        if (empty($notice)) {
+        $notice = new Notice();
 
-            // XXX: transaction here?
+        $notice->profile_id = $profile->id;
+        $notice->uri        = $statusUri;
+        $notice->url        = $statusUri;
+        $notice->created    = strftime(
+            '%Y-%m-%d %H:%M:%S',
+            strtotime($status->created_at)
+        );
 
-            $notice = new Notice();
+        $notice->source     = 'twitter';
 
-            $notice->profile_id = $id;
-            $notice->uri        = $uri;
-            $notice->created    = strftime('%Y-%m-%d %H:%M:%S',
-                                           strtotime($status->created_at));
-            $notice->content    = common_shorten_links($status->text); // XXX
-            $notice->rendered   = common_render_content($notice->content, $notice);
-            $notice->source     = 'twitter';
-            $notice->reply_to   = null; // XXX: lookup reply
-            $notice->is_local   = Notice::GATEWAY;
+        $notice->reply_to   = null;
 
-            if (Event::handle('StartNoticeSave', array(&$notice))) {
-                $id = $notice->insert();
-                Event::handle('EndNoticeSave', array($notice));
+        if (!empty($status->in_reply_to_status_id)) {
+            common_log(LOG_INFO, "Status {$status->id} is a reply to status {$status->in_reply_to_status_id}");
+            $n2s = Notice_to_status::staticGet('status_id', $status->in_reply_to_status_id);
+            if (empty($n2s)) {
+                common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
+            } else {
+                $reply = Notice::staticGet('id', $n2s->notice_id);
+                if (empty($reply)) {
+                    common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
+                } else {
+                    common_log(LOG_INFO, "Found local notice {$reply->id} for status {$status->in_reply_to_status_id}");
+                    $notice->reply_to     = $reply->id;
+                    $notice->conversation = $reply->conversation;
+                }
             }
+        }
 
+        if (empty($notice->conversation)) {
+            $conv = Conversation::create();
+            $notice->conversation = $conv->id;
+            common_log(LOG_INFO, "No known conversation for status {$status->id} so making a new one {$conv->id}.");
         }
 
-        if (!Notice_inbox::pkeyGet(array('notice_id' => $notice->id,
-                                         'user_id' => $flink->user_id))) {
-            // Add to inbox
-            $inbox = new Notice_inbox();
+        $notice->is_local   = Notice::GATEWAY;
 
-            $inbox->user_id   = $flink->user_id;
-            $inbox->notice_id = $notice->id;
-            $inbox->created   = $notice->created;
-            $inbox->source    = NOTICE_INBOX_SOURCE_GATEWAY; // From a private source
+        $notice->content  = html_entity_decode($status->text);
+        $notice->rendered = $this->linkify($status);
 
-            $inbox->insert();
+        if (Event::handle('StartNoticeSave', array(&$notice))) {
 
+            $id = $notice->insert();
+
+            if (!$id) {
+                common_log_db_error($notice, 'INSERT', __FILE__);
+                common_log(LOG_ERR, $this->name() .
+                    ' - Problem saving notice.');
+            }
+
+            Event::handle('EndNoticeSave', array($notice));
         }
 
-        $notice->blowCaches();
+        Notice_to_status::saveNew($notice->id, $status->id);
+
+        $this->saveStatusMentions($notice, $status);
+
+        $notice->blowOnInsert();
 
         return $notice;
     }
 
+    /**
+     * Make an URI for a status.
+     *
+     * @param object $status status object
+     *
+     * @return string URI
+     */
+    function makeStatusURI($username, $id)
+    {
+        return 'http://twitter.com/'
+          . $username
+          . '/status/'
+          . $id;
+    }
+
+    /**
+     * Look up a Profile by profileurl field.  Profile::staticGet() was
+     * not working consistently.
+     *
+     * @param string $nickname   local nickname of the Twitter user
+     * @param string $profileurl the profile url
+     *
+     * @return mixed value the first Profile with that url, or null
+     */
+    function getProfileByUrl($nickname, $profileurl)
+    {
+        $profile = new Profile();
+        $profile->nickname = $nickname;
+        $profile->profileurl = $profileurl;
+        $profile->limit(1);
+
+        if ($profile->find()) {
+            $profile->fetch();
+            return $profile;
+        }
+
+        return null;
+    }
+
+    /**
+     * Check to see if this Twitter status has already been imported
+     *
+     * @param Profile $profile   Twitter user's local profile
+     * @param string  $statusUri URI of the status on Twitter
+     *
+     * @return mixed value a matching Notice or null
+     */
+    function checkDupe($profile, $statusUri)
+    {
+        $notice = new Notice();
+        $notice->uri = $statusUri;
+        $notice->profile_id = $profile->id;
+        $notice->limit(1);
+
+        if ($notice->find()) {
+            $notice->fetch();
+            return $notice;
+        }
+
+        return null;
+    }
+
     function ensureProfile($user)
     {
         // check to see if there's already a profile for this user
-
         $profileurl = 'http://twitter.com/' . $user->screen_name;
-        $profile = Profile::staticGet('profileurl', $profileurl);
+        $profile = $this->getProfileByUrl($user->screen_name, $profileurl);
 
         if (!empty($profile)) {
             common_debug($this->name() .
@@ -301,7 +421,7 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             // Check to see if the user's Avatar has changed
 
             $this->checkAvatar($user, $profile);
-            return $profile->id;
+            return $profile;
 
         } else {
             common_debug($this->name() . ' - Adding profile and remote profile ' .
@@ -318,7 +438,11 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             $profile->profileurl = $profileurl;
             $profile->created = common_sql_now();
 
-            $id = $profile->insert();
+            try {
+                $id = $profile->insert();
+            } catch(Exception $e) {
+                common_log(LOG_WARNING, $this->name . ' Couldn\'t insert profile - ' . $e->getMessage());
+            }
 
             if (empty($id)) {
                 common_log_db_error($profile, 'INSERT', __FILE__);
@@ -331,14 +455,17 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
 
             if (empty($remote_pro)) {
-
                 $remote_pro = new Remote_profile();
 
                 $remote_pro->id = $id;
                 $remote_pro->uri = $profileurl;
                 $remote_pro->created = common_sql_now();
 
-                $rid = $remote_pro->insert();
+                try {
+                    $rid = $remote_pro->insert();
+                } catch (Exception $e) {
+                    common_log(LOG_WARNING, $this->name() . ' Couldn\'t save remote profile - ' . $e->getMessage());
+                }
 
                 if (empty($rid)) {
                     common_log_db_error($profile, 'INSERT', __FILE__);
@@ -351,7 +478,7 @@ class TwitterStatusFetcher extends ParallelizingDaemon
 
             $this->saveAvatars($user, $id);
 
-            return $id;
+            return $profile;
         }
     }
 
@@ -382,7 +509,6 @@ class TwitterStatusFetcher extends ParallelizingDaemon
 
             $this->updateAvatars($twitter_user, $profile);
         }
-
     }
 
     function updateAvatars($twitter_user, $profile) {
@@ -407,17 +533,13 @@ class TwitterStatusFetcher extends ParallelizingDaemon
     }
 
     function missingAvatarFile($profile) {
-
         foreach (array(24, 48, 73) as $size) {
-
             $filename = $profile->getAvatar($size)->filename;
             $avatarpath = Avatar::path($filename);
-
             if (file_exists($avatarpath) == FALSE) {
                 return true;
             }
         }
-
         return false;
     }
 
@@ -458,7 +580,7 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             if ($this->fetchAvatar($url, $filename)) {
                 $this->newAvatar($id, $size, $mediatype, $filename);
             } else {
-                common_log(LOG_WARNING, $this->id() .
+                common_log(LOG_WARNING, $id() .
                            " - Problem fetching Avatar: $url");
             }
         }
@@ -479,7 +601,6 @@ class TwitterStatusFetcher extends ParallelizingDaemon
         $avatar = $profile->getAvatar($sizes[$size]);
 
         // Delete the avatar, if present
-
         if ($avatar) {
             $avatar->delete();
         }
@@ -504,10 +625,8 @@ class TwitterStatusFetcher extends ParallelizingDaemon
             $avatar->height = 48;
             break;
         default:
-
             // Note: Twitter's big avatars are a different size than
             // StatusNet's (StatusNet's = 96)
-
             $avatar->width  = 73;
             $avatar->height = 73;
         }
@@ -519,7 +638,11 @@ class TwitterStatusFetcher extends ParallelizingDaemon
 
         $avatar->created = common_sql_now();
 
-        $id = $avatar->insert();
+        try {
+            $id = $avatar->insert();
+        } catch (Exception $e) {
+            common_log(LOG_WARNING, $this->name() . ' Couldn\'t insert avatar - ' . $e->getMessage());
+        }
 
         if (empty($id)) {
             common_log_db_error($avatar, 'INSERT', __FILE__);
@@ -559,6 +682,122 @@ class TwitterStatusFetcher extends ParallelizingDaemon
 
         return true;
     }
+
+    const URL = 1;
+    const HASHTAG = 2;
+    const MENTION = 3;
+
+    function linkify($status)
+    {
+        $text = $status->text;
+
+        if (empty($status->entities)) {
+            common_log(LOG_WARNING, "No entities data for {$status->id}; trying to fake up links ourselves.");
+            $text = common_replace_urls_callback($text, 'common_linkify');
+            $text = preg_replace('/(^|\&quot\;|\'|\(|\[|\{|\s+)#([\pL\pN_\-\.]{1,64})/e', "'\\1#'.TwitterStatusFetcher::tagLink('\\2')", $text);
+            $text = preg_replace('/(^|\s+)@([a-z0-9A-Z_]{1,64})/e', "'\\1@'.TwitterStatusFetcher::atLink('\\2')", $text);
+            return $text;
+        }
+
+        // Move all the entities into order so we can
+        // replace them in reverse order and thus
+        // not mess up their indices
+
+        $toReplace = array();
+
+        if (!empty($status->entities->urls)) {
+            foreach ($status->entities->urls as $url) {
+                $toReplace[$url->indices[0]] = array(self::URL, $url);
+            }
+        }
+
+        if (!empty($status->entities->hashtags)) {
+            foreach ($status->entities->hashtags as $hashtag) {
+                $toReplace[$hashtag->indices[0]] = array(self::HASHTAG, $hashtag);
+            }
+        }
+
+        if (!empty($status->entities->user_mentions)) {
+            foreach ($status->entities->user_mentions as $mention) {
+                $toReplace[$mention->indices[0]] = array(self::MENTION, $mention);
+            }
+        }
+
+        // sort in reverse order by key
+
+        krsort($toReplace);
+
+        foreach ($toReplace as $part) {
+            list($type, $object) = $part;
+            switch($type) {
+            case self::URL:
+                $linkText = $this->makeUrlLink($object);
+                break;
+            case self::HASHTAG:
+                $linkText = $this->makeHashtagLink($object);
+                break;
+            case self::MENTION:
+                $linkText = $this->makeMentionLink($object);
+                break;
+            default:
+                continue;
+            }
+            $text = mb_substr($text, 0, $object->indices[0]) . $linkText . mb_substr($text, $object->indices[1]);
+        }
+        return $text;
+    }
+
+    function makeUrlLink($object)
+    {
+        return "<a href='{$object->url}' class='extlink'>{$object->url}</a>";
+    }
+
+    function makeHashtagLink($object)
+    {
+        return "#" . self::tagLink($object->text);
+    }
+
+    function makeMentionLink($object)
+    {
+        return "@".self::atLink($object->screen_name, $object->name);
+    }
+
+    static function tagLink($tag)
+    {
+        return "<a href='https://twitter.com/search?q=%23{$tag}' class='hashtag'>{$tag}</a>";
+    }
+
+    static function atLink($screenName, $fullName=null)
+    {
+        if (!empty($fullName)) {
+            return "<a href='http://twitter.com/{$screenName}' title='{$fullName}'>{$screenName}</a>";
+        } else {
+            return "<a href='http://twitter.com/{$screenName}'>{$screenName}</a>";
+        }
+    }
+
+    function saveStatusMentions($notice, $status)
+    {
+        $mentions = array();
+
+        if (empty($status->entities) || empty($status->entities->user_mentions)) {
+            return;
+        }
+
+        foreach ($status->entities->user_mentions as $mention) {
+            $flink = Foreign_link::getByForeignID($mention->id, TWITTER_SERVICE);
+            if (!empty($flink)) {
+                $user = User::staticGet('id', $flink->user_id);
+                if (!empty($user)) {
+                    $reply = new Reply();
+                    $reply->notice_id  = $notice->id;
+                    $reply->profile_id = $user->id;
+                    common_log(LOG_INFO, __METHOD__ . ": saving reply: notice {$notice->id} to profile {$user->id}");
+                    $id = $reply->insert();
+                }
+            }
+        }
+    }
 }
 
 $id    = null;
@@ -580,4 +819,3 @@ if (have_option('d') || have_option('debug')) {
 
 $fetcher = new TwitterStatusFetcher($id, 60, 2, $debug);
 $fetcher->runOnce();
-