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