From ca4c0a160122d20f95877102f9712aee45c7afb8 Mon Sep 17 00:00:00 2001
From: Zach Copley <zach@status.net>
Date: Tue, 16 Nov 2010 02:30:08 +0000
Subject: [PATCH] - Map notices to Facebook stream items - rename plugin
 FacebookBridgePlugin - delete/like/unlike notices across the bridge

---
 ...SSOPlugin.php => FacebookBridgePlugin.php} |  74 +++-
 .../actions/facebookdeauthorize.php           |   6 +-
 .../actions/facebookfinishlogin.php           | 190 ++++++----
 plugins/FacebookSSO/actions/facebooklogin.php |   2 +-
 .../FacebookSSO/classes/Notice_to_item.php    | 190 ++++++++++
 plugins/FacebookSSO/lib/facebookclient.php    | 351 +++++++++++++++++-
 6 files changed, 716 insertions(+), 97 deletions(-)
 rename plugins/FacebookSSO/{FacebookSSOPlugin.php => FacebookBridgePlugin.php} (86%)
 create mode 100644 plugins/FacebookSSO/classes/Notice_to_item.php

diff --git a/plugins/FacebookSSO/FacebookSSOPlugin.php b/plugins/FacebookSSO/FacebookBridgePlugin.php
similarity index 86%
rename from plugins/FacebookSSO/FacebookSSOPlugin.php
rename to plugins/FacebookSSO/FacebookBridgePlugin.php
index 19d61211d8..c30ea15440 100644
--- a/plugins/FacebookSSO/FacebookSSOPlugin.php
+++ b/plugins/FacebookSSO/FacebookBridgePlugin.php
@@ -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.')
         );
diff --git a/plugins/FacebookSSO/actions/facebookdeauthorize.php b/plugins/FacebookSSO/actions/facebookdeauthorize.php
index fb4afa13bc..cb816fc54a 100644
--- a/plugins/FacebookSSO/actions/facebookdeauthorize.php
+++ b/plugins/FacebookSSO/actions/facebookdeauthorize.php
@@ -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);
             }
         }
     }
diff --git a/plugins/FacebookSSO/actions/facebookfinishlogin.php b/plugins/FacebookSSO/actions/facebookfinishlogin.php
index e61f351547..2174c5ad4a 100644
--- a/plugins/FacebookSSO/actions/facebookfinishlogin.php
+++ b/plugins/FacebookSSO/actions/facebookfinishlogin.php
@@ -97,7 +97,7 @@ class FacebookfinishloginAction extends Action
         parent::handle($args);
 
         if (common_is_real_login()) {
-            
+
             // User is already logged in, are her accounts already linked?
 
             $flink = Foreign_link::getByForeignID($this->fbuid, FACEBOOK_SERVICE);
@@ -121,48 +121,52 @@ class FacebookfinishloginAction extends Action
             } else {
 
                 // Possibly reconnect an existing account
-                
+
                 $this->connectUser();
             }
 
         } else if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+            $this->handlePost();
+        } else {
+            $this->tryLogin();
+        }
+    }
 
-            $token = $this->trimmed('token');
+    function handlePost()
+    {
+        $token = $this->trimmed('token');
 
-            if (!$token || $token != common_session_token()) {
+        if (!$token || $token != common_session_token()) {
+            $this->showForm(
+                _m('There was a problem with your session token. Try again, please.')
+            );
+            return;
+        }
+
+        if ($this->arg('create')) {
+
+            if (!$this->boolean('license')) {
                 $this->showForm(
-                    _m('There was a problem with your session token. Try again, please.'));
+                    _m('You can\'t register if you don\'t agree to the license.'),
+                    $this->trimmed('newname')
+                );
                 return;
             }
 
-            if ($this->arg('create')) {
-
-                if (!$this->boolean('license')) {
-                    $this->showForm(
-                        _m('You can\'t register if you don\'t agree to the license.'),
-                        $this->trimmed('newname')
-                    );
-                    return;
-                }
-
-                // We has a valid Facebook session and the Facebook user has
-                // agreed to the SN license, so create a new user
-                $this->createNewUser();
-
-            } else if ($this->arg('connect')) {
+            // We has a valid Facebook session and the Facebook user has
+            // agreed to the SN license, so create a new user
+            $this->createNewUser();
 
-                $this->connectNewUser();
+        } else if ($this->arg('connect')) {
 
-            } else {
+            $this->connectNewUser();
 
-                $this->showForm(
-                    _m('An unknown error has occured.'),
-                    $this->trimmed('newname')
-                );
-            }
         } else {
 
-            $this->tryLogin();
+            $this->showForm(
+                _m('An unknown error has occured.'),
+                $this->trimmed('newname')
+            );
         }
     }
 
