]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/TwitterBridge/daemons/twitterstatusfetcher.php
Merge branch 'twitterannoyances' of gitorious.org:~evan/statusnet/evans-mainline...
[quix0rs-gnu-social.git] / plugins / TwitterBridge / daemons / twitterstatusfetcher.php
1 #!/usr/bin/env php
2 <?php
3 /**
4  * StatusNet - the distributed open-source microblogging tool
5  * Copyright (C) 2008-2010, StatusNet, Inc.
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 published by
9  * the Free Software Foundation, either version 3 of the License, or
10  * (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 <http://www.gnu.org/licenses/>.
19  */
20
21 define('INSTALLDIR', realpath(dirname(__FILE__) . '/../../..'));
22
23 // Tune number of processes and how often to poll Twitter
24 // XXX: Should these things be in config.php?
25 define('MAXCHILDREN', 2);
26 define('POLL_INTERVAL', 60); // in seconds
27
28 $shortoptions = 'di::';
29 $longoptions = array('id::', 'debug');
30
31 $helptext = <<<END_OF_TRIM_HELP
32 Batch script for retrieving Twitter messages from foreign service.
33
34   -i --id              Identity (default 'generic')
35   -d --debug           Debug (lots of log output)
36
37 END_OF_TRIM_HELP;
38
39 require_once INSTALLDIR . '/scripts/commandline.inc';
40 require_once INSTALLDIR . '/lib/common.php';
41 require_once INSTALLDIR . '/lib/daemon.php';
42 require_once INSTALLDIR . '/plugins/TwitterBridge/twitter.php';
43 require_once INSTALLDIR . '/plugins/TwitterBridge/twitteroauthclient.php';
44
45 /**
46  * Fetch statuses from Twitter
47  *
48  * Fetches statuses from Twitter and inserts them as notices
49  *
50  * NOTE: an Avatar path MUST be set in config.php for this
51  * script to work, e.g.:
52  *     $config['avatar']['path'] = $config['site']['path'] . '/avatar/';
53  *
54  * @todo @fixme @gar Fix the above. For some reason $_path is always empty when
55  * this script is run, so the default avatar path is always set wrong in
56  * default.php. Therefore it must be set explicitly in config.php. --Z
57  *
58  * @category Twitter
59  * @package  StatusNet
60  * @author   Zach Copley <zach@status.net>
61  * @author   Evan Prodromou <evan@status.net>
62  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
63  * @link     http://status.net/
64  */
65
66 class TwitterStatusFetcher extends ParallelizingDaemon
67 {
68     /**
69      *  Constructor
70      *
71      * @param string  $id           the name/id of this daemon
72      * @param int     $interval     sleep this long before doing everything again
73      * @param int     $max_children maximum number of child processes at a time
74      * @param boolean $debug        debug output flag
75      *
76      * @return void
77      *
78      **/
79     function __construct($id = null, $interval = 60,
80                          $max_children = 2, $debug = null)
81     {
82         parent::__construct($id, $interval, $max_children, $debug);
83     }
84
85     /**
86      * Name of this daemon
87      *
88      * @return string Name of the daemon.
89      */
90
91     function name()
92     {
93         return ('twitterstatusfetcher.'.$this->_id);
94     }
95
96     /**
97      * Find all the Twitter foreign links for users who have requested
98      * importing of their friends' timelines
99      *
100      * @return array flinks an array of Foreign_link objects
101      */
102
103     function getObjects()
104     {
105         global $_DB_DATAOBJECT;
106         $flink = new Foreign_link();
107         $conn = &$flink->getDatabaseConnection();
108
109         $flink->service = TWITTER_SERVICE;
110         $flink->orderBy('last_noticesync');
111         $flink->find();
112
113         $flinks = array();
114
115         while ($flink->fetch()) {
116
117             if (($flink->noticesync & FOREIGN_NOTICE_RECV) ==
118                 FOREIGN_NOTICE_RECV) {
119                 $flinks[] = clone($flink);
120                 common_log(LOG_INFO, "sync: foreign id $flink->foreign_id");
121             } else {
122                 common_log(LOG_INFO, "nothing to sync");
123             }
124         }
125
126         $flink->free();
127         unset($flink);
128
129         $conn->disconnect();
130         unset($_DB_DATAOBJECT['CONNECTIONS']);
131
132         return $flinks;
133     }
134
135     function childTask($flink) {
136
137         // Each child ps needs its own DB connection
138
139         // Note: DataObject::getDatabaseConnection() creates
140         // a new connection if there isn't one already
141
142         $conn = &$flink->getDatabaseConnection();
143
144         $this->getTimeline($flink);
145
146         $flink->last_friendsync = common_sql_now();
147         $flink->update();
148
149         $conn->disconnect();
150
151         // XXX: Couldn't find a less brutal way to blow
152         // away a cached connection
153
154         global $_DB_DATAOBJECT;
155         unset($_DB_DATAOBJECT['CONNECTIONS']);
156     }
157
158     function getTimeline($flink)
159     {
160         if (empty($flink)) {
161             common_log(LOG_WARNING, $this->name() .
162                        " - Can't retrieve Foreign_link for foreign ID $fid");
163             return;
164         }
165
166         common_debug($this->name() . ' - Trying to get timeline for Twitter user ' .
167                      $flink->foreign_id);
168
169         // XXX: Biggest remaining issue - How do we know at which status
170         // to start importing?  How many statuses?  Right now I'm going
171         // with the default last 20.
172
173         $client = null;
174
175         if (TwitterOAuthClient::isPackedToken($flink->credentials)) {
176             $token = TwitterOAuthClient::unpackToken($flink->credentials);
177             $client = new TwitterOAuthClient($token->key, $token->secret);
178             common_debug($this->name() . ' - Grabbing friends timeline with OAuth.');
179         } else {
180             common_debug("Skipping friends timeline for $flink->foreign_id since not OAuth.");
181         }
182
183         $timeline = null;
184
185         $lastId = Twitter_synch_status::getLastId($flink->user_id, 'home_timeline');
186
187         try {
188             $timeline = $client->statusesHomeTimeline($lastId);
189         } catch (Exception $e) {
190             common_log(LOG_WARNING, $this->name() .
191                        ' - Twitter client unable to get friends timeline for user ' .
192                        $flink->user_id . ' - code: ' .
193                        $e->getCode() . 'msg: ' . $e->getMessage());
194         }
195
196         if (empty($timeline)) {
197             common_log(LOG_WARNING, $this->name() .  " - Empty timeline.");
198             return;
199         }
200
201         common_debug(LOG_INFO, $this->name() . ' - Retrieved ' . sizeof($timeline) . ' statuses from Twitter.');
202
203         $lastSeenId = null;
204
205         // Reverse to preserve order
206
207         foreach (array_reverse($timeline) as $status) {
208
209             $lastSeenId = $status->id;
210
211             // Hacktastic: filter out stuff coming from this StatusNet
212
213             $source = mb_strtolower(common_config('integration', 'source'));
214
215             if (preg_match("/$source/", mb_strtolower($status->source))) {
216                 common_debug($this->name() . ' - Skipping import of status ' .
217                              $status->id . ' with source ' . $source);
218                 continue;
219             }
220
221             // Don't save it if the user is protected
222             // FIXME: save it but treat it as private
223
224             if ($status->user->protected) {
225                 continue;
226             }
227
228             $notice = $this->saveStatus($status);
229
230             if (!empty($notice)) {
231                 Inbox::insertNotice($flink->user_id, $notice->id);
232             }
233         }
234
235         if (!empty($lastSeenId)) {
236             Twitter_synch_status::setLastId($flink->user_id, 'home_timeline', $lastSeenId);
237         }
238
239         // Okay, record the time we synced with Twitter for posterity
240
241         $flink->last_noticesync = common_sql_now();
242         $flink->update();
243     }
244
245     function saveStatus($status)
246     {
247         $profile = $this->ensureProfile($status->user);
248
249         if (empty($profile)) {
250             common_log(LOG_ERR, $this->name() .
251                 ' - Problem saving notice. No associated Profile.');
252             return null;
253         }
254
255         $statusUri = $this->makeStatusURI($status->user->screen_name, $status->id);
256
257         // check to see if we've already imported the status
258
259         $n2s = Notice_to_status::staticGet('status_id', $status->id);
260
261         if (!empty($n2s)) {
262             common_log(
263                 LOG_INFO,
264                 $this->name() .
265                 " - Ignoring duplicate import: {$status->id}"
266             );
267             return Notice::staticGet('id', $n2s->notice_id);
268         }
269
270         common_debug("Saving status {$status->id} with data " . print_r($status, true));
271
272         // If it's a retweet, save it as a repeat!
273
274         if (!empty($status->retweeted_status)) {
275             common_log(LOG_INFO, "Status {$status->id} is a retweet of {$status->retweeted_status->id}.");
276             $original = $this->saveStatus($status->retweeted_status);
277             if (empty($original)) {
278                 return null;
279             } else {
280                 $author = $original->getProfile();
281                 // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
282                 // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
283                 $content = sprintf(_('RT @%1$s %2$s'),
284                                    $author->nickname,
285                                    $original->content);
286
287                 if (Notice::contentTooLong($content)) {
288                     $contentlimit = Notice::maxContent();
289                     $content = mb_substr($content, 0, $contentlimit - 4) . ' ...';
290                 }
291
292                 $repeat = Notice::saveNew($profile->id,
293                                           $content,
294                                           'twitter',
295                                           array('repeat_of' => $original->id,
296                                                 'uri' => $statusUri,
297                                                 'is_local' => Notice::GATEWAY));
298                 common_log(LOG_INFO, "Saved {$repeat->id} as a repeat of {$original->id}");
299                 Notice_to_status::saveNew($repeat->id, $status->id);
300                 return $repeat;
301             }
302         }
303
304         $notice = new Notice();
305
306         $notice->profile_id = $profile->id;
307         $notice->uri        = $statusUri;
308         $notice->url        = $statusUri;
309         $notice->created    = strftime(
310             '%Y-%m-%d %H:%M:%S',
311             strtotime($status->created_at)
312         );
313
314         $notice->source     = 'twitter';
315
316         $notice->reply_to   = null;
317
318         if (!empty($status->in_reply_to_status_id)) {
319             common_log(LOG_INFO, "Status {$status->id} is a reply to status {$status->in_reply_to_status_id}");
320             $n2s = Notice_to_status::staticGet('status_id', $status->in_reply_to_status_id);
321             if (empty($n2s)) {
322                 common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
323             } else {
324                 $reply = Notice::staticGet('id', $n2s->notice_id);
325                 if (empty($reply)) {
326                     common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
327                 } else {
328                     common_log(LOG_INFO, "Found local notice {$reply->id} for status {$status->in_reply_to_status_id}");
329                     $notice->reply_to     = $reply->id;
330                     $notice->conversation = $reply->conversation;
331                 }
332             }
333         }
334
335         if (empty($notice->conversation)) {
336             $conv = Conversation::create();
337             $notice->conversation = $conv->id;
338             common_log(LOG_INFO, "No known conversation for status {$status->id} so making a new one {$conv->id}.");
339         }
340
341         $notice->is_local   = Notice::GATEWAY;
342
343         $notice->content  = html_entity_decode($status->text);
344         $notice->rendered = $this->linkify($status);
345
346         if (Event::handle('StartNoticeSave', array(&$notice))) {
347
348             $id = $notice->insert();
349
350             if (!$id) {
351                 common_log_db_error($notice, 'INSERT', __FILE__);
352                 common_log(LOG_ERR, $this->name() .
353                     ' - Problem saving notice.');
354             }
355
356             Event::handle('EndNoticeSave', array($notice));
357         }
358
359         Notice_to_status::saveNew($notice->id, $status->id);
360
361         $this->saveStatusMentions($notice, $status);
362
363         $notice->blowOnInsert();
364
365         return $notice;
366     }
367
368     /**
369      * Make an URI for a status.
370      *
371      * @param object $status status object
372      *
373      * @return string URI
374      */
375
376     function makeStatusURI($username, $id)
377     {
378         return 'http://twitter.com/'
379           . $username
380           . '/status/'
381           . $id;
382     }
383
384     /**
385      * Look up a Profile by profileurl field.  Profile::staticGet() was
386      * not working consistently.
387      *
388      * @param string $nickname   local nickname of the Twitter user
389      * @param string $profileurl the profile url
390      *
391      * @return mixed value the first Profile with that url, or null
392      */
393
394     function getProfileByUrl($nickname, $profileurl)
395     {
396         $profile = new Profile();
397         $profile->nickname = $nickname;
398         $profile->profileurl = $profileurl;
399         $profile->limit(1);
400
401         if ($profile->find()) {
402             $profile->fetch();
403             return $profile;
404         }
405
406         return null;
407     }
408
409     /**
410      * Check to see if this Twitter status has already been imported
411      *
412      * @param Profile $profile   Twitter user's local profile
413      * @param string  $statusUri URI of the status on Twitter
414      *
415      * @return mixed value a matching Notice or null
416      */
417
418     function checkDupe($profile, $statusUri)
419     {
420         $notice = new Notice();
421         $notice->uri = $statusUri;
422         $notice->profile_id = $profile->id;
423         $notice->limit(1);
424
425         if ($notice->find()) {
426             $notice->fetch();
427             return $notice;
428         }
429
430         return null;
431     }
432
433     function ensureProfile($user)
434     {
435         // check to see if there's already a profile for this user
436
437         $profileurl = 'http://twitter.com/' . $user->screen_name;
438         $profile = $this->getProfileByUrl($user->screen_name, $profileurl);
439
440         if (!empty($profile)) {
441             common_debug($this->name() .
442                          " - Profile for $profile->nickname found.");
443
444             // Check to see if the user's Avatar has changed
445
446             $this->checkAvatar($user, $profile);
447             return $profile;
448
449         } else {
450
451             common_debug($this->name() . ' - Adding profile and remote profile ' .
452                          "for Twitter user: $profileurl.");
453
454             $profile = new Profile();
455             $profile->query("BEGIN");
456
457             $profile->nickname = $user->screen_name;
458             $profile->fullname = $user->name;
459             $profile->homepage = $user->url;
460             $profile->bio = $user->description;
461             $profile->location = $user->location;
462             $profile->profileurl = $profileurl;
463             $profile->created = common_sql_now();
464
465             try {
466                 $id = $profile->insert();
467             } catch(Exception $e) {
468                 common_log(LOG_WARNING, $this->name . ' Couldn\'t insert profile - ' . $e->getMessage());
469             }
470
471             if (empty($id)) {
472                 common_log_db_error($profile, 'INSERT', __FILE__);
473                 $profile->query("ROLLBACK");
474                 return false;
475             }
476
477             // check for remote profile
478
479             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
480
481             if (empty($remote_pro)) {
482
483                 $remote_pro = new Remote_profile();
484
485                 $remote_pro->id = $id;
486                 $remote_pro->uri = $profileurl;
487                 $remote_pro->created = common_sql_now();
488
489                 try {
490                     $rid = $remote_pro->insert();
491                 } catch (Exception $e) {
492                     common_log(LOG_WARNING, $this->name() . ' Couldn\'t save remote profile - ' . $e->getMessage());
493                 }
494
495                 if (empty($rid)) {
496                     common_log_db_error($profile, 'INSERT', __FILE__);
497                     $profile->query("ROLLBACK");
498                     return false;
499                 }
500             }
501
502             $profile->query("COMMIT");
503
504             $this->saveAvatars($user, $id);
505
506             return $profile;
507         }
508     }
509
510     function checkAvatar($twitter_user, $profile)
511     {
512         global $config;
513
514         $path_parts = pathinfo($twitter_user->profile_image_url);
515
516         $newname = 'Twitter_' . $twitter_user->id . '_' .
517             $path_parts['basename'];
518
519         $oldname = $profile->getAvatar(48)->filename;
520
521         if ($newname != $oldname) {
522             common_debug($this->name() . ' - Avatar for Twitter user ' .
523                          "$profile->nickname has changed.");
524             common_debug($this->name() . " - old: $oldname new: $newname");
525
526             $this->updateAvatars($twitter_user, $profile);
527         }
528
529         if ($this->missingAvatarFile($profile)) {
530             common_debug($this->name() . ' - Twitter user ' .
531                          $profile->nickname .
532                          ' is missing one or more local avatars.');
533             common_debug($this->name() ." - old: $oldname new: $newname");
534
535             $this->updateAvatars($twitter_user, $profile);
536         }
537     }
538
539     function updateAvatars($twitter_user, $profile) {
540
541         global $config;
542
543         $path_parts = pathinfo($twitter_user->profile_image_url);
544
545         $img_root = substr($path_parts['basename'], 0, -11);
546         $ext = $path_parts['extension'];
547         $mediatype = $this->getMediatype($ext);
548
549         foreach (array('mini', 'normal', 'bigger') as $size) {
550             $url = $path_parts['dirname'] . '/' .
551                 $img_root . '_' . $size . ".$ext";
552             $filename = 'Twitter_' . $twitter_user->id . '_' .
553                 $img_root . "_$size.$ext";
554
555             $this->updateAvatar($profile->id, $size, $mediatype, $filename);
556             $this->fetchAvatar($url, $filename);
557         }
558     }
559
560     function missingAvatarFile($profile) {
561         foreach (array(24, 48, 73) as $size) {
562             $filename = $profile->getAvatar($size)->filename;
563             $avatarpath = Avatar::path($filename);
564             if (file_exists($avatarpath) == FALSE) {
565                 return true;
566             }
567         }
568         return false;
569     }
570
571     function getMediatype($ext)
572     {
573         $mediatype = null;
574
575         switch (strtolower($ext)) {
576         case 'jpg':
577             $mediatype = 'image/jpg';
578             break;
579         case 'gif':
580             $mediatype = 'image/gif';
581             break;
582         default:
583             $mediatype = 'image/png';
584         }
585
586         return $mediatype;
587     }
588
589     function saveAvatars($user, $id)
590     {
591         global $config;
592
593         $path_parts = pathinfo($user->profile_image_url);
594         $ext = $path_parts['extension'];
595         $end = strlen('_normal' . $ext);
596         $img_root = substr($path_parts['basename'], 0, -($end+1));
597         $mediatype = $this->getMediatype($ext);
598
599         foreach (array('mini', 'normal', 'bigger') as $size) {
600             $url = $path_parts['dirname'] . '/' .
601                 $img_root . '_' . $size . ".$ext";
602             $filename = 'Twitter_' . $user->id . '_' .
603                 $img_root . "_$size.$ext";
604
605             if ($this->fetchAvatar($url, $filename)) {
606                 $this->newAvatar($id, $size, $mediatype, $filename);
607             } else {
608                 common_log(LOG_WARNING, $id() .
609                            " - Problem fetching Avatar: $url");
610             }
611         }
612     }
613
614     function updateAvatar($profile_id, $size, $mediatype, $filename) {
615
616         common_debug($this->name() . " - Updating avatar: $size");
617
618         $profile = Profile::staticGet($profile_id);
619
620         if (empty($profile)) {
621             common_debug($this->name() . " - Couldn't get profile: $profile_id!");
622             return;
623         }
624
625         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
626         $avatar = $profile->getAvatar($sizes[$size]);
627
628         // Delete the avatar, if present
629
630         if ($avatar) {
631             $avatar->delete();
632         }
633
634         $this->newAvatar($profile->id, $size, $mediatype, $filename);
635     }
636
637     function newAvatar($profile_id, $size, $mediatype, $filename)
638     {
639         global $config;
640
641         $avatar = new Avatar();
642         $avatar->profile_id = $profile_id;
643
644         switch($size) {
645         case 'mini':
646             $avatar->width  = 24;
647             $avatar->height = 24;
648             break;
649         case 'normal':
650             $avatar->width  = 48;
651             $avatar->height = 48;
652             break;
653         default:
654
655             // Note: Twitter's big avatars are a different size than
656             // StatusNet's (StatusNet's = 96)
657
658             $avatar->width  = 73;
659             $avatar->height = 73;
660         }
661
662         $avatar->original = 0; // we don't have the original
663         $avatar->mediatype = $mediatype;
664         $avatar->filename = $filename;
665         $avatar->url = Avatar::url($filename);
666
667         $avatar->created = common_sql_now();
668
669         try {
670             $id = $avatar->insert();
671         } catch (Exception $e) {
672             common_log(LOG_WARNING, $this->name() . ' Couldn\'t insert avatar - ' . $e->getMessage());
673         }
674
675         if (empty($id)) {
676             common_log_db_error($avatar, 'INSERT', __FILE__);
677             return null;
678         }
679
680         common_debug($this->name() .
681                      " - Saved new $size avatar for $profile_id.");
682
683         return $id;
684     }
685
686     /**
687      * Fetch a remote avatar image and save to local storage.
688      *
689      * @param string $url avatar source URL
690      * @param string $filename bare local filename for download
691      * @return bool true on success, false on failure
692      */
693     function fetchAvatar($url, $filename)
694     {
695         common_debug($this->name() . " - Fetching Twitter avatar: $url");
696
697         $request = HTTPClient::start();
698         $response = $request->get($url);
699         if ($response->isOk()) {
700             $avatarfile = Avatar::path($filename);
701             $ok = file_put_contents($avatarfile, $response->getBody());
702             if (!$ok) {
703                 common_log(LOG_WARNING, $this->name() .
704                            " - Couldn't open file $filename");
705                 return false;
706             }
707         } else {
708             return false;
709         }
710
711         return true;
712     }
713
714     const URL = 1;
715     const HASHTAG = 2;
716     const MENTION = 3;
717
718     function linkify($status)
719     {
720         $text = $status->text;
721
722         if (empty($status->entities)) {
723             return $text;
724         }
725
726         // Move all the entities into order so we can
727         // replace them in reverse order and thus
728         // not mess up their indices
729
730         $toReplace = array();
731
732         if (!empty($status->entities->urls)) {
733             foreach ($status->entities->urls as $url) {
734                 $toReplace[$url->indices[0]] = array(self::URL, $url);
735             }
736         }
737
738         if (!empty($status->entities->hashtags)) {
739             foreach ($status->entities->hashtags as $hashtag) {
740                 $toReplace[$hashtag->indices[0]] = array(self::HASHTAG, $hashtag);
741             }
742         }
743
744         if (!empty($status->entities->user_mentions)) {
745             foreach ($status->entities->user_mentions as $mention) {
746                 $toReplace[$mention->indices[0]] = array(self::MENTION, $mention);
747             }
748         }
749
750         // sort in reverse order by key
751
752         krsort($toReplace);
753
754         foreach ($toReplace as $part) {
755             list($type, $object) = $part;
756             switch($type) {
757             case self::URL:
758                 $linkText = $this->makeUrlLink($object);
759                 break;
760             case self::HASHTAG:
761                 $linkText = $this->makeHashtagLink($object);
762                 break;
763             case self::MENTION:
764                 $linkText = $this->makeMentionLink($object);
765                 break;
766             default:
767                 continue;
768             }
769             $text = substr_replace($text,
770                                    $linkText,
771                                    $object->indices[0],
772                                    $object->indices[1] - $object->indices[0]);
773         }
774         return $text;
775     }
776
777     function makeUrlLink($object)
778     {
779         return "<a href='{$object->url}' class='extlink'>{$object->url}</a>";
780     }
781
782     function makeHashtagLink($object)
783     {
784         return "#<a href='https://twitter.com/search?q=%23{$object->text}' class='hashtag'>{$object->text}</a>";
785     }
786
787     function makeMentionLink($object)
788     {
789         return "@<a href='http://twitter.com/{$object->screen_name}' title='{$object->name}'>{$object->screen_name}</a>";
790     }
791
792     function saveStatusMentions($notice, $status)
793     {
794         $mentions = array();
795
796         if (empty($status->entities) || empty($status->entities->user_mentions)) {
797             return;
798         }
799
800         foreach ($status->entities->user_mentions as $mention) {
801             $flink = Foreign_link::getByForeignID($mention->id, TWITTER_SERVICE);
802             if (!empty($flink)) {
803                 $user = User::staticGet('id', $flink->user_id);
804                 if (!empty($user)) {
805                     $reply = new Reply();
806                     $reply->notice_id  = $notice->id;
807                     $reply->profile_id = $user->id;
808                     common_log(LOG_INFO, __METHOD__ . ": saving reply: notice {$notice->id} to profile {$user->id}");
809                     $id = $reply->insert();
810                 }
811             }
812         }
813     }
814 }
815
816 $id    = null;
817 $debug = null;
818
819 if (have_option('i')) {
820     $id = get_option_value('i');
821 } else if (have_option('--id')) {
822     $id = get_option_value('--id');
823 } else if (count($args) > 0) {
824     $id = $args[0];
825 } else {
826     $id = null;
827 }
828
829 if (have_option('d') || have_option('debug')) {
830     $debug = true;
831 }
832
833 $fetcher = new TwitterStatusFetcher($id, 60, 2, $debug);
834 $fetcher->runOnce();
835