]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/TwitterBridge/twitterimport.php
9e53849d8477eb923c39fcf26a8541617ec4eaa1
[quix0rs-gnu-social.git] / plugins / TwitterBridge / twitterimport.php
1 <?php
2 /**
3  * StatusNet, the distributed open-source microblogging tool
4  *
5  * PHP version 5
6  *
7  * LICENCE: 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  * @category  Plugin
21  * @package   StatusNet
22  * @author    Zach Copley <zach@status.net>
23  * @author    Julien C <chaumond@gmail.com>
24  * @author    Brion Vibber <brion@status.net>
25  * @copyright 2009-2010 StatusNet, Inc.
26  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27  * @link      http://status.net/
28  */
29
30 if (!defined('STATUSNET')) {
31     exit(1);
32 }
33
34 require_once INSTALLDIR . '/plugins/TwitterBridge/twitter.php';
35
36 /**
37  * Encapsulation of the Twitter status -> notice incoming bridge import.
38  * Is used by both the polling twitterstatusfetcher.php daemon, and the
39  * in-progress streaming import.
40  *
41  * @category Plugin
42  * @package  StatusNet
43  * @author   Zach Copley <zach@status.net>
44  * @author   Julien C <chaumond@gmail.com>
45  * @author   Brion Vibber <brion@status.net>
46  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
47  * @link     http://status.net/
48  * @link     http://twitter.com/
49  */
50 class TwitterImport
51 {
52     public function importStatus($status)
53     {
54         // Hacktastic: filter out stuff coming from this StatusNet
55         $source = mb_strtolower(common_config('integration', 'source'));
56
57         if (preg_match("/$source/", mb_strtolower($status->source))) {
58             common_debug($this->name() . ' - Skipping import of status ' .
59                          $status->id . ' with source ' . $source);
60             return null;
61         }
62
63         // Don't save it if the user is protected
64         // FIXME: save it but treat it as private
65         if ($status->user->protected) {
66             return null;
67         }
68
69         $notice = $this->saveStatus($status);
70
71         return $notice;
72     }
73
74     function name()
75     {
76         return get_class($this);
77     }
78
79     function saveStatus($status)
80     {
81         $profile = $this->ensureProfile($status->user);
82
83         if (empty($profile)) {
84             common_log(LOG_ERR, $this->name() .
85                 ' - Problem saving notice. No associated Profile.');
86             return null;
87         }
88
89         $statusUri = $this->makeStatusURI($status->user->screen_name, $status->id);
90
91         // check to see if we've already imported the status
92         $n2s = Notice_to_status::staticGet('status_id', $status->id);
93
94         if (!empty($n2s)) {
95             common_log(
96                 LOG_INFO,
97                 $this->name() .
98                 " - Ignoring duplicate import: {$status->id}"
99             );
100             return Notice::staticGet('id', $n2s->notice_id);
101         }
102
103         // If it's a retweet, save it as a repeat!
104         if (!empty($status->retweeted_status)) {
105             common_log(LOG_INFO, "Status {$status->id} is a retweet of {$status->retweeted_status->id}.");
106             $original = $this->saveStatus($status->retweeted_status);
107             if (empty($original)) {
108                 return null;
109             } else {
110                 $author = $original->getProfile();
111                 // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
112                 // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
113                 $content = sprintf(_m('RT @%1$s %2$s'),
114                                    $author->nickname,
115                                    $original->content);
116
117                 if (Notice::contentTooLong($content)) {
118                     $contentlimit = Notice::maxContent();
119                     $content = mb_substr($content, 0, $contentlimit - 4) . ' ...';
120                 }
121
122                 $repeat = Notice::saveNew($profile->id,
123                                           $content,
124                                           'twitter',
125                                           array('repeat_of' => $original->id,
126                                                 'uri' => $statusUri,
127                                                 'is_local' => Notice::GATEWAY));
128                 common_log(LOG_INFO, "Saved {$repeat->id} as a repeat of {$original->id}");
129                 Notice_to_status::saveNew($repeat->id, $status->id);
130                 return $repeat;
131             }
132         }
133
134         $notice = new Notice();
135
136         $notice->profile_id = $profile->id;
137         $notice->uri        = $statusUri;
138         $notice->url        = $statusUri;
139         $notice->created    = strftime(
140             '%Y-%m-%d %H:%M:%S',
141             strtotime($status->created_at)
142         );
143
144         $notice->source     = 'twitter';
145
146         $notice->reply_to   = null;
147
148         if (!empty($status->in_reply_to_status_id)) {
149             common_log(LOG_INFO, "Status {$status->id} is a reply to status {$status->in_reply_to_status_id}");
150             $n2s = Notice_to_status::staticGet('status_id', $status->in_reply_to_status_id);
151             if (empty($n2s)) {
152                 common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
153             } else {
154                 $reply = Notice::staticGet('id', $n2s->notice_id);
155                 if (empty($reply)) {
156                     common_log(LOG_INFO, "Couldn't find local notice for status {$status->in_reply_to_status_id}");
157                 } else {
158                     common_log(LOG_INFO, "Found local notice {$reply->id} for status {$status->in_reply_to_status_id}");
159                     $notice->reply_to     = $reply->id;
160                     $notice->conversation = $reply->conversation;
161                 }
162             }
163         }
164
165         if (empty($notice->conversation)) {
166             $conv = Conversation::create();
167             $notice->conversation = $conv->id;
168             common_log(LOG_INFO, "No known conversation for status {$status->id} so making a new one {$conv->id}.");
169         }
170
171         $notice->is_local   = Notice::GATEWAY;
172
173         $notice->content  = html_entity_decode($status->text, ENT_QUOTES, 'UTF-8');
174         $notice->rendered = $this->linkify($status);
175
176         if (Event::handle('StartNoticeSave', array(&$notice))) {
177
178             $id = $notice->insert();
179
180             if (!$id) {
181                 common_log_db_error($notice, 'INSERT', __FILE__);
182                 common_log(LOG_ERR, $this->name() .
183                     ' - Problem saving notice.');
184             }
185
186             Event::handle('EndNoticeSave', array($notice));
187         }
188
189         Notice_to_status::saveNew($notice->id, $status->id);
190
191         $this->saveStatusMentions($notice, $status);
192         $this->saveStatusAttachments($notice, $status);
193
194         $notice->blowOnInsert();
195
196         return $notice;
197     }
198
199     /**
200      * Make an URI for a status.
201      *
202      * @param object $status status object
203      *
204      * @return string URI
205      */
206     function makeStatusURI($username, $id)
207     {
208         return 'http://twitter.com/'
209           . $username
210           . '/status/'
211           . $id;
212     }
213
214
215     /**
216      * Look up a Profile by profileurl field.  Profile::staticGet() was
217      * not working consistently.
218      *
219      * @param string $nickname   local nickname of the Twitter user
220      * @param string $profileurl the profile url
221      *
222      * @return mixed value the first Profile with that url, or null
223      */
224     function getProfileByUrl($nickname, $profileurl)
225     {
226         $profile = new Profile();
227         $profile->nickname = $nickname;
228         $profile->profileurl = $profileurl;
229         $profile->limit(1);
230
231         if ($profile->find()) {
232             $profile->fetch();
233             return $profile;
234         }
235
236         return null;
237     }
238
239     /**
240      * Check to see if this Twitter status has already been imported
241      *
242      * @param Profile $profile   Twitter user's local profile
243      * @param string  $statusUri URI of the status on Twitter
244      *
245      * @return mixed value a matching Notice or null
246      */
247     function checkDupe($profile, $statusUri)
248     {
249         $notice = new Notice();
250         $notice->uri = $statusUri;
251         $notice->profile_id = $profile->id;
252         $notice->limit(1);
253
254         if ($notice->find()) {
255             $notice->fetch();
256             return $notice;
257         }
258
259         return null;
260     }
261
262     function ensureProfile($user)
263     {
264         // check to see if there's already a profile for this user
265         $profileurl = 'http://twitter.com/' . $user->screen_name;
266         $profile = $this->getProfileByUrl($user->screen_name, $profileurl);
267
268         if (!empty($profile)) {
269             common_debug($this->name() .
270                          " - Profile for $profile->nickname found.");
271
272             // Check to see if the user's Avatar has changed
273
274             $this->checkAvatar($user, $profile);
275             return $profile;
276
277         } else {
278             common_debug($this->name() . ' - Adding profile and remote profile ' .
279                          "for Twitter user: $profileurl.");
280
281             $profile = new Profile();
282             $profile->query("BEGIN");
283
284             $profile->nickname = $user->screen_name;
285             $profile->fullname = $user->name;
286             $profile->homepage = $user->url;
287             $profile->bio = $user->description;
288             $profile->location = $user->location;
289             $profile->profileurl = $profileurl;
290             $profile->created = common_sql_now();
291
292             try {
293                 $id = $profile->insert();
294             } catch(Exception $e) {
295                 common_log(LOG_WARNING, $this->name() . ' Couldn\'t insert profile - ' . $e->getMessage());
296             }
297
298             if (empty($id)) {
299                 common_log_db_error($profile, 'INSERT', __FILE__);
300                 $profile->query("ROLLBACK");
301                 return false;
302             }
303
304             // check for remote profile
305
306             $remote_pro = Remote_profile::staticGet('uri', $profileurl);
307
308             if (empty($remote_pro)) {
309                 $remote_pro = new Remote_profile();
310
311                 $remote_pro->id = $id;
312                 $remote_pro->uri = $profileurl;
313                 $remote_pro->created = common_sql_now();
314
315                 try {
316                     $rid = $remote_pro->insert();
317                 } catch (Exception $e) {
318                     common_log(LOG_WARNING, $this->name() . ' Couldn\'t save remote profile - ' . $e->getMessage());
319                 }
320
321                 if (empty($rid)) {
322                     common_log_db_error($profile, 'INSERT', __FILE__);
323                     $profile->query("ROLLBACK");
324                     return false;
325                 }
326             }
327
328             $profile->query("COMMIT");
329
330             $this->saveAvatars($user, $id);
331
332             return $profile;
333         }
334     }
335
336     function checkAvatar($twitter_user, $profile)
337     {
338         global $config;
339
340         $path_parts = pathinfo($twitter_user->profile_image_url);
341
342         $newname = 'Twitter_' . $twitter_user->id . '_' .
343             $path_parts['basename'];
344
345         $oldname = $profile->getAvatar(48)->filename;
346
347         if ($newname != $oldname) {
348             common_debug($this->name() . ' - Avatar for Twitter user ' .
349                          "$profile->nickname has changed.");
350             common_debug($this->name() . " - old: $oldname new: $newname");
351
352             $this->updateAvatars($twitter_user, $profile);
353         }
354
355         if ($this->missingAvatarFile($profile)) {
356             common_debug($this->name() . ' - Twitter user ' .
357                          $profile->nickname .
358                          ' is missing one or more local avatars.');
359             common_debug($this->name() ." - old: $oldname new: $newname");
360
361             $this->updateAvatars($twitter_user, $profile);
362         }
363     }
364
365     function updateAvatars($twitter_user, $profile) {
366
367         global $config;
368
369         $path_parts = pathinfo($twitter_user->profile_image_url);
370
371         $img_root = substr($path_parts['basename'], 0, -11);
372         $ext = $path_parts['extension'];
373         $mediatype = $this->getMediatype($ext);
374
375         foreach (array('mini', 'normal', 'bigger') as $size) {
376             $url = $path_parts['dirname'] . '/' .
377                 $img_root . '_' . $size . ".$ext";
378             $filename = 'Twitter_' . $twitter_user->id . '_' .
379                 $img_root . "_$size.$ext";
380
381             $this->updateAvatar($profile->id, $size, $mediatype, $filename);
382             $this->fetchAvatar($url, $filename);
383         }
384     }
385
386     function missingAvatarFile($profile) {
387         foreach (array(24, 48, 73) as $size) {
388             $filename = $profile->getAvatar($size)->filename;
389             $avatarpath = Avatar::path($filename);
390             if (file_exists($avatarpath) == FALSE) {
391                 return true;
392             }
393         }
394         return false;
395     }
396
397     function getMediatype($ext)
398     {
399         $mediatype = null;
400
401         switch (strtolower($ext)) {
402         case 'jpg':
403             $mediatype = 'image/jpg';
404             break;
405         case 'gif':
406             $mediatype = 'image/gif';
407             break;
408         default:
409             $mediatype = 'image/png';
410         }
411
412         return $mediatype;
413     }
414
415     function saveAvatars($user, $id)
416     {
417         global $config;
418
419         $path_parts = pathinfo($user->profile_image_url);
420         $ext = $path_parts['extension'];
421         $end = strlen('_normal' . $ext);
422         $img_root = substr($path_parts['basename'], 0, -($end+1));
423         $mediatype = $this->getMediatype($ext);
424
425         foreach (array('mini', 'normal', 'bigger') as $size) {
426             $url = $path_parts['dirname'] . '/' .
427                 $img_root . '_' . $size . ".$ext";
428             $filename = 'Twitter_' . $user->id . '_' .
429                 $img_root . "_$size.$ext";
430
431             if ($this->fetchAvatar($url, $filename)) {
432                 $this->newAvatar($id, $size, $mediatype, $filename);
433             } else {
434                 common_log(LOG_WARNING, $id() .
435                            " - Problem fetching Avatar: $url");
436             }
437         }
438     }
439
440     function updateAvatar($profile_id, $size, $mediatype, $filename) {
441
442         common_debug($this->name() . " - Updating avatar: $size");
443
444         $profile = Profile::staticGet($profile_id);
445
446         if (empty($profile)) {
447             common_debug($this->name() . " - Couldn't get profile: $profile_id!");
448             return;
449         }
450
451         $sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
452         $avatar = $profile->getAvatar($sizes[$size]);
453
454         // Delete the avatar, if present
455         if ($avatar) {
456             $avatar->delete();
457         }
458
459         $this->newAvatar($profile->id, $size, $mediatype, $filename);
460     }
461
462     function newAvatar($profile_id, $size, $mediatype, $filename)
463     {
464         global $config;
465
466         $avatar = new Avatar();
467         $avatar->profile_id = $profile_id;
468
469         switch($size) {
470         case 'mini':
471             $avatar->width  = 24;
472             $avatar->height = 24;
473             break;
474         case 'normal':
475             $avatar->width  = 48;
476             $avatar->height = 48;
477             break;
478         default:
479             // Note: Twitter's big avatars are a different size than
480             // StatusNet's (StatusNet's = 96)
481             $avatar->width  = 73;
482             $avatar->height = 73;
483         }
484
485         $avatar->original = 0; // we don't have the original
486         $avatar->mediatype = $mediatype;
487         $avatar->filename = $filename;
488         $avatar->url = Avatar::url($filename);
489
490         $avatar->created = common_sql_now();
491
492         try {
493             $id = $avatar->insert();
494         } catch (Exception $e) {
495             common_log(LOG_WARNING, $this->name() . ' Couldn\'t insert avatar - ' . $e->getMessage());
496         }
497
498         if (empty($id)) {
499             common_log_db_error($avatar, 'INSERT', __FILE__);
500             return null;
501         }
502
503         common_debug($this->name() .
504                      " - Saved new $size avatar for $profile_id.");
505
506         return $id;
507     }
508
509     /**
510      * Fetch a remote avatar image and save to local storage.
511      *
512      * @param string $url avatar source URL
513      * @param string $filename bare local filename for download
514      * @return bool true on success, false on failure
515      */
516     function fetchAvatar($url, $filename)
517     {
518         common_debug($this->name() . " - Fetching Twitter avatar: $url");
519
520         $request = HTTPClient::start();
521         $response = $request->get($url);
522         if ($response->isOk()) {
523             $avatarfile = Avatar::path($filename);
524             $ok = file_put_contents($avatarfile, $response->getBody());
525             if (!$ok) {
526                 common_log(LOG_WARNING, $this->name() .
527                            " - Couldn't open file $filename");
528                 return false;
529             }
530         } else {
531             return false;
532         }
533
534         return true;
535     }
536
537     const URL = 1;
538     const HASHTAG = 2;
539     const MENTION = 3;
540
541     function linkify($status)
542     {
543         $text = $status->text;
544
545         if (empty($status->entities)) {
546             common_log(LOG_WARNING, "No entities data for {$status->id}; trying to fake up links ourselves.");
547             $text = common_replace_urls_callback($text, 'common_linkify');
548             $text = preg_replace('/(^|\&quot\;|\'|\(|\[|\{|\s+)#([\pL\pN_\-\.]{1,64})/e', "'\\1#'.TwitterStatusFetcher::tagLink('\\2')", $text);
549             $text = preg_replace('/(^|\s+)@([a-z0-9A-Z_]{1,64})/e', "'\\1@'.TwitterStatusFetcher::atLink('\\2')", $text);
550             return $text;
551         }
552
553         // Move all the entities into order so we can
554         // replace them in reverse order and thus
555         // not mess up their indices
556
557         $toReplace = array();
558
559         if (!empty($status->entities->urls)) {
560             foreach ($status->entities->urls as $url) {
561                 $toReplace[$url->indices[0]] = array(self::URL, $url);
562             }
563         }
564
565         if (!empty($status->entities->hashtags)) {
566             foreach ($status->entities->hashtags as $hashtag) {
567                 $toReplace[$hashtag->indices[0]] = array(self::HASHTAG, $hashtag);
568             }
569         }
570
571         if (!empty($status->entities->user_mentions)) {
572             foreach ($status->entities->user_mentions as $mention) {
573                 $toReplace[$mention->indices[0]] = array(self::MENTION, $mention);
574             }
575         }
576
577         // sort in reverse order by key
578
579         krsort($toReplace);
580
581         foreach ($toReplace as $part) {
582             list($type, $object) = $part;
583             switch($type) {
584             case self::URL:
585                 $linkText = $this->makeUrlLink($object);
586                 break;
587             case self::HASHTAG:
588                 $linkText = $this->makeHashtagLink($object);
589                 break;
590             case self::MENTION:
591                 $linkText = $this->makeMentionLink($object);
592                 break;
593             default:
594                 continue;
595             }
596             $text = mb_substr($text, 0, $object->indices[0]) . $linkText . mb_substr($text, $object->indices[1]);
597         }
598         return $text;
599     }
600
601     function makeUrlLink($object)
602     {
603         return "<a href='{$object->url}' class='extlink'>{$object->url}</a>";
604     }
605
606     function makeHashtagLink($object)
607     {
608         return "#" . self::tagLink($object->text);
609     }
610
611     function makeMentionLink($object)
612     {
613         return "@".self::atLink($object->screen_name, $object->name);
614     }
615
616     static function tagLink($tag)
617     {
618         return "<a href='https://twitter.com/search?q=%23{$tag}' class='hashtag'>{$tag}</a>";
619     }
620
621     static function atLink($screenName, $fullName=null)
622     {
623         if (!empty($fullName)) {
624             return "<a href='http://twitter.com/{$screenName}' title='{$fullName}'>{$screenName}</a>";
625         } else {
626             return "<a href='http://twitter.com/{$screenName}'>{$screenName}</a>";
627         }
628     }
629
630     function saveStatusMentions($notice, $status)
631     {
632         $mentions = array();
633
634         if (empty($status->entities) || empty($status->entities->user_mentions)) {
635             return;
636         }
637
638         foreach ($status->entities->user_mentions as $mention) {
639             $flink = Foreign_link::getByForeignID($mention->id, TWITTER_SERVICE);
640             if (!empty($flink)) {
641                 $user = User::staticGet('id', $flink->user_id);
642                 if (!empty($user)) {
643                     $reply = new Reply();
644                     $reply->notice_id  = $notice->id;
645                     $reply->profile_id = $user->id;
646                     common_log(LOG_INFO, __METHOD__ . ": saving reply: notice {$notice->id} to profile {$user->id}");
647                     $id = $reply->insert();
648                 }
649             }
650         }
651     }
652
653     /**
654      * Record URL links from the notice. Needed to get thumbnail records
655      * for referenced photo and video posts, etc.
656      *
657      * @param Notice $notice
658      * @param object $status
659      */
660     function saveStatusAttachments($notice, $status)
661     {
662         if (common_config('attachments', 'process_links')) {
663             if (!empty($status->entities) && !empty($status->entities->urls)) {
664                 foreach ($status->entities->urls as $url) {
665                     File::processNew($url->url, $notice->id);
666                 }
667             }
668         }
669     }
670 }