twitter.php 16 KB

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