]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/TwitterBridge/daemons/twitterstatusfetcher.php
Some fixes to make the twitterstatusfetcher behave better in a multi-site configuration
[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/twitterbasicauthclient.php';
44 require_once INSTALLDIR . '/plugins/TwitterBridge/twitteroauthclient.php';
45
46 /**
47  * Fetch statuses from Twitter
48  *
49  * Fetches statuses from Twitter and inserts them as notices
50  *
51  * NOTE: an Avatar path MUST be set in config.php for this
52  * script to work, e.g.:
53  *     $config['avatar']['path'] = $config['site']['path'] . '/avatar/';
54  *
55  * @todo @fixme @gar Fix the above. For some reason $_path is always empty when
56  * this script is run, so the default avatar path is always set wrong in
57  * default.php. Therefore it must be set explicitly in config.php. --Z
58  *
59  * @category Twitter
60  * @package  StatusNet
61  * @author   Zach Copley <zach@status.net>
62  * @author   Evan Prodromou <evan@status.net>
63  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
64  * @link     http://status.net/
65  */
66
67 class TwitterStatusFetcher extends ParallelizingDaemon
68 {
69     /**
70      *  Constructor
71      *
72      * @param string  $id           the name/id of this daemon
73      * @param int     $interval     sleep this long before doing everything again
74      * @param int     $max_children maximum number of child processes at a time
75      * @param boolean $debug        debug output flag
76      *
77      * @return void
78      *
79      **/
80     function __construct($id = null, $interval = 60,
81                          $max_children = 2, $debug = null)
82     {
83         parent::__construct($id, $interval, $max_children, $debug);
84     }
85
86     /**
87      * Name of this daemon
88      *
89      * @return string Name of the daemon.
90      */
91
92     function name()
93     {
94         return ('twitterstatusfetcher.'.$this->_id);
95     }
96
97     /**
98      * Find all the Twitter foreign links for users who have requested
99      * importing of their friends' timelines
100      *
101      * @return array flinks an array of Foreign_link objects
102      */
103
104     function getObjects()
105     {
106         global $_DB_DATAOBJECT;
107
108         $flink = new Foreign_link();
109         $conn = &$flink->getDatabaseConnection();
110
111         $flink->service = TWITTER_SERVICE;
112         $flink->orderBy('last_noticesync');
113         $flink->find();
114
115         $flinks = array();
116
117         while ($flink->fetch()) {
118
119             if (($flink->noticesync & FOREIGN_NOTICE_RECV) ==
120                 FOREIGN_NOTICE_RECV) {
121                 $flinks[] = clone($flink);
122                 common_log(LOG_INFO, "sync: foreign id $flink->foreign_id");
123             } else {
124                 common_log(LOG_INFO, "nothing to sync");
125             }
126         }
127
128         $flink->free();
129         unset($flink);
130
131         $conn->disconnect();
132         unset($_DB_DATAOBJECT['CONNECTIONS']);
133
134         return $flinks;
135     }
136
137     function childTask($flink) {
138
139         // Each child ps needs its own DB connection
140
141         // Note: DataObject::getDatabaseConnection() creates
142         // a new connection if there isn't one already
143
144         $conn = &$flink->getDatabaseConnection();
145
146         $this->getTimeline($flink);
147
148         $flink->last_friendsync = common_sql_now();
149         $flink->update();
150
151         $conn->disconnect();
152
153         // XXX: Couldn't find a less brutal way to blow
154         // away a cached connection
155
156         global $_DB_DATAOBJECT;
157         unset($_DB_DATAOBJECT['CONNECTIONS']);
158     }
159
160     function getTimeline($flink)
161     {
162         if (empty($flink)) {
163             common_log(LOG_WARNING, $this->name() .
164                        " - Can't retrieve Foreign_link for foreign ID $fid");
165             return;
166         }
167
168         common_debug($this->name() . ' - Trying to get timeline for Twitter user ' .
169                      $flink->foreign_id);
170
171         // XXX: Biggest remaining issue - How do we know at which status
172         // to start importing?  How many statuses?  Right now I'm going
173         // with the default last 20.
174
175         $client = null;
176
177         if (TwitterOAuthClient::isPackedToken($flink->credentials)) {
178             $token = TwitterOAuthClient::unpackToken($flink->credentials);
179             $client = new TwitterOAuthClient($token->key, $token->secret);
180             common_debug($this->name() . ' - Grabbing friends timeline with OAuth.');
181         } else {
182             $client = new TwitterBasicAuthClient($flink);
183             common_debug($this->name() . ' - Grabbing friends timeline with basic auth.');
184         }
185
186         $timeline = null;
187
188         try {
189             $timeline = $client->statusesFriendsTimeline();
190         } catch (Exception $e) {
191             common_log(LOG_WARNING, $this->name() .
192                        ' - Twitter client unable to get friends timeline for user ' .
193                        $flink->user_id . ' - code: ' .
194                        $e->getCode() . 'msg: ' . $e->getMessage());
195         }
196
197         if (empty($timeline)) {
198             common_log(LOG_WARNING, $this->name() .  " - Empty timeline.");
199             return;
200         }
201
202         common_debug(LOG_INFO, $this->name() . ' - Retrieved ' . sizeof($timeline) . ' statuses from Twitter.');
203
204         // Reverse to preserve order
205
206         foreach (array_reverse($timeline) as $status) {
207
208             // Hacktastic: filter out stuff coming from this StatusNet
209
210             $source = mb_strtolower(common_config('integration', 'source'));
211
212             if (preg_match("/$source/", mb_strtolower($status->source))) {
213                 common_debug($this->name() . ' - Skipping import of status ' .
214                              $status->id . ' with source ' . $source);
215                 continue;
216             }
217
218             $this->saveStatus($status, $flink);
219         }
220
221         // Okay, record the time we synced with Twitter for posterity
222
223         $flink->last_noticesync = common_sql_now();
224         $flink->update();
225     }
226
227     function saveStatus($status, $flink)
228     {
229         $profile = $this->ensureProfile($status->user);
230
231         if (empty($profile)) {
232             common_log(LOG_ERR, $this->name() .
233                 ' - Problem saving notice. No associated Profile.');
234             return;
235         }
236
237         $statusUri = 'http://twitter.com/'
238             . $status->user->screen_name
239             . '/status/'
240             . $status->id;
241
242         // check to see if we've already imported the status
243
244         $dupe = $this->checkDupe($profile, $statusUri);
245
246         if (!empty($dupe)) {
247             common_log(
248                 LOG_INFO,
249                 $this->name() .
250                 " - Ignoring duplicate import: $statusUri"
251             );
252             return;
253         }
254
255         $notice = new Notice();
256
257         $notice->profile_id = $profile->id;
258         $notice->uri        = $statusUri;
259         $notice->url        = $statusUri;
260         $notice->created    = strftime(
261             '%Y-%m-%d %H:%M:%S',
262             strtotime($status->created_at)
263         );
264
265         $notice->source     = 'twitter';
266         $notice->reply_to   = null;
267         $notice->is_local   = Notice::GATEWAY;
268
269         $notice->content    = common_shorten_links($status->text);
270         $notice->rendered   = common_render_content(
271             $notice->content,
272             $notice
273         );
274
275         if (Event::handle('StartNoticeSave', array(&$notice))) {
276
277             $id = $notice->insert();
278
279             if (!$id) {
280                 common_log_db_error($notice, 'INSERT', __FILE__);
281                 common_log(LOG_ERR, $this->name() .
282                     ' - Problem saving notice.');
283             }
284
285             Event::handle('EndNoticeSave', array($notice));
286         }
287
288         $orig = clone($notice);
289         $conv = Conversation::create();
290
291         $notice->conversation = $conv->id;
292
293         if (!$notice->update($orig)) {
294             common_log_db_error($notice, 'UPDATE', __FILE__);
295             common_log(LOG_ERR, $this->name() .
296                 ' - Problem saving notice.');
297         }
298
299         Inbox::insertNotice($flink->user_id, $notice->id);
300         $notice->blowOnInsert();
301
302         return $notice;
303     }
304
305     /**
306      * Look up a Profile by profileurl field.  Profile::staticGet() was
307      * not working consistently.
308      *
309      * @param string $nickname   local nickname of the Twitter user
310      * @param string $profileurl the profile url
311      *
312      * @return mixed value the first Profile with that url, or null
313      */
314
315     function getProfileByUrl($nickname, $profileurl)
316     {
317         $profile = new Profile();
318         $profile->nickname = $nickname;
319         $profile->profileurl = $profileurl;
320         $profile->limit(1);
321
322         if ($profile->find()) {
323             $profile->fetch();
324             return $profile;
325         }
326
327         return null;
328     }
329
330     /**
331      * Check to see if this Twitter status has already been imported
332      *
333      * @param Profile $profile   Twitter user's local profile
334      * @param string  $statusUri URI of the status on Twitter
335      *
336      * @return mixed value a matching Notice or null
337      */
338
339     function checkDupe($profile, $statusUri)
340     {
341         $notice = new Notice();
342         $notice->uri = $statusUri;
343         $notice->profile_id = $profile->id;
344         $notice->limit(1);
345
346         if ($notice->find()) {
347             $notice->fetch();
348             return $notice;
349         }
350
351         return null;
352     }
353
354     function ensureProfile($user)
355     {
356         // check to see if there's already a profile for this user
357
358         $profileurl = 'http://twitter.com/' . $user->screen_name;
359         $profile = $this->getProfileByUrl($user->screen_name, $profileurl);
360
361         if (!empty($profile)) {
362             common_debug($this->name() .
363                          " - Profile for $profile->nickname found.");
364
365             // Check to see if the user's Avatar has changed
366
367             $this->checkAvatar($user, $profile);
368             return $profile;
369
370         } else {
371
372             common_debug($this->name() . ' - Adding profile and remote profile ' .
373                          "for Twitter user: $profileurl.");
374
375             $profile = new Profile();
376             $profile->query("BEGIN");
377
378             $profile->nickname = $user->screen_name;
379             $profile->fullname = $user->name;
380             $profile->homepage = $user->url;
381             $profile->bio = $user->description;
382             $profile->location = $user->location;
383             $profile->profileurl = $profileurl;
384             $profile->created = common_sql_now();
385
386             try {
387                 $id = $profile->insert();
388             } catch(Exception $e) {
389                 common_log(LOG_WARNING, $this->name . ' Couldn\'t insert profile - ' . $e->getMessage());
390             }
391
392             if (empty($id)) {
393                 common_log_db_error($profile, 'INSERT', __FILE__);
394                 $profile->query("ROLLBACK");
395                 return false;
396             }
397
398             // check for remote profile
399
400             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
401
402             if (empty($remote_pro)) {
403
404                 $remote_pro = new Remote_profile();
405
406                 $remote_pro->id = $id;
407                 $remote_pro->uri = $profileurl;
408                 $remote_pro->created = common_sql_now();
409
410                 try {
411                     $rid = $remote_pro->insert();
412                 } catch (Exception $e) {
413                     common_log(LOG_WARNING, $this->name() . ' Couldn\'t save remote profile - ' . $e->getMessage());
414                 }
415
416                 if (empty($rid)) {
417                     common_log_db_error($profile, 'INSERT', __FILE__);
418                     $profile->query("ROLLBACK");
419                     return false;
420                 }
421             }
422
423             $profile->query("COMMIT");
424
425             $this->saveAvatars($user, $id);
426
427             return $profile;
428         }
429     }
430
431     function checkAvatar($twitter_user, $profile)
432     {
433         global $config;
434
435         $path_parts = pathinfo($twitter_user->profile_image_url);
436
437         $newname = 'Twitter_' . $twitter_user->id . '_' .
438             $path_parts['basename'];
439
440         $oldname = $profile->getAvatar(48)->filename;
441
442         if ($newname != $oldname) {
443             common_debug($this->name() . ' - Avatar for Twitter user ' .
444                          "$profile->nickname has changed.");
445             common_debug($this->name() . " - old: $oldname new: $newname");
446
447             $this->updateAvatars($twitter_user, $profile);
448         }
449
450         if ($this->missingAvatarFile($profile)) {
451             common_debug($this->name() . ' - Twitter user ' .
452                          $profile->nickname .
453                          ' is missing one or more local avatars.');
454             common_debug($this->name() ." - old: $oldname new: $newname");
455
456             $this->updateAvatars($twitter_user, $profile);
457         }
458     }
459
460     function updateAvatars($twitter_user, $profile) {
461
462         global $config;
463
464         $path_parts = pathinfo($twitter_user->profile_image_url);
465
466         $img_root = substr($path_parts['basename'], 0, -11);
467         $ext = $path_parts['extension'];
468         $mediatype = $this->getMediatype($ext);
469
470         foreach (array('mini', 'normal', 'bigger') as $size) {
471             $url = $path_parts['dirname'] . '/' .
472                 $img_root . '_' . $size . ".$ext";
473             $filename = 'Twitter_' . $twitter_user->id . '_' .
474                 $img_root . "_$size.$ext";
475
476             $this->updateAvatar($profile->id, $size, $mediatype, $filename);
477             $this->fetchAvatar($url, $filename);
478         }
479     }
480
481     function missingAvatarFile($profile) {
482         foreach (array(24, 48, 73) as $size) {
483             $filename = $profile->getAvatar($size)->filename;
484             $avatarpath = Avatar::path($filename);
485             if (file_exists($avatarpath) == FALSE) {
486                 return true;
487             }
488         }
489         return false;
490     }
491
492     function getMediatype($ext)
493     {
494         $mediatype = null;
495
496         switch (strtolower($ext)) {
497         case 'jpg':
498             $mediatype = 'image/jpg';
499             break;
500         case 'gif':
501             $mediatype = 'image/gif';
502             break;
503         default:
504             $mediatype = 'image/png';
505         }
506
507         return $mediatype;
508     }
509
510     function saveAvatars($user, $id)
511     {
512         global $config;
513
514         $path_parts = pathinfo($user->profile_image_url);
515         $ext = $path_parts['extension'];
516         $end = strlen('_normal' . $ext);
517         $img_root = substr($path_parts['basename'], 0, -($end+1));
518         $mediatype = $this->getMediatype($ext);
519
520         foreach (array('mini', 'normal', 'bigger') as $size) {
521             $url = $path_parts['dirname'] . '/' .
522                 $img_root . '_' . $size . ".$ext";
523             $filename = 'Twitter_' . $user->id . '_' .
524                 $img_root . "_$size.$ext";
525
526             if ($this->fetchAvatar($url, $filename)) {
527                 $this->newAvatar($id, $size, $mediatype, $filename);
528             } else {
529                 common_log(LOG_WARNING, $id() .
530                            " - Problem fetching Avatar: $url");
531             }
532         }
533     }
534
535     function updateAvatar($profile_id, $size, $mediatype, $filename) {
536
537         common_debug($this->name() . " - Updating avatar: $size");
538
539         $profile = Profile::staticGet($profile_id);
540
541         if (empty($profile)) {
542             common_debug($this->name() . " - Couldn't get profile: $profile_id!");
543             return;
544         }
545
546         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
547         $avatar = $profile->getAvatar($sizes[$size]);
548
549         // Delete the avatar, if present
550
551         if ($avatar) {
552             $avatar->delete();
553         }
554
555         $this->newAvatar($profile->id, $size, $mediatype, $filename);
556     }
557
558     function newAvatar($profile_id, $size, $mediatype, $filename)
559     {
560         global $config;
561
562         $avatar = new Avatar();
563         $avatar->profile_id = $profile_id;
564
565         switch($size) {
566         case 'mini':
567             $avatar->width  = 24;
568             $avatar->height = 24;
569             break;
570         case 'normal':
571             $avatar->width  = 48;
572             $avatar->height = 48;
573             break;
574         default:
575
576             // Note: Twitter's big avatars are a different size than
577             // StatusNet's (StatusNet's = 96)
578
579             $avatar->width  = 73;
580             $avatar->height = 73;
581         }
582
583         $avatar->original = 0; // we don't have the original
584         $avatar->mediatype = $mediatype;
585         $avatar->filename = $filename;
586         $avatar->url = Avatar::url($filename);
587
588         $avatar->created = common_sql_now();
589
590         try {
591             $id = $avatar->insert();
592         } catch (Exception $e) {
593             common_log(LOG_WARNING, $this->name() . ' Couldn\'t insert avatar - ' . $e->getMessage());
594         }
595
596         if (empty($id)) {
597             common_log_db_error($avatar, 'INSERT', __FILE__);
598             return null;
599         }
600
601         common_debug($this->name() .
602                      " - Saved new $size avatar for $profile_id.");
603
604         return $id;
605     }
606
607     /**
608      * Fetch a remote avatar image and save to local storage.
609      *
610      * @param string $url avatar source URL
611      * @param string $filename bare local filename for download
612      * @return bool true on success, false on failure
613      */
614     function fetchAvatar($url, $filename)
615     {
616         common_debug($this->name() . " - Fetching Twitter avatar: $url");
617
618         $request = HTTPClient::start();
619         $response = $request->get($url);
620         if ($response->isOk()) {
621             $avatarfile = Avatar::path($filename);
622             $ok = file_put_contents($avatarfile, $response->getBody());
623             if (!$ok) {
624                 common_log(LOG_WARNING, $this->name() .
625                            " - Couldn't open file $filename");
626                 return false;
627             }
628         } else {
629             return false;
630         }
631
632         return true;
633     }
634 }
635
636 $id    = null;
637 $debug = null;
638
639 if (have_option('i')) {
640     $id = get_option_value('i');
641 } else if (have_option('--id')) {
642     $id = get_option_value('--id');
643 } else if (count($args) > 0) {
644     $id = $args[0];
645 } else {
646     $id = null;
647 }
648
649 if (have_option('d') || have_option('debug')) {
650     $debug = true;
651 }
652
653 $fetcher = new TwitterStatusFetcher($id, 60, 2, $debug);
654 $fetcher->runOnce();
655