@@ -173,7 +177,7 @@ class FacebookfinishloginAction extends Action
             $this->element('div', array('class' => 'error'), $this->error);
 
         } else {
-        
+
             $this->element(
                 'div', 'instructions',
                 // TRANS: %s is the site name.
@@ -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);
-
+        $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");
-        }
-
-        $result = $this->flinkUser($user->id, $this->fbuid);
-
-        if (!$result) {
-            $this->serverError(_m('Error connecting user to Facebook.'));
-            return;
+            common_debug(
+                sprintf(
+                    'Found a legit user to connect to Facebook: %s (%d)',
+                    $user->nickname,
+                    $user->id
+                ),
+                __FILE__
+            );
         }
 
-        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()
@@ -573,7 +588,7 @@ class FacebookfinishloginAction extends Action
         $flink->user_id = $user_id;
         $flink->foreign_id = $fbuid;
         $flink->service = FACEBOOK_SERVICE;
-        
+
         // Pull the access token from the Facebook cookies
         $flink->credentials = $this->facebook->getAccessToken();
 
@@ -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);
      }
 
-    function isNewNickname($str)
-    {
-        if (!Validate::string($str, array('min_length' => 1,
-                                          'max_length' => 64,
-                                          'format' => NICKNAME_FMT))) {
+     /*
+      * Is the desired nickname already taken?
+      *
+      * @return boolean result
+      */
+     function isNewNickname($str)
+     {
+        if (
+            !Validate::string(
+                $str,
+                array(
+                    'min_length' => 1,
+                    'max_length' => 64,
+                    '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;
+     }
+
 }
diff --git a/plugins/FacebookSSO/actions/facebooklogin.php b/plugins/FacebookSSO/actions/facebooklogin.php
index 08c237fe6e..9a230b7241 100644
--- a/plugins/FacebookSSO/actions/facebooklogin.php
+++ b/plugins/FacebookSSO/actions/facebooklogin.php
@@ -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',
diff --git a/plugins/FacebookSSO/classes/Notice_to_item.php b/plugins/FacebookSSO/classes/Notice_to_item.php
new file mode 100644
index 0000000000..a6a8030342
--- /dev/null
+++ b/plugins/FacebookSSO/classes/Notice_to_item.php
@@ -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;
+    }
+}
diff --git a/plugins/FacebookSSO/lib/facebookclient.php b/plugins/FacebookSSO/lib/facebookclient.php
index cf00b55e3a..33edf5c6b1 100644
--- a/plugins/FacebookSSO/lib/facebookclient.php
+++ b/plugins/FacebookSSO/lib/facebookclient.php
@@ -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,24 +497,42 @@ class Facebookclient
         $result = $this->facebook->api(
             array(
                 'method'               => 'users.setStatus',
-                'status'               => $this->notice->content,
+                'status'               => $this->formatMessage(),
                 'status_includes_verb' => true,
                 'uid'                  => $fbuid
             )
         );
 
-        common_log(
-            LOG_INFO,
-            sprintf(
-                "Posted notice %s as a status update for %s (%d), fbuid %s",
+        if ($result == 1) { // 1 is success
+
+            common_log(
+                LOG_INFO,
+                sprintf(
+                    "Posted notice %s as a status update for %s (%d), fbuid %s",
+                    $this->notice->id,
+                    $this->user->nickname,
+                    $this->user->id,
+                    $fbuid
+                ),
+                __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
-            ),
-            __FILE__
-        );
+                $fbuid,
+                $result // will contain 0, or an error
+            );
 
+            throw new FacebookApiException($msg, $result);
+        }
     }
 
     /*
@@ -524,25 +558,66 @@ class Facebookclient
         $result = $this->facebook->api(
             array(
                 'method'     => 'stream.publish',
-                'message'    => $this->notice->content,
+                'message'    => $this->formatMessage(),
                 'attachment' => $fbattachment,
                 'uid'        => $fbuid
             )
         );
 
-        common_log(
-            LOG_INFO,
-            sprintf(
-                'Posted notice %d as a %s for %s (%d), fbuid %s',
+        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(
+                    'Posted notice %d as a %s for %s (%d), fbuid %s',
+                    $this->notice->id,
+                    empty($fbattachment) ? 'stream item' : 'stream item with attachment',
+                    $this->user->nickname,
+                    $this->user->id,
+                    $fbuid
+                ),
+                __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
-            ),
-            __FILE__
-        );
+            );
+
+            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__
+                );
+            }
+        }
+    }
+
 }
-- 
2.39.5