]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/TwitterBridge/twitter.php
8f58153d2c39e1d8dee031e6e641425735b024d9
[quix0rs-gnu-social.git] / plugins / TwitterBridge / twitter.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2008-2011 StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 if (!defined('STATUSNET') && !defined('LACONICA')) {
21     exit(1);
22 }
23
24 define('TWITTER_SERVICE', 1); // Twitter is foreign_service ID 1
25
26 function add_twitter_user($twitter_id, $screen_name)
27 {
28     // Clear out any bad old foreign_users with the new user's legit URL
29     // This can happen when users move around or fakester accounts get
30     // repoed, and things like that.
31     $luser = Foreign_user::getForeignUser($twitter_id, TWITTER_SERVICE);
32
33     if (!empty($luser)) {
34         $result = $luser->delete();
35         if ($result != false) {
36             common_log(
37                 LOG_INFO,
38                 "Twitter bridge - removed old Twitter user: $screen_name ($twitter_id)."
39             );
40         }
41     }
42
43     $fuser = new Foreign_user();
44
45     $fuser->nickname = $screen_name;
46     $fuser->uri = 'http://twitter.com/' . $screen_name;
47     $fuser->id = $twitter_id;
48     $fuser->service = TWITTER_SERVICE;
49     $fuser->created = common_sql_now();
50     $result = $fuser->insert();
51
52     if (empty($result)) {
53         common_log(LOG_WARNING,
54             "Twitter bridge - failed to add new Twitter user: $twitter_id - $screen_name.");
55         common_log_db_error($fuser, 'INSERT', __FILE__);
56     } else {
57         common_log(LOG_INFO,
58                    "Twitter bridge - Added new Twitter user: $screen_name ($twitter_id).");
59     }
60
61     return $result;
62 }
63
64 // Creates or Updates a Twitter user
65 function save_twitter_user($twitter_id, $screen_name)
66 {
67     // Check to see whether the Twitter user is already in the system,
68     // and update its screen name and uri if so.
69     $fuser = Foreign_user::getForeignUser($twitter_id, TWITTER_SERVICE);
70
71     if (!empty($fuser)) {
72         // Delete old record if Twitter user changed screen name
73
74         if ($fuser->nickname != $screen_name) {
75             $oldname = $fuser->nickname;
76             $fuser->delete();
77             common_log(LOG_INFO, sprintf('Twitter bridge - Updated nickname (and URI) ' .
78                                          'for Twitter user %1$d - %2$s, was %3$s.',
79                                          $fuser->id,
80                                          $screen_name,
81                                          $oldname));
82         }
83     } else {
84         // Kill any old, invalid records for this screen name
85         $fuser = Foreign_user::getByNickname($screen_name, TWITTER_SERVICE);
86
87         if (!empty($fuser)) {
88             $fuser->delete();
89             common_log(
90                 LOG_INFO,
91                 sprintf(
92                     'Twitter bridge - deteted old record for Twitter ' .
93                     'screen name "%s" belonging to Twitter ID %d.',
94                     $screen_name,
95                     $fuser->id
96                 )
97             );
98         }
99     }
100
101     return add_twitter_user($twitter_id, $screen_name);
102 }
103
104 function is_twitter_bound($notice, $flink) {
105
106     // Don't send activity activities (at least for now)
107     if ($notice->object_type == ActivityObject::ACTIVITY) {
108         return false;
109     }
110
111     // Don't send things that aren't posts (at least for now)
112     if ($notice->verb != ActivityVerb::POST) {
113         return false;
114     }
115
116     // Check to see if notice should go to Twitter
117     if (!empty($flink) && ($flink->noticesync & FOREIGN_NOTICE_SEND == FOREIGN_NOTICE_SEND)) {
118
119         // If it's not a Twitter-style reply, or if the user WANTS to send replies,
120         // or if it's in reply to a twitter notice
121         if (!preg_match('/^@[a-zA-Z0-9_]{1,15}\b/u', $notice->content) ||
122             ($flink->noticesync & FOREIGN_NOTICE_SEND_REPLY == FOREIGN_NOTICE_SEND_REPLY) ||
123             is_twitter_notice($notice->reply_to)) {
124             return true;
125         }
126     }
127
128     return false;
129 }
130
131 function is_twitter_notice($id)
132 {
133     $n2s = Notice_to_status::staticGet('notice_id', $id);
134
135     return (!empty($n2s));
136 }
137
138 /**
139  * Pull the formatted status ID number from a Twitter status object
140  * returned via JSON from Twitter API.
141  *
142  * Encapsulates checking for the id_str attribute, which is required
143  * to read 64-bit "Snowflake" ID numbers on a 32-bit system -- the
144  * integer id attribute gets corrupted into a double-precision float,
145  * losing a few digits of precision.
146  *
147  * Warning: avoid performing arithmetic or direct comparisons with
148  * this number, as it may get coerced back to a double on 32-bit.
149  *
150  * @param object $status
151  * @param string $field base field name if not 'id'
152  * @return mixed id number as int or string
153  */
154 function twitter_id($status, $field='id')
155 {
156     $field_str = "{$field}_str";
157     if (isset($status->$field_str)) {
158         // String version of the id -- required on 32-bit systems
159         // since the 64-bit numbers get corrupted as ints.
160         return $status->$field_str;
161     } else {
162         return $status->$field;
163     }
164 }
165
166 /**
167  * Check if we need to broadcast a notice over the Twitter bridge, and
168  * do so if necessary. Will determine whether to do a straight post or
169  * a repeat/retweet
170  *
171  * This function is meant to be called directly from TwitterQueueHandler.
172  *
173  * @param Notice $notice
174  * @return boolean true if complete or successful, false if we should retry
175  */
176 function broadcast_twitter($notice)
177 {
178     $flink = Foreign_link::getByUserID($notice->profile_id,
179                                        TWITTER_SERVICE);
180
181     // Don't bother with basic auth, since it's no longer allowed
182     if (!empty($flink) && TwitterOAuthClient::isPackedToken($flink->credentials)) {
183         if (is_twitter_bound($notice, $flink)) {
184             if (!empty($notice->repeat_of) && is_twitter_notice($notice->repeat_of)) {
185                 $retweet = retweet_notice($flink, Notice::staticGet('id', $notice->repeat_of));
186                 if (is_object($retweet)) {
187                     Notice_to_status::saveNew($notice->id, twitter_id($retweet));
188                     return true;
189                 } else {
190                     // Our error processing will have decided if we need to requeue
191                     // this or can discard safely.
192                     return $retweet;
193                 }
194             } else {
195                 return broadcast_oauth($notice, $flink);
196             }
197         }
198     }
199
200     return true;
201 }
202
203 /**
204  * Send a retweet to Twitter for a notice that has been previously bridged
205  * in or out.
206  *
207  * Warning: the return value is not guaranteed to be an object; some error
208  * conditions will return a 'true' which should be passed on to a calling
209  * queue handler.
210  *
211  * No local information about the resulting retweet is saved: it's up to
212  * caller to save new mappings etc if appropriate.
213  *
214  * @param Foreign_link $flink
215  * @param Notice $notice
216  * @return mixed object with resulting Twitter status data on success, or true/false/null on error conditions.
217  */
218 function retweet_notice($flink, $notice)
219 {
220     $token = TwitterOAuthClient::unpackToken($flink->credentials);
221     $client = new TwitterOAuthClient($token->key, $token->secret);
222
223     $id = twitter_status_id($notice);
224
225     if (empty($id)) {
226         common_log(LOG_WARNING, "Trying to retweet notice {$notice->id} with no known status id.");
227         return null;
228     }
229
230     try {
231         $status = $client->statusesRetweet($id);
232         return $status;
233     } catch (OAuthClientException $e) {
234         return process_error($e, $flink, $notice);
235     }
236 }
237
238 function twitter_status_id($notice)
239 {
240     $n2s = Notice_to_status::staticGet('notice_id', $notice->id);
241     if (empty($n2s)) {
242         return null;
243     } else {
244         return $n2s->status_id;
245     }
246 }
247
248 /**
249  * Pull any extra information from a notice that we should transfer over
250  * to Twitter beyond the notice text itself.
251  *
252  * @param Notice $notice
253  * @return array of key-value pairs for Twitter update submission
254  * @access private
255  */
256 function twitter_update_params($notice)
257 {
258     $params = array();
259     if ($notice->lat || $notice->lon) {
260         $params['lat'] = $notice->lat;
261         $params['long'] = $notice->lon;
262     }
263     if (!empty($notice->reply_to) && is_twitter_notice($notice->reply_to)) {
264         $reply = Notice::staticGet('id', $notice->reply_to);
265         $params['in_reply_to_status_id'] = twitter_status_id($reply);
266     }
267     return $params;
268 }
269
270 function broadcast_oauth($notice, $flink) {
271     $user = $flink->getUser();
272     $statustxt = format_status($notice);
273     $params = twitter_update_params($notice);
274
275     $token = TwitterOAuthClient::unpackToken($flink->credentials);
276     $client = new TwitterOAuthClient($token->key, $token->secret);
277     $status = null;
278
279     try {
280         $status = $client->statusesUpdate($statustxt, $params);
281         if (!empty($status)) {
282             Notice_to_status::saveNew($notice->id, twitter_id($status));
283         }
284     } catch (OAuthClientException $e) {
285         return process_error($e, $flink, $notice);
286     }
287
288     if (empty($status)) {
289         // This could represent a failure posting,
290         // or the Twitter API might just be behaving flakey.
291         $errmsg = sprintf('Twitter bridge - No data returned by Twitter API when ' .
292                           'trying to post notice %d for User %s (user id %d).',
293                           $notice->id,
294                           $user->nickname,
295                           $user->id);
296
297         common_log(LOG_WARNING, $errmsg);
298
299         return false;
300     }
301
302     // Notice crossed the great divide
303     $msg = sprintf('Twitter bridge - posted notice %d to Twitter using ' .
304                    'OAuth for User %s (user id %d).',
305                    $notice->id,
306                    $user->nickname,
307                    $user->id);
308
309     common_log(LOG_INFO, $msg);
310
311     return true;
312 }
313
314 function process_error($e, $flink, $notice)
315 {
316     $user = $flink->getUser();
317     $code = $e->getCode();
318
319     $logmsg = sprintf('Twitter bridge - %d posting notice %d for ' .
320                       'User %s (user id: %d): %s.',
321                       $code,
322                       $notice->id,
323                       $user->nickname,
324                       $user->id,
325                       $e->getMessage());
326
327     common_log(LOG_WARNING, $logmsg);
328
329     // http://dev.twitter.com/pages/responses_errors
330     switch($code) {
331      case 400:
332          // Probably invalid data (bad Unicode chars or coords) that
333          // cannot be resolved by just sending again.
334          //
335          // It could also be rate limiting, but retrying immediately
336          // won't help much with that, so we'll discard for now.
337          // If a facility for retrying things later comes up in future,
338          // we can detect the rate-limiting headers and use that.
339          //
340          // Discard the message permanently.
341          return true;
342          break;
343      case 401:
344         // Probably a revoked or otherwise bad access token - nuke!
345         remove_twitter_link($flink);
346         return true;
347         break;
348      case 403:
349         // User has exceeder her rate limit -- toss the notice
350         return true;
351         break;
352      case 404:
353          // Resource not found. Shouldn't happen much on posting,
354          // but just in case!
355          //
356          // Consider it a matter for tossing the notice.
357          return true;
358          break;
359      default:
360
361         // For every other case, it's probably some flakiness so try
362         // sending the notice again later (requeue).
363
364         return false;
365         break;
366     }
367 }
368
369 function format_status($notice)
370 {
371     // Start with the plaintext source of this notice...
372     $statustxt = $notice->content;
373
374     // Convert !groups to #hashes
375     // XXX: Make this an optional setting?
376     $statustxt = preg_replace('/(^|\s)!([A-Za-z0-9]{1,64})/', "\\1#\\2", $statustxt);
377
378     // Twitter still has a 140-char hardcoded max.
379     if (mb_strlen($statustxt) > 140) {
380         $noticeUrl = common_shorten_url($notice->uri);
381         $urlLen = mb_strlen($noticeUrl);
382         $statustxt = mb_substr($statustxt, 0, 140 - ($urlLen + 3)) . ' … ' . $noticeUrl;
383     }
384
385     return $statustxt;
386 }
387
388 function remove_twitter_link($flink)
389 {
390     $user = $flink->getUser();
391
392     common_log(LOG_INFO, 'Removing Twitter bridge Foreign link for ' .
393                "user $user->nickname (user id: $user->id).");
394
395     $result = $flink->safeDelete();
396
397     if (empty($result)) {
398         common_log(LOG_ERR, 'Could not remove Twitter bridge ' .
399                    "Foreign_link for $user->nickname (user id: $user->id)!");
400         common_log_db_error($flink, 'DELETE', __FILE__);
401     }
402
403     // Notify the user that her Twitter bridge is down
404
405     if (isset($user->email)) {
406         $result = mail_twitter_bridge_removed($user);
407
408         if (!$result) {
409             $msg = 'Unable to send email to notify ' .
410               "$user->nickname (user id: $user->id) " .
411               'that their Twitter bridge link was ' .
412               'removed!';
413
414             common_log(LOG_WARNING, $msg);
415         }
416     }
417 }
418
419 /**
420  * Send a mail message to notify a user that her Twitter bridge link
421  * has stopped working, and therefore has been removed.  This can
422  * happen when the user changes her Twitter password, or otherwise
423  * revokes access.
424  *
425  * @param User $user   user whose Twitter bridge link has been removed
426  *
427  * @return boolean success flag
428  */
429 function mail_twitter_bridge_removed($user)
430 {
431     $profile = $user->getProfile();
432
433     common_switch_locale($user->language);
434
435     // TRANS: Mail subject after forwarding notices to Twitter has stopped working.
436     $subject = sprintf(_m('Your Twitter bridge has been disabled'));
437
438     $site_name = common_config('site', 'name');
439
440     // TRANS: Mail body after forwarding notices to Twitter has stopped working.
441     // TRANS: %1$ is the name of the user the mail is sent to, %2$s is a URL to the
442     // TRANS: Twitter settings, %3$s is the StatusNet sitename.
443     $body = sprintf(_m('Hi, %1$s. We\'re sorry to inform you that your ' .
444         'link to Twitter has been disabled. We no longer seem to have ' .
445     'permission to update your Twitter status. Did you maybe revoke ' .
446     '%3$s\'s access?' . "\n\n" .
447     'You can re-enable your Twitter bridge by visiting your ' .
448     "Twitter settings page:\n\n\t%2\$s\n\n" .
449         "Regards,\n%3\$s"),
450         $profile->getBestName(),
451         common_local_url('twittersettings'),
452         common_config('site', 'name'));
453
454     common_switch_locale();
455     return mail_to_user($user, $subject, $body);
456 }