]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - scripts/twitterstatusfetcher.php
Make TwitterStatusFetcher daemon work with OAuth
[quix0rs-gnu-social.git] / scripts / twitterstatusfetcher.php
1 #!/usr/bin/env php
2 <?php
3 /**
4  * Laconica - a distributed open-source microblogging tool
5  * Copyright (C) 2008, 2009, Control Yourself, 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/daemon.php';
41
42 /**
43  * Fetcher for statuses from Twitter
44  *
45  * Fetches statuses from Twitter and inserts them as notices in local
46  * system.
47  *
48  * @category Twitter
49  * @package  Laconica
50  * @author   Zach Copley <zach@controlyourself.ca>
51  * @author   Evan Prodromou <evan@controlyourself.ca>
52  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
53  * @link     http://laconi.ca/
54  */
55
56 // NOTE: an Avatar path MUST be set in config.php for this
57 // script to work: e.g.: $config['avatar']['path'] = '/laconica/avatar';
58
59 class TwitterStatusFetcher extends Daemon
60 {
61     private $_children = array();
62
63     function __construct($id=null, $daemonize=true)
64     {
65         parent::__construct($daemonize);
66
67         if ($id) {
68             $this->set_id($id);
69         }
70     }
71
72     /**
73      * Name of this daemon
74      *
75      * @return string Name of the daemon.
76      */
77
78     function name()
79     {
80         return ('twitterstatusfetcher.'.$this->_id);
81     }
82
83     /**
84      * Run the daemon
85      *
86      * @return void
87      */
88
89     function run()
90     {
91         if (defined('SCRIPT_DEBUG')) {
92             common_debug($this->name() .
93                 ': debugging log output enabled.');
94         }
95
96         do {
97
98             $flinks = $this->refreshFlinks();
99
100             foreach ($flinks as $f) {
101
102                 // We have to disconnect from the DB before forking so
103                 // each sub-process will open its own connection and
104                 // avoid stomping on the others
105
106                 $conn = &$f->getDatabaseConnection();
107                 $conn->disconnect();
108
109                 $pid = pcntl_fork();
110
111                 if ($pid == -1) {
112                     die ("Couldn't fork!");
113                 }
114
115                 if ($pid) {
116
117                     // Parent
118                     if (defined('SCRIPT_DEBUG')) {
119                         common_debug("Parent: forked new status ".
120                                      " fetcher process " . $pid);
121                     }
122
123                     $this->_children[] = $pid;
124
125                 } else {
126
127                     // Child
128                     $this->getTimeline($f);
129                     exit();
130                 }
131
132                 // Remove child from ps list as it finishes
133                 while (($c = pcntl_wait($status, WNOHANG OR WUNTRACED)) > 0) {
134
135                     if (defined('SCRIPT_DEBUG')) {
136                         common_debug("Child $c finished.");
137                     }
138
139                     $this->removePs($this->_children, $c);
140                 }
141
142                 // Wait! We have too many damn kids.
143                 if (sizeof($this->_children) > MAXCHILDREN) {
144
145                     if (defined('SCRIPT_DEBUG')) {
146                         common_debug('Too many children. Waiting...');
147                     }
148
149                     if (($c = pcntl_wait($status, WUNTRACED)) > 0) {
150
151                         if (defined('SCRIPT_DEBUG')) {
152                             common_debug("Finished waiting for $c");
153                         }
154
155                         $this->removePs($this->_children, $c);
156                     }
157                 }
158             }
159
160             // Remove all children from the process list before restarting
161             while (($c = pcntl_wait($status, WUNTRACED)) > 0) {
162
163                 if (defined('SCRIPT_DEBUG')) {
164                     common_debug("Child $c finished.");
165                 }
166
167                 $this->removePs($this->_children, $c);
168             }
169
170             // Rest for a bit before we fetch more statuses
171
172             if (defined('SCRIPT_DEBUG')) {
173                 common_debug('Waiting ' . POLL_INTERVAL .
174                     ' secs before hitting Twitter again.');
175             }
176
177             if (POLL_INTERVAL > 0) {
178                 sleep(POLL_INTERVAL);
179             }
180
181         } while (true);
182     }
183
184     /**
185      * Refresh the foreign links for this user
186      *
187      * @return void
188      */
189
190     function refreshFlinks()
191     {
192         $flink = new Foreign_link();
193
194         $flink->service = TWITTER_SERVICE;
195
196         $flink->orderBy('last_noticesync');
197
198         $cnt = $flink->find();
199
200         if (defined('SCRIPT_DEBUG')) {
201             common_debug('Updating Twitter friends subscriptions' .
202                 " for $cnt users.");
203         }
204
205         $flinks = array();
206
207         while ($flink->fetch()) {
208
209             if (($flink->noticesync & FOREIGN_NOTICE_RECV) ==
210                 FOREIGN_NOTICE_RECV) {
211                 $flinks[] = clone($flink);
212             }
213         }
214
215         $flink->free();
216         unset($flink);
217
218         return $flinks;
219     }
220
221     /**
222      * Unknown
223      *
224      * @param array  &$plist unknown.
225      * @param string $ps     unknown.
226      *
227      * @return unknown
228      * @todo document
229      */
230
231     function removePs(&$plist, $ps)
232     {
233         for ($i = 0; $i < sizeof($plist); $i++) {
234             if ($plist[$i] == $ps) {
235                 unset($plist[$i]);
236                 $plist = array_values($plist);
237                 break;
238             }
239         }
240     }
241
242     function getTimeline($flink)
243     {
244          if (empty($flink)) {
245             common_log(LOG_WARNING,
246                 "Can't retrieve Foreign_link for foreign ID $fid");
247             return;
248         }
249
250         if (defined('SCRIPT_DEBUG')) {
251             common_debug('Trying to get timeline for Twitter user ' .
252                 $flink->foreign_id);
253         }
254
255         // XXX: Biggest remaining issue - How do we know at which status
256         // to start importing?  How many statuses?  Right now I'm going
257         // with the default last 20.
258
259         $client = new TwitterOAuthClient($flink->token, $flink->credentials);
260
261         $timeline = null;
262
263         try {
264             $timeline = $client->statuses_friends_timeline();
265         } catch (OAuthClientCurlException $e) {
266             common_log(LOG_WARNING,
267                        'OAuth client unable to get friends timeline for user ' .
268                        $flink->user_id . ' - code: ' .
269                        $e->getCode() . 'msg: ' . $e->getMessage());
270         }
271
272         if (empty($timeline)) {
273             common_log(LOG_WARNING, "Empty timeline.");
274             return;
275         }
276
277         // Reverse to preserve order
278         foreach (array_reverse($timeline) as $status) {
279
280             // Hacktastic: filter out stuff coming from this Laconica
281             $source = mb_strtolower(common_config('integration', 'source'));
282
283             if (preg_match("/$source/", mb_strtolower($status->source))) {
284                 if (defined('SCRIPT_DEBUG')) {
285                     common_debug('Skipping import of status ' . $status->id .
286                         ' with source ' . $source);
287                 }
288                 continue;
289             }
290
291             $this->saveStatus($status, $flink);
292         }
293
294         // Okay, record the time we synced with Twitter for posterity
295         $flink->last_noticesync = common_sql_now();
296         $flink->update();
297     }
298
299     function saveStatus($status, $flink)
300     {
301         $id = $this->ensureProfile($status->user);
302         $profile = Profile::staticGet($id);
303
304         if (empty($profile)) {
305             common_log(LOG_ERR,
306                 'Problem saving notice. No associated Profile.');
307             return null;
308         }
309
310         // XXX: change of screen name?
311
312         $uri = 'http://twitter.com/' . $status->user->screen_name .
313             '/status/' . $status->id;
314
315         $notice = Notice::staticGet('uri', $uri);
316
317         // check to see if we've already imported the status
318
319         if (empty($notice)) {
320
321             $notice = new Notice();
322
323             $notice->profile_id = $id;
324             $notice->uri        = $uri;
325             $notice->created    = strftime('%Y-%m-%d %H:%M:%S',
326                                            strtotime($status->created_at));
327             $notice->content    = common_shorten_links($status->text); // XXX
328             $notice->rendered   = common_render_content($notice->content, $notice);
329             $notice->source     = 'twitter';
330             $notice->reply_to   = null; // XXX lookup reply
331             $notice->is_local   = Notice::GATEWAY;
332
333             if (Event::handle('StartNoticeSave', array(&$notice))) {
334                 $id = $notice->insert();
335                 Event::handle('EndNoticeSave', array($notice));
336             }
337         }
338
339         if (!Notice_inbox::pkeyGet(array('notice_id' => $notice->id,
340                                          'user_id' => $flink->user_id))) {
341             // Add to inbox
342             $inbox = new Notice_inbox();
343
344             $inbox->user_id   = $flink->user_id;
345             $inbox->notice_id = $notice->id;
346             $inbox->created   = $notice->created;
347             $inbox->source    = NOTICE_INBOX_SOURCE_GATEWAY; // From a private source
348
349             $inbox->insert();
350         }
351     }
352
353     function ensureProfile($user)
354     {
355         // check to see if there's already a profile for this user
356         $profileurl = 'http://twitter.com/' . $user->screen_name;
357         $profile = Profile::staticGet('profileurl', $profileurl);
358
359         if ($profile) {
360             if (defined('SCRIPT_DEBUG')) {
361                 common_debug("Profile for $profile->nickname found.");
362             }
363
364             // Check to see if the user's Avatar has changed
365             $this->checkAvatar($user, $profile);
366
367             return $profile->id;
368
369         } else {
370             if (defined('SCRIPT_DEBUG')) {
371                 common_debug('Adding profile and remote profile ' .
372                     "for Twitter user: $profileurl");
373             }
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             $id = $profile->insert();
387
388             if (empty($id)) {
389                 common_log_db_error($profile, 'INSERT', __FILE__);
390                 $profile->query("ROLLBACK");
391                 return false;
392             }
393
394             // check for remote profile
395             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
396
397             if (!$remote_pro) {
398
399                 $remote_pro = new Remote_profile();
400
401                 $remote_pro->id = $id;
402                 $remote_pro->uri = $profileurl;
403                 $remote_pro->created = common_sql_now();
404
405                 $rid = $remote_pro->insert();
406
407                 if (empty($rid)) {
408                     common_log_db_error($profile, 'INSERT', __FILE__);
409                     $profile->query("ROLLBACK");
410                     return false;
411                 }
412             }
413
414             $profile->query("COMMIT");
415
416             $this->saveAvatars($user, $id);
417
418             return $id;
419         }
420     }
421
422     function checkAvatar($twitter_user, $profile)
423     {
424         global $config;
425
426         $path_parts = pathinfo($twitter_user->profile_image_url);
427
428         $newname = 'Twitter_' . $twitter_user->id . '_' .
429             $path_parts['basename'];
430
431         $oldname = $profile->getAvatar(48)->filename;
432
433         if ($newname != $oldname) {
434
435             if (defined('SCRIPT_DEBUG')) {
436                 common_debug('Avatar for Twitter user ' .
437                     "$profile->nickname has changed.");
438                 common_debug("old: $oldname new: $newname");
439             }
440
441             $this->updateAvatars($twitter_user, $profile);
442         }
443
444         if ($this->missingAvatarFile($profile)) {
445
446             if (defined('SCRIPT_DEBUG')) {
447                 common_debug('Twitter user ' . $profile->nickname .
448                     ' is missing one or more local avatars.');
449                 common_debug("old: $oldname new: $newname");
450             }
451
452             $this->updateAvatars($twitter_user, $profile);
453         }
454
455     }
456
457     function updateAvatars($twitter_user, $profile) {
458
459         global $config;
460
461         $path_parts = pathinfo($twitter_user->profile_image_url);
462
463         $img_root = substr($path_parts['basename'], 0, -11);
464         $ext = $path_parts['extension'];
465         $mediatype = $this->getMediatype($ext);
466
467         foreach (array('mini', 'normal', 'bigger') as $size) {
468             $url = $path_parts['dirname'] . '/' .
469                 $img_root . '_' . $size . ".$ext";
470             $filename = 'Twitter_' . $twitter_user->id . '_' .
471                 $img_root . "_$size.$ext";
472
473             $this->updateAvatar($profile->id, $size, $mediatype, $filename);
474             $this->fetchAvatar($url, $filename);
475         }
476     }
477
478     function missingAvatarFile($profile) {
479
480         foreach (array(24, 48, 73) as $size) {
481
482             $filename = $profile->getAvatar($size)->filename;
483             $avatarpath = Avatar::path($filename);
484
485             if (file_exists($avatarpath) == FALSE) {
486                 return true;
487             }
488         }
489
490         return false;
491     }
492
493     function getMediatype($ext)
494     {
495         $mediatype = null;
496
497         switch (strtolower($ext)) {
498         case 'jpg':
499             $mediatype = 'image/jpg';
500             break;
501         case 'gif':
502             $mediatype = 'image/gif';
503             break;
504         default:
505             $mediatype = 'image/png';
506         }
507
508         return $mediatype;
509     }
510
511     function saveAvatars($user, $id)
512     {
513         global $config;
514
515         $path_parts = pathinfo($user->profile_image_url);
516         $ext = $path_parts['extension'];
517         $end = strlen('_normal' . $ext);
518         $img_root = substr($path_parts['basename'], 0, -($end+1));
519         $mediatype = $this->getMediatype($ext);
520
521         foreach (array('mini', 'normal', 'bigger') as $size) {
522             $url = $path_parts['dirname'] . '/' .
523                 $img_root . '_' . $size . ".$ext";
524             $filename = 'Twitter_' . $user->id . '_' .
525                 $img_root . "_$size.$ext";
526
527             if ($this->fetchAvatar($url, $filename)) {
528                 $this->newAvatar($id, $size, $mediatype, $filename);
529             } else {
530                 common_log(LOG_WARNING, "Problem fetching Avatar: $url", __FILE__);
531             }
532         }
533     }
534
535     function updateAvatar($profile_id, $size, $mediatype, $filename) {
536
537         if (defined('SCRIPT_DEBUG')) {
538             common_debug("Updating avatar: $size");
539         }
540
541         $profile = Profile::staticGet($profile_id);
542
543         if (empty($profile)) {
544             if (defined('SCRIPT_DEBUG')) {
545                 common_debug("Couldn't get profile: $profile_id!");
546             }
547             return;
548         }
549
550         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
551         $avatar = $profile->getAvatar($sizes[$size]);
552
553         // Delete the avatar, if present
554         if ($avatar) {
555             $avatar->delete();
556         }
557
558         $this->newAvatar($profile->id, $size, $mediatype, $filename);
559     }
560
561     function newAvatar($profile_id, $size, $mediatype, $filename)
562     {
563         global $config;
564
565         $avatar = new Avatar();
566         $avatar->profile_id = $profile_id;
567
568         switch($size) {
569         case 'mini':
570             $avatar->width  = 24;
571             $avatar->height = 24;
572             break;
573         case 'normal':
574             $avatar->width  = 48;
575             $avatar->height = 48;
576             break;
577         default:
578
579             // Note: Twitter's big avatars are a different size than
580             // Laconica's (Laconica's = 96)
581
582             $avatar->width  = 73;
583             $avatar->height = 73;
584         }
585
586         $avatar->original = 0; // we don't have the original
587         $avatar->mediatype = $mediatype;
588         $avatar->filename = $filename;
589         $avatar->url = Avatar::url($filename);
590
591         if (defined('SCRIPT_DEBUG')) {
592             common_debug("new filename: $avatar->url");
593         }
594
595         $avatar->created = common_sql_now();
596
597         $id = $avatar->insert();
598
599         if (empty($id)) {
600             common_log_db_error($avatar, 'INSERT', __FILE__);
601             return null;
602         }
603
604         if (defined('SCRIPT_DEBUG')) {
605             common_debug("Saved new $size avatar for $profile_id.");
606         }
607
608         return $id;
609     }
610
611     function fetchAvatar($url, $filename)
612     {
613         $avatar_dir = INSTALLDIR . '/avatar/';
614
615         $avatarfile = $avatar_dir . $filename;
616
617         $out = fopen($avatarfile, 'wb');
618         if (!$out) {
619             common_log(LOG_WARNING, "Couldn't open file $filename", __FILE__);
620             return false;
621         }
622
623         if (defined('SCRIPT_DEBUG')) {
624             common_debug("Fetching avatar: $url");
625         }
626
627         $ch = curl_init();
628         curl_setopt($ch, CURLOPT_URL, $url);
629         curl_setopt($ch, CURLOPT_FILE, $out);
630         curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
631         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
632         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
633         $result = curl_exec($ch);
634         curl_close($ch);
635
636         fclose($out);
637
638         return $result;
639     }
640 }
641
642 declare(ticks = 1);
643
644 if (have_option('i')) {
645     $id = get_option_value('i');
646 } else if (have_option('--id')) {
647     $id = get_option_value('--id');
648 } else if (count($args) > 0) {
649     $id = $args[0];
650 } else {
651     $id = null;
652 }
653
654 if (have_option('d') || have_option('debug')) {
655     define('SCRIPT_DEBUG', true);
656 }
657
658 $fetcher = new TwitterStatusFetcher($id);
659 $fetcher->runOnce();
660