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