- Map notices to Facebook stream items

- rename plugin FacebookBridgePlugin
- delete/like/unlike notices across the bridge
This commit is contained in:
Zach Copley 2010-11-16 02:30:08 +00:00
parent 3c921f38de
commit ca4c0a1601
6 changed files with 716 additions and 97 deletions

View File

@ -45,10 +45,9 @@ define("FACEBOOK_SERVICE", 2);
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
* @link http://status.net/
*/
class FacebookSSOPlugin extends Plugin
class FacebookBridgePlugin extends Plugin
{
public $appId = null; // Facebook application ID
public $apikey = null; // Facebook API key (for deprecated "Old REST API")
public $secret = null; // Facebook application secret
public $facebook = null; // Facebook application instance
public $dir = null; // Facebook SSO plugin dir
@ -64,7 +63,6 @@ class FacebookSSOPlugin extends Plugin
{
$this->facebook = Facebookclient::getFacebook(
$this->appId,
$this->apikey,
$this->secret
);
@ -101,12 +99,32 @@ class FacebookSSOPlugin extends Plugin
case 'FacebookQueueHandler':
include_once $dir . '/lib/' . strtolower($cls) . '.php';
return false;
case 'Notice_to_item':
include_once $dir . '/classes/' . $cls . '.php';
return false;
default:
return true;
}
}
/**
* Database schema setup
*
* We maintain a table mapping StatusNet notices to Facebook items
*
* @see Schema
* @see ColumnDef
*
* @return boolean hook value; true means continue processing, false means stop.
*/
function onCheckSchema()
{
$schema = Schema::get();
$schema->ensureTable('notice_to_item', Notice_to_item::schemaDef());
return true;
}
/*
* Does this $action need the Facebook JavaScripts?
*/
@ -436,6 +454,54 @@ ENDOFSCRIPT;
}
}
/**
* If a notice gets deleted, remove the Notice_to_item mapping and
* delete the item on Facebook
*
* @param User $user The user doing the deleting
* @param Notice $notice The notice getting deleted
*
* @return boolean hook value
*/
function onStartDeleteOwnNotice(User $user, Notice $notice)
{
$client = new Facebookclient($notice);
$client->streamRemove();
return true;
}
/**
* Notify remote users when their notices get favorited.
*
* @param Profile or User $profile of local user doing the faving
* @param Notice $notice being favored
* @return hook return value
*/
function onEndFavorNotice(Profile $profile, Notice $notice)
{
$client = new Facebookclient($notice);
$client->like();
return true;
}
/**
* Notify remote users when their notices get de-favorited.
*
* @param Profile $profile Profile person doing the de-faving
* @param Notice $notice Notice being favored
*
* @return hook return value
*/
function onEndDisfavorNotice(Profile $profile, Notice $notice)
{
$client = new Facebookclient($notice);
$client->unLike();
return true;
}
/*
* Add version info for this plugin
*
@ -447,7 +513,7 @@ ENDOFSCRIPT;
'name' => 'Facebook Single-Sign-On',
'version' => STATUSNET_VERSION,
'author' => 'Craig Andrews, Zach Copley',
'homepage' => 'http://status.net/wiki/Plugin:FacebookSSO',
'homepage' => 'http://status.net/wiki/Plugin:FacebookBridge',
'rawdescription' =>
_m('A plugin for integrating StatusNet with Facebook.')
);

View File

@ -112,7 +112,7 @@ class FacebookdeauthorizeAction extends Action
common_log(
LOG_WARNING,
sprintf(
'%s (%d), fbuid $s has deauthorized his/her Facebook '
'%s (%d), fbuid %d has deauthorized his/her Facebook '
. 'connection but hasn\'t set a password so s/he '
. 'is locked out.',
$user->nickname,
@ -135,8 +135,8 @@ class FacebookdeauthorizeAction extends Action
);
} else {
// It probably wasn't Facebook that hit this action,
// so redirect to the login page
common_redirect(common_local_url('login'), 303);
// so redirect to the public timeline
common_redirect(common_local_url('public'), 303);
}
}
}

View File

@ -126,12 +126,20 @@ class FacebookfinishloginAction extends Action
}
} else if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$this->handlePost();
} else {
$this->tryLogin();
}
}
function handlePost()
{
$token = $this->trimmed('token');
if (!$token || $token != common_session_token()) {
$this->showForm(
_m('There was a problem with your session token. Try again, please.'));
_m('There was a problem with your session token. Try again, please.')
);
return;
}
@ -160,10 +168,6 @@ class FacebookfinishloginAction extends Action
$this->trimmed('newname')
);
}
} else {
$this->tryLogin();
}
}
function showPageNotice()
@ -343,19 +347,23 @@ class FacebookfinishloginAction extends Action
'nickname' => $nickname,
'fullname' => $this->fbuser['first_name']
. ' ' . $this->fbuser['last_name'],
'email' => $this->fbuser['email'],
'email_confirmed' => true,
'homepage' => $this->fbuser['website'],
'bio' => $this->fbuser['about'],
'location' => $this->fbuser['location']['name']
);
// It's possible that the email address is already in our
// DB. It's a unique key, so we need to check
if ($this->isNewEmail($this->fbuser['email'])) {
$args['email'] = $this->fbuser['email'];
$args['email_confirmed'] = true;
}
if (!empty($invite)) {
$args['code'] = $invite->code;
}
$user = User::register($args);
$result = $this->flinkUser($user->id, $this->fbuid);
if (!$result) {
@ -363,6 +371,9 @@ class FacebookfinishloginAction extends Action
return;
}
// Add a Foreign_user record
Facebookclient::addFacebookUser($this->fbuser);
$this->setAvatar($user);
common_set_user($user);
@ -371,20 +382,16 @@ class FacebookfinishloginAction extends Action
common_log(
LOG_INFO,
sprintf(
'Registered new user %d from Facebook user %s',
'Registered new user %s (%d) from Facebook user %s, (fbuid %d)',
$user->nickname,
$user->id,
$this->fbuser['name'],
$this->fbuid
),
__FILE__
);
common_redirect(
common_local_url(
'showstream',
array('nickname' => $user->nickname)
),
303
);
$this->goHome($user->nickname);
}
/*
@ -401,17 +408,19 @@ class FacebookfinishloginAction extends Action
// fetch the picture from Facebook
$client = new HTTPClient();
common_debug("status = $status - " . $finalUrl , __FILE__);
// fetch the actual picture
$response = $client->get($picUrl);
if ($response->isOk()) {
$finalUrl = $client->getUrl();
$filename = 'facebook-' . substr(strrchr($finalUrl, '/'), 1 );
common_debug("Filename = " . $filename, __FILE__);
// Make sure the filename is unique becuase it's possible for a user
// to deauthorize our app, and then come back in as a new user but
// have the same Facebook picture (avatar URLs have a unique index
// and their URLs are based on the filenames).
$filename = 'facebook-' . common_good_rand(4) . '-'
. substr(strrchr($finalUrl, '/'), 1);
$ok = file_put_contents(
Avatar::path($filename),
@ -430,17 +439,20 @@ class FacebookfinishloginAction extends Action
} else {
// save it as an avatar
$profile = $user->getProfile();
if ($profile->setOriginal($filename)) {
common_log(
LOG_INFO,
sprintf(
'Saved avatar for %s (%d) from Facebook profile %s, filename = %s',
'Saved avatar for %s (%d) from Facebook picture for '
. '%s (fbuid %d), filename = %s',
$user->nickname,
$user->id,
$this->fbuser['name'],
$this->fbuid,
$picture
$filename
),
__FILE__
);
@ -462,19 +474,17 @@ class FacebookfinishloginAction extends Action
$user = User::staticGet('nickname', $nickname);
if (!empty($user)) {
common_debug('Facebook Connect Plugin - ' .
"Legit user to connect to Facebook: $nickname");
common_debug(
sprintf(
'Found a legit user to connect to Facebook: %s (%d)',
$user->nickname,
$user->id
),
__FILE__
);
}
$result = $this->flinkUser($user->id, $this->fbuid);
if (!$result) {
$this->serverError(_m('Error connecting user to Facebook.'));
return;
}
common_debug('Facebook Connnect Plugin - ' .
"Connected Facebook user $this->fbuid to local user $user->id");
$this->tryLinkUser($user);
common_set_user($user);
common_real_login(true);
@ -485,7 +495,12 @@ class FacebookfinishloginAction extends Action
function connectUser()
{
$user = common_current_user();
$this->tryLinkUser($user);
common_redirect(common_local_url('facebookfinishlogin'), 303);
}
function tryLinkUser($user)
{
$result = $this->flinkUser($user->id, $this->fbuid);
if (empty($result)) {
@ -495,14 +510,14 @@ class FacebookfinishloginAction extends Action
common_debug(
sprintf(
'Connected Facebook user %s to local user %d',
'Connected Facebook user %s (fbuid %d) to local user %s (%d)',
$this->fbuser['name'],
$this->fbuid,
$user->nickname,
$user->id
),
__FILE__
);
common_redirect(common_local_url('facebookfinishlogin'), 303);
}
function tryLogin()
@ -595,8 +610,8 @@ class FacebookfinishloginAction extends Action
// Try the full name
$fullname = trim($this->fbuser['firstname'] .
' ' . $this->fbuser['lastname']);
$fullname = trim($this->fbuser['first_name'] .
' ' . $this->fbuser['last_name']);
if (!empty($fullname)) {
$fullname = $this->nicknamize($fullname);
@ -617,20 +632,57 @@ class FacebookfinishloginAction extends Action
return strtolower($str);
}
/*
* Is the desired nickname already taken?
*
* @return boolean result
*/
function isNewNickname($str)
{
if (!Validate::string($str, array('min_length' => 1,
if (
!Validate::string(
$str,
array(
'min_length' => 1,
'max_length' => 64,
'format' => NICKNAME_FMT))) {
'format' => NICKNAME_FMT
)
)
) {
return false;
}
if (!User::allowed_nickname($str)) {
return false;
}
if (User::staticGet('nickname', $str)) {
return false;
}
return true;
}
/*
* Do we already have a user record with this email?
* (emails have to be unique but they can change)
*
* @param string $email the email address to check
*
* @return boolean result
*/
function isNewEmail($email)
{
// we shouldn't have to validate the format
$result = User::staticGet('email', $email);
if (empty($result)) {
common_debug("XXXXXXXXXXXXXXXXXX We've never seen this email before!!!");
return true;
}
common_debug("XXXXXXXXXXXXXXXXXX dupe email address!!!!");
return false;
}
}

View File

@ -89,7 +89,7 @@ class FacebookloginAction extends Action
$attrs = array(
'src' => common_path(
'plugins/FacebookSSO/images/login-button.png',
'plugins/FacebookBridge/images/login-button.png',
true
),
'alt' => 'Login with Facebook',

View File

@ -0,0 +1,190 @@
<?php
/**
* Data class for storing notice-to-Facebook-item mappings
*
* PHP version 5
*
* @category Data
* @package StatusNet
* @author Zach Copley <zach@status.net>
* @license http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
* @link http://status.net/
*
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, 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/>.
*/
if (!defined('STATUSNET')) {
exit(1);
}
require_once INSTALLDIR . '/classes/Memcached_DataObject.php';
/**
* Data class for mapping notices to Facebook stream items
*
* Note that notice_id is unique only within a single database; if you
* want to share this data for some reason, get the notice's URI and use
* that instead, since it's universally unique.
*
* @category Action
* @package StatusNet
* @author Zach Copley <zach@status.net>
* @license http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
* @link http://status.net/
*
* @see DB_DataObject
*/
class Notice_to_item extends Memcached_DataObject
{
public $__table = 'notice_to_item'; // table name
public $notice_id; // int(4) primary_key not_null
public $item_id; // varchar(255) not null
public $created; // datetime
/**
* Get an instance by key
*
* This is a utility method to get a single instance with a given key value.
*
* @param string $k Key to use to lookup
* @param mixed $v Value to lookup
*
* @return Notice_to_item object found, or null for no hits
*
*/
function staticGet($k, $v=null)
{
return Memcached_DataObject::staticGet('Notice_to_item', $k, $v);
}
/**
* return table definition for DB_DataObject
*
* DB_DataObject needs to know something about the table to manipulate
* instances. This method provides all the DB_DataObject needs to know.
*
* @return array array of column definitions
*/
function table()
{
return array(
'notice_id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
'item_id' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL
);
}
static function schemaDef()
{
return array(
new ColumnDef('notice_id', 'integer', null, false, 'PRI'),
new ColumnDef('item_id', 'varchar', 255, false, 'UNI'),
new ColumnDef('created', 'datetime', null, false)
);
}
/**
* return key definitions for DB_DataObject
*
* DB_DataObject needs to know about keys that the table has, since it
* won't appear in StatusNet's own keys list. In most cases, this will
* simply reference your keyTypes() function.
*
* @return array list of key field names
*/
function keys()
{
return array_keys($this->keyTypes());
}
/**
* return key definitions for Memcached_DataObject
*
* Our caching system uses the same key definitions, but uses a different
* method to get them. This key information is used to store and clear
* cached data, so be sure to list any key that will be used for static
* lookups.
*
* @return array associative array of key definitions, field name to type:
* 'K' for primary key: for compound keys, add an entry for each component;
* 'U' for unique keys: compound keys are not well supported here.
*/
function keyTypes()
{
return array('notice_id' => 'K', 'item_id' => 'U');
}
/**
* Magic formula for non-autoincrementing integer primary keys
*
* If a table has a single integer column as its primary key, DB_DataObject
* assumes that the column is auto-incrementing and makes a sequence table
* to do this incrementation. Since we don't need this for our class, we
* overload this method and return the magic formula that DB_DataObject needs.
*
* @return array magic three-false array that stops auto-incrementing.
*/
function sequenceKey()
{
return array(false, false, false);
}
/**
* Save a mapping between a notice and a Facebook item
*
* @param integer $notice_id ID of the notice in StatusNet
* @param integer $item_id ID of the stream item on Facebook
*
* @return Notice_to_item new object for this value
*/
static function saveNew($notice_id, $item_id)
{
$n2i = Notice_to_item::staticGet('notice_id', $notice_id);
if (!empty($n2i)) {
return $n2i;
}
$n2i = Notice_to_item::staticGet('item_id', $item_id);
if (!empty($n2i)) {
return $n2i;
}
common_debug(
"Mapping notice {$notice_id} to Facebook item {$item_id}",
__FILE__
);
$n2i = new Notice_to_item();
$n2i->notice_id = $notice_id;
$n2i->item_id = $item_id;
$n2i->created = common_sql_now();
$n2i->insert();
return $n2i;
}
}

View File

@ -173,11 +173,11 @@ class Facebookclient
if ($this->isFacebookBound()) {
common_debug("notice is facebook bound", __FILE__);
if (empty($this->flink->credentials)) {
$this->sendOldRest();
return $this->sendOldRest();
} else {
// Otherwise we most likely have an access token
$this->sendGraph();
return $this->sendGraph();
}
} else {
@ -213,6 +213,7 @@ class Facebookclient
$params = array(
'access_token' => $this->flink->credentials,
// XXX: Need to worrry about length of the message?
'message' => $this->notice->content
);
@ -220,7 +221,7 @@ class Facebookclient
if (!empty($attachments)) {
// We can only send one attachment with the Graph API
// We can only send one attachment with the Graph API :(
$first = array_shift($attachments);
@ -240,6 +241,21 @@ class Facebookclient
sprintf('/%s/feed', $fbuid), 'post', $params
);
// Save a mapping
Notice_to_item::saveNew($this->notice->id, $result['id']);
common_log(
LOG_INFO,
sprintf(
"Posted notice %d as a stream item for %s (%d), fbuid %s",
$this->notice->id,
$this->user->nickname,
$this->user->id,
$fbuid
),
__FILE__
);
} catch (FacebookApiException $e) {
return $this->handleFacebookError($e);
}
@ -481,12 +497,14 @@ class Facebookclient
$result = $this->facebook->api(
array(
'method' => 'users.setStatus',
'status' => $this->notice->content,
'status' => $this->formatMessage(),
'status_includes_verb' => true,
'uid' => $fbuid
)
);
if ($result == 1) { // 1 is success
common_log(
LOG_INFO,
sprintf(
@ -499,6 +517,22 @@ class Facebookclient
__FILE__
);
// There is no item ID returned for status update so we can't
// save a Notice_to_item mapping
} else {
$msg = sprintf(
"Error posting notice %s as a status update for %s (%d), fbuid %s - error code: %s",
$this->notice->id,
$this->user->nickname,
$this->user->id,
$fbuid,
$result // will contain 0, or an error
);
throw new FacebookApiException($msg, $result);
}
}
/*
@ -524,12 +558,17 @@ class Facebookclient
$result = $this->facebook->api(
array(
'method' => 'stream.publish',
'message' => $this->notice->content,
'message' => $this->formatMessage(),
'attachment' => $fbattachment,
'uid' => $fbuid
)
);
if (!empty($result)) { // result will contain the item ID
// Save a mapping
Notice_to_item::saveNew($this->notice->id, $result);
common_log(
LOG_INFO,
sprintf(
@ -543,6 +582,42 @@ class Facebookclient
__FILE__
);
} else {
$msg = sprintf(
'Could not post notice %d as a %s for %s (%d), fbuid %s - error code: %s',
$this->notice->id,
empty($fbattachment) ? 'stream item' : 'stream item with attachment',
$this->user->nickname,
$this->user->id,
$result, // result will contain an error code
$fbuid
);
throw new FacebookApiException($msg, $result);
}
}
/*
* Format the text message of a stream item so it's appropriate for
* sending to Facebook. If the notice is too long, truncate it, and
* add a linkback to the original notice at the end.
*
* @return String $txt the formated message
*/
function formatMessage()
{
// Start with the plaintext source of this notice...
$txt = $this->notice->content;
// Facebook has a 420-char hardcoded max.
if (mb_strlen($statustxt) > 420) {
$noticeUrl = common_shorten_url($this->notice->uri);
$urlLen = mb_strlen($noticeUrl);
$txt = mb_substr($statustxt, 0, 420 - ($urlLen + 3)) . ' … ' . $noticeUrl;
}
return $txt;
}
/*
@ -708,4 +783,240 @@ BODY;
return mail_to_user($this->user, $subject, $body);
}
/*
* Check to see if we have a mapping to a copy of this notice
* on Facebook
*
* @param Notice $notice the notice to check
*
* @return mixed null if it can't find one, or the id of the Facebook
* stream item
*/
static function facebookStatusId($notice)
{
$n2i = Notice_to_item::staticGet('notice_id', $notice->id);
if (empty($n2i)) {
return null;
} else {
return $n2i->item_id;
}
}
/*
* Save a Foreign_user record of a Facebook user
*
* @param object $fbuser a Facebook Graph API user obj
* See: http://developers.facebook.com/docs/reference/api/user
* @return mixed $result Id or key
*
*/
static function addFacebookUser($fbuser)
{
// remove any existing, possibly outdated, record
$luser = Foreign_user::getForeignUser($fbuser['id'], FACEBOOK_SERVICE);
if (!empty($luser)) {
$result = $luser->delete();
if ($result != false) {
common_log(
LOG_INFO,
sprintf(
'Removed old Facebook user: %s, fbuid %d',
$fbuid['name'],
$fbuid['id']
),
__FILE__
);
}
}
$fuser = new Foreign_user();
$fuser->nickname = $fbuser['name'];
$fuser->uri = $fbuser['link'];
$fuser->id = $fbuser['id'];
$fuser->service = FACEBOOK_SERVICE;
$fuser->created = common_sql_now();
$result = $fuser->insert();
if (empty($result)) {
common_log(
LOG_WARNING,
sprintf(
'Failed to add new Facebook user: %s, fbuid %d',
$fbuser['name'],
$fbuser['id']
),
__FILE__
);
common_log_db_error($fuser, 'INSERT', __FILE__);
} else {
common_log(
LOG_INFO,
sprintf(
'Added new Facebook user: %s, fbuid %d',
$fbuser['name'],
$fbuser['id']
),
__FILE__
);
}
return $result;
}
/*
* Remove an item from a Facebook user's feed if we have a mapping
* for it.
*/
function streamRemove()
{
$n2i = Notice_to_item::staticGet('notice_id', $this->notice->id);
if (!empty($this->flink) && !empty($n2i)) {
$result = $this->facebook->api(
array(
'method' => 'stream.remove',
'post_id' => $n2i->item_id,
'uid' => $this->flink->foreign_id
)
);
if (!empty($result) && result == true) {
common_log(
LOG_INFO,
sprintf(
'Deleted Facebook item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
$n2i->delete();
} else {
common_log(
LOG_WARNING,
sprintf(
'Could not deleted Facebook item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
}
}
}
/*
* Like an item in a Facebook user's feed if we have a mapping
* for it.
*/
function like()
{
$n2i = Notice_to_item::staticGet('notice_id', $this->notice->id);
if (!empty($this->flink) && !empty($n2i)) {
$result = $this->facebook->api(
array(
'method' => 'stream.addlike',
'post_id' => $n2i->item_id,
'uid' => $this->flink->foreign_id
)
);
if (!empty($result) && result == true) {
common_log(
LOG_INFO,
sprintf(
'Added like for item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
} else {
common_log(
LOG_WARNING,
sprintf(
'Could not like Facebook item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
}
}
}
/*
* Unlike an item in a Facebook user's feed if we have a mapping
* for it.
*/
function unLike()
{
$n2i = Notice_to_item::staticGet('notice_id', $this->notice->id);
if (!empty($this->flink) && !empty($n2i)) {
$result = $this->facebook->api(
array(
'method' => 'stream.removeLike',
'post_id' => $n2i->item_id,
'uid' => $this->flink->foreign_id
)
);
if (!empty($result) && result == true) {
common_log(
LOG_INFO,
sprintf(
'Removed like for item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
} else {
common_log(
LOG_WARNING,
sprintf(
'Could not remove like for Facebook item: %s for %s (%d), fbuid %d',
$n2i->item_id,
$this->user->nickname,
$this->user->id,
$this->flink->foreign_id
),
__FILE__
);
}
}
}
}