forked from GNUsocial/gnu-social
19d39b2029
We mark Twitter statuses with flags so they don't get shown in the wrong places.
602 lines
17 KiB
PHP
Executable File
602 lines
17 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Laconica - a distributed open-source microblogging tool
|
|
* Copyright (C) 2008, Controlez-Vous, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Abort if called from a web server
|
|
if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) {
|
|
print "This script must be run from the command line\n";
|
|
exit();
|
|
}
|
|
|
|
define('INSTALLDIR', realpath(dirname(__FILE__) . '/..'));
|
|
define('LACONICA', true);
|
|
|
|
// Tune number of processes and how often to poll Twitter
|
|
// XXX: Should these things be in config.php?
|
|
define('MAXCHILDREN', 2);
|
|
define('POLL_INTERVAL', 60); // in seconds
|
|
|
|
// Uncomment this to get useful logging
|
|
define('SCRIPT_DEBUG', true);
|
|
|
|
require_once INSTALLDIR . '/lib/common.php';
|
|
require_once INSTALLDIR . '/lib/daemon.php';
|
|
|
|
/**
|
|
* Fetcher for statuses from Twitter
|
|
*
|
|
* Fetches statuses from Twitter and inserts them as notices in local
|
|
* system.
|
|
*
|
|
* @category Twitter
|
|
* @package Laconica
|
|
* @author Zach Copley <zach@controlyourself.ca>
|
|
* @author Evan Prodromou <evan@controlyourself.ca>
|
|
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
|
|
* @link http://laconi.ca/
|
|
*/
|
|
|
|
class TwitterStatusFetcher extends Daemon
|
|
{
|
|
private $_children = array();
|
|
|
|
/**
|
|
* Name of this daemon
|
|
*
|
|
* @return string Name of the daemon.
|
|
*/
|
|
|
|
function name()
|
|
{
|
|
return ('twitterstatusfetcher.generic');
|
|
}
|
|
|
|
/**
|
|
* Run the daemon
|
|
*
|
|
* @return void
|
|
*/
|
|
|
|
function run()
|
|
{
|
|
do {
|
|
|
|
$flinks = $this->refreshFlinks();
|
|
|
|
foreach ($flinks as $f) {
|
|
|
|
// We have to disconnect from the DB before forking so
|
|
// each sub-process will open its own connection and
|
|
// avoid stomping on the others
|
|
|
|
$conn = &$f->getDatabaseConnection();
|
|
$conn->disconnect();
|
|
|
|
$pid = pcntl_fork();
|
|
|
|
if ($pid == -1) {
|
|
die ("Couldn't fork!");
|
|
}
|
|
|
|
if ($pid) {
|
|
|
|
// Parent
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Parent: forked new status ".
|
|
" fetcher process " . $pid);
|
|
}
|
|
|
|
$this->_children[] = $pid;
|
|
|
|
} else {
|
|
|
|
// Child
|
|
$this->getTimeline($f);
|
|
exit();
|
|
}
|
|
|
|
// Remove child from ps list as it finishes
|
|
while (($c = pcntl_wait($status, WNOHANG OR WUNTRACED)) > 0) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Child $c finished.");
|
|
}
|
|
|
|
$this->removePs($this->_children, $c);
|
|
}
|
|
|
|
// Wait! We have too many damn kids.
|
|
if (sizeof($this->_children) > MAXCHILDREN) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Too many children. Waiting...');
|
|
}
|
|
|
|
if (($c = pcntl_wait($status, WUNTRACED)) > 0) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Finished waiting for $c");
|
|
}
|
|
|
|
$this->removePs($this->_children, $c);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove all children from the process list before restarting
|
|
while (($c = pcntl_wait($status, WUNTRACED)) > 0) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Child $c finished.");
|
|
}
|
|
|
|
$this->removePs($this->_children, $c);
|
|
}
|
|
|
|
// Rest for a bit before we fetch more statuses
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Waiting ' . POLL_INTERVAL .
|
|
' secs before hitting Twitter again.');
|
|
}
|
|
|
|
if (POLL_INTERVAL > 0) {
|
|
sleep(POLL_INTERVAL);
|
|
}
|
|
|
|
} while (true);
|
|
}
|
|
|
|
/**
|
|
* Refresh the foreign links for this user
|
|
*
|
|
* @return void
|
|
*/
|
|
|
|
function refreshFlinks()
|
|
{
|
|
$flink = new Foreign_link();
|
|
|
|
$flink->service = 1; // Twitter
|
|
|
|
$flink->orderBy('last_noticesync');
|
|
|
|
$cnt = $flink->find();
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Updating Twitter friends subscriptions' .
|
|
" for $cnt users.");
|
|
}
|
|
|
|
$flinks = array();
|
|
|
|
while ($flink->fetch()) {
|
|
|
|
if (($flink->noticesync & FOREIGN_NOTICE_RECV) ==
|
|
FOREIGN_NOTICE_RECV) {
|
|
$flinks[] = clone($flink);
|
|
}
|
|
}
|
|
|
|
$flink->free();
|
|
unset($flink);
|
|
|
|
return $flinks;
|
|
}
|
|
|
|
/**
|
|
* Unknown
|
|
*
|
|
* @param array &$plist unknown.
|
|
* @param string $ps unknown.
|
|
*
|
|
* @return unknown
|
|
* @todo document
|
|
*/
|
|
|
|
function removePs(&$plist, $ps)
|
|
{
|
|
for ($i = 0; $i < sizeof($plist); $i++) {
|
|
if ($plist[$i] == $ps) {
|
|
unset($plist[$i]);
|
|
$plist = array_values($plist);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getTimeline($flink)
|
|
{
|
|
if (empty($flink)) {
|
|
common_log(LOG_WARNING,
|
|
"Can't retrieve Foreign_link for foreign ID $fid");
|
|
return;
|
|
}
|
|
|
|
$fuser = $flink->getForeignUser();
|
|
|
|
if (empty($fuser)) {
|
|
common_log(LOG_WARNING, "Unmatched user for ID " .
|
|
$flink->user_id);
|
|
return;
|
|
}
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Trying to get timeline for Twitter user ' .
|
|
"$fuser->nickname ($flink->foreign_id).");
|
|
}
|
|
|
|
// XXX: Biggest remaining issue - How do we know at which status
|
|
// to start importing? How many statuses? Right now I'm going
|
|
// with the default last 20.
|
|
|
|
$url = 'http://twitter.com/statuses/friends_timeline.json';
|
|
|
|
$timeline_json = get_twitter_data($url, $fuser->nickname,
|
|
$flink->credentials);
|
|
|
|
$timeline = json_decode($timeline_json);
|
|
|
|
if (empty($timeline)) {
|
|
common_log(LOG_WARNING, "Empty timeline.");
|
|
return;
|
|
}
|
|
|
|
// Reverse to preserve order
|
|
foreach (array_reverse($timeline) as $status) {
|
|
|
|
// Hacktastic: filter out stuff coming from this Laconica
|
|
$source = mb_strtolower(common_config('integration', 'source'));
|
|
|
|
if (preg_match("/$source/", mb_strtolower($status->source))) {
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Skipping import of status ' . $status->id .
|
|
' with source ' . $source);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$this->saveStatus($status, $flink);
|
|
}
|
|
|
|
// Okay, record the time we synced with Twitter for posterity
|
|
$flink->last_noticesync = common_sql_now();
|
|
$flink->update();
|
|
}
|
|
|
|
function saveStatus($status, $flink)
|
|
{
|
|
$id = $this->ensureProfile($status->user);
|
|
$profile = Profile::staticGet($id);
|
|
|
|
if (!$profile) {
|
|
common_log(LOG_ERR,
|
|
'Problem saving notice. No associated Profile.');
|
|
return null;
|
|
}
|
|
|
|
// XXX: change of screen name?
|
|
|
|
$uri = 'http://twitter.com/' . $status->user->screen_name .
|
|
'/status/' . $status->id;
|
|
|
|
$notice = Notice::staticGet('uri', $uri);
|
|
|
|
// check to see if we've already imported the status
|
|
|
|
if (!$notice) {
|
|
|
|
$notice = new Notice();
|
|
|
|
$notice->profile_id = $id;
|
|
$notice->uri = $uri;
|
|
$notice->created = strftime('%Y-%m-%d %H:%M:%S',
|
|
strtotime($status->created_at));
|
|
$notice->content = common_shorten_links($status->text); // XXX
|
|
$notice->rendered = common_render_content($notice->content, $notice);
|
|
$notice->source = 'twitter';
|
|
$notice->reply_to = null; // XXX lookup reply
|
|
$notice->is_local = NOTICE_GATEWAY;
|
|
|
|
if (Event::handle('StartNoticeSave', array(&$notice))) {
|
|
$id = $notice->insert();
|
|
Event::handle('EndNoticeSave', array($notice));
|
|
}
|
|
}
|
|
|
|
if (!Notice_inbox::pkeyGet(array('notice_id' => $notice->id,
|
|
'user_id' => $flink->user_id))) {
|
|
// Add to inbox
|
|
$inbox = new Notice_inbox();
|
|
|
|
$inbox->user_id = $flink->user_id;
|
|
$inbox->notice_id = $notice->id;
|
|
$inbox->created = $notice->created;
|
|
$inbox->source = NOTICE_INBOX_SOURCE_GATEWAY; // From a private source
|
|
|
|
$inbox->insert();
|
|
}
|
|
}
|
|
|
|
function ensureProfile($user)
|
|
{
|
|
// check to see if there's already a profile for this user
|
|
$profileurl = 'http://twitter.com/' . $user->screen_name;
|
|
$profile = Profile::staticGet('profileurl', $profileurl);
|
|
|
|
if ($profile) {
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Profile for $profile->nickname found.");
|
|
}
|
|
|
|
// Check to see if the user's Avatar has changed
|
|
$this->checkAvatar($user, $profile);
|
|
|
|
return $profile->id;
|
|
|
|
} else {
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Adding profile and remote profile ' .
|
|
"for Twitter user: $profileurl");
|
|
}
|
|
|
|
$profile = new Profile();
|
|
$profile->query("BEGIN");
|
|
|
|
$profile->nickname = $user->screen_name;
|
|
$profile->fullname = $user->name;
|
|
$profile->homepage = $user->url;
|
|
$profile->bio = $user->description;
|
|
$profile->location = $user->location;
|
|
$profile->profileurl = $profileurl;
|
|
$profile->created = common_sql_now();
|
|
|
|
$id = $profile->insert();
|
|
|
|
if (empty($id)) {
|
|
common_log_db_error($profile, 'INSERT', __FILE__);
|
|
$profile->query("ROLLBACK");
|
|
return false;
|
|
}
|
|
|
|
// check for remote profile
|
|
$remote_pro = Remote_profile::staticGet('uri', $profileurl);
|
|
|
|
if (!$remote_pro) {
|
|
|
|
$remote_pro = new Remote_profile();
|
|
|
|
$remote_pro->id = $id;
|
|
$remote_pro->uri = $profileurl;
|
|
$remote_pro->created = common_sql_now();
|
|
|
|
$rid = $remote_pro->insert();
|
|
|
|
if (empty($rid)) {
|
|
common_log_db_error($profile, 'INSERT', __FILE__);
|
|
$profile->query("ROLLBACK");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$profile->query("COMMIT");
|
|
|
|
$this->saveAvatars($user, $id);
|
|
|
|
return $id;
|
|
}
|
|
}
|
|
|
|
function checkAvatar($user, $profile)
|
|
{
|
|
global $config;
|
|
|
|
$path_parts = pathinfo($user->profile_image_url);
|
|
$newname = 'Twitter_' . $user->id . '_' .
|
|
$path_parts['basename'];
|
|
|
|
$oldname = $profile->getAvatar(48)->filename;
|
|
|
|
if ($newname != $oldname) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug('Avatar for Twitter user ' .
|
|
"$profile->nickname has changed.");
|
|
common_debug("old: $oldname new: $newname");
|
|
}
|
|
|
|
$img_root = substr($path_parts['basename'], 0, -11);
|
|
$ext = $path_parts['extension'];
|
|
$mediatype = $this->getMediatype($ext);
|
|
|
|
foreach (array('mini', 'normal', 'bigger') as $size) {
|
|
$url = $path_parts['dirname'] . '/' .
|
|
$img_root . '_' . $size . ".$ext";
|
|
$filename = 'Twitter_' . $user->id . '_' .
|
|
$img_root . "_$size.$ext";
|
|
|
|
if ($this->fetchAvatar($url, $filename)) {
|
|
$this->updateAvatar($profile->id, $size, $mediatype, $filename);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getMediatype($ext)
|
|
{
|
|
$mediatype = null;
|
|
|
|
switch (strtolower($ext)) {
|
|
case 'jpg':
|
|
$mediatype = 'image/jpg';
|
|
break;
|
|
case 'gif':
|
|
$mediatype = 'image/gif';
|
|
break;
|
|
default:
|
|
$mediatype = 'image/png';
|
|
}
|
|
|
|
return $mediatype;
|
|
}
|
|
|
|
function saveAvatars($user, $id)
|
|
{
|
|
global $config;
|
|
|
|
$path_parts = pathinfo($user->profile_image_url);
|
|
$ext = $path_parts['extension'];
|
|
$end = strlen('_normal' . $ext);
|
|
$img_root = substr($path_parts['basename'], 0, -($end+1));
|
|
$mediatype = $this->getMediatype($ext);
|
|
|
|
foreach (array('mini', 'normal', 'bigger') as $size) {
|
|
$url = $path_parts['dirname'] . '/' .
|
|
$img_root . '_' . $size . ".$ext";
|
|
$filename = 'Twitter_' . $user->id . '_' .
|
|
$img_root . "_$size.$ext";
|
|
|
|
if ($this->fetchAvatar($url, $filename)) {
|
|
$this->newAvatar($id, $size, $mediatype, $filename);
|
|
} else {
|
|
common_log(LOG_WARNING, "Problem fetching Avatar: $url", __FILE__);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateAvatar($profile_id, $size, $mediatype, $filename) {
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Updating avatar: $size");
|
|
}
|
|
|
|
$profile = Profile::staticGet($profile_id);
|
|
|
|
if (!$profile) {
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Couldn't get profile: $profile_id!");
|
|
}
|
|
return;
|
|
}
|
|
|
|
$sizes = array('mini' => 24, 'normal' => 48, 'bigger' => 73);
|
|
$avatar = $profile->getAvatar($sizes[$size]);
|
|
|
|
if ($avatar) {
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Deleting $size avatar for $profile->nickname.");
|
|
}
|
|
@unlink(INSTALLDIR . '/avatar/' . $avatar->filename);
|
|
$avatar->delete();
|
|
}
|
|
|
|
$this->newAvatar($profile->id, $size, $mediatype, $filename);
|
|
}
|
|
|
|
function newAvatar($profile_id, $size, $mediatype, $filename)
|
|
{
|
|
global $config;
|
|
|
|
$avatar = new Avatar();
|
|
$avatar->profile_id = $profile_id;
|
|
|
|
switch($size) {
|
|
case 'mini':
|
|
$avatar->width = 24;
|
|
$avatar->height = 24;
|
|
break;
|
|
case 'normal':
|
|
$avatar->width = 48;
|
|
$avatar->height = 48;
|
|
break;
|
|
default:
|
|
|
|
// Note: Twitter's big avatars are a different size than
|
|
// Laconica's (Laconica's = 96)
|
|
|
|
$avatar->width = 73;
|
|
$avatar->height = 73;
|
|
}
|
|
|
|
$avatar->original = 0; // we don't have the original
|
|
$avatar->mediatype = $mediatype;
|
|
$avatar->filename = $filename;
|
|
$avatar->url = Avatar::url($filename);
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("new filename: $avatar->url");
|
|
}
|
|
|
|
$avatar->created = common_sql_now();
|
|
|
|
$id = $avatar->insert();
|
|
|
|
if (!$id) {
|
|
common_log_db_error($avatar, 'INSERT', __FILE__);
|
|
return null;
|
|
}
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Saved new $size avatar for $profile_id.");
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
|
|
function fetchAvatar($url, $filename)
|
|
{
|
|
$avatar_dir = INSTALLDIR . '/avatar/';
|
|
|
|
$avatarfile = $avatar_dir . $filename;
|
|
|
|
$out = fopen($avatarfile, 'wb');
|
|
if (!$out) {
|
|
common_log(LOG_WARNING, "Couldn't open file $filename", __FILE__);
|
|
return false;
|
|
}
|
|
|
|
if (defined('SCRIPT_DEBUG')) {
|
|
common_debug("Fetching avatar: $url");
|
|
}
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_FILE, $out);
|
|
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
|
|
$result = curl_exec($ch);
|
|
curl_close($ch);
|
|
|
|
fclose($out);
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
ini_set("max_execution_time", "0");
|
|
ini_set("max_input_time", "0");
|
|
set_time_limit(0);
|
|
mb_internal_encoding('UTF-8');
|
|
declare(ticks = 1);
|
|
|
|
$fetcher = new TwitterStatusFetcher();
|
|
$fetcher->runOnce();
|
|
|