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