3 * Name: Twitter Connector
4 * Description: Bidirectional (posting, relaying and reading) connector for Twitter.
6 * Author: Tobias Diekershoff <https://f.diekershoff.de/profile/tobias>
7 * Author: Michael Vogel <https://pirati.ca/profile/heluecht>
8 * Maintainer: Hypolite Petovan <https://friendica.mrpetovan.com/profile/hypolite>
10 * Copyright (c) 2011-2013 Tobias Diekershoff, Michael Vogel, Hypolite Petovan
11 * All rights reserved.
13 * Redistribution and use in source and binary forms, with or without
14 * modification, are permitted provided that the following conditions are met:
15 * * Redistributions of source code must retain the above copyright notice,
16 * this list of conditions and the following disclaimer.
17 * * Redistributions in binary form must reproduce the above
18 * * copyright notice, this list of conditions and the following disclaimer in
19 * the documentation and/or other materials provided with the distribution.
20 * * Neither the name of the <organization> nor the names of its contributors
21 * may be used to endorse or promote products derived from this software
22 * without specific prior written permission.
24 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27 * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT,
28 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
29 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
30 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
31 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
32 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
33 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36 /* Twitter Addon for Friendica
38 * Author: Tobias Diekershoff
39 * tobias.diekershoff@gmx.net
41 * License:3-clause BSD license
44 * To use this addon you need a OAuth Consumer key pair (key & secret)
45 * you can get it from Twitter at https://twitter.com/apps
47 * Register your Friendica site as "Client" application with "Read & Write" access
48 * we do not need "Twitter as login". When you've registered the app you get the
49 * OAuth Consumer key and secret pair for your application/site.
51 * Add this key pair to your config/twitter.config.php file or use the admin panel.
55 * 'consumerkey' => '',
56 * 'consumersecret' => '',
60 * To activate the addon itself add it to the system.addon
61 * setting. After this, your user can configure their Twitter account settings
62 * from "Settings -> Addon Settings".
64 * Requirements: PHP5, curl
67 use Abraham\TwitterOAuth\TwitterOAuth;
68 use Abraham\TwitterOAuth\TwitterOAuthException;
69 use Codebird\Codebird;
71 use Friendica\Content\Text\BBCode;
72 use Friendica\Content\Text\Plaintext;
73 use Friendica\Core\Hook;
74 use Friendica\Core\Logger;
75 use Friendica\Core\Protocol;
76 use Friendica\Core\Renderer;
77 use Friendica\Core\Worker;
78 use Friendica\Database\DBA;
80 use Friendica\Model\Contact;
81 use Friendica\Model\Conversation;
82 use Friendica\Model\Group;
83 use Friendica\Model\Item;
84 use Friendica\Model\ItemURI;
85 use Friendica\Model\Post;
86 use Friendica\Model\Tag;
87 use Friendica\Model\User;
88 use Friendica\Protocol\Activity;
89 use Friendica\Core\Config\Util\ConfigFileManager;
90 use Friendica\Core\System;
91 use Friendica\Model\Photo;
92 use Friendica\Util\DateTimeFormat;
93 use Friendica\Util\Images;
94 use Friendica\Util\Strings;
96 require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
98 define('TWITTER_DEFAULT_POLL_INTERVAL', 5); // given in minutes
100 function twitter_install()
102 // we need some hooks, for the configuration and for sending tweets
103 Hook::register('load_config' , __FILE__, 'twitter_load_config');
104 Hook::register('connector_settings' , __FILE__, 'twitter_settings');
105 Hook::register('connector_settings_post', __FILE__, 'twitter_settings_post');
106 Hook::register('hook_fork' , __FILE__, 'twitter_hook_fork');
107 Hook::register('post_local' , __FILE__, 'twitter_post_local');
108 Hook::register('notifier_normal' , __FILE__, 'twitter_post_hook');
109 Hook::register('jot_networks' , __FILE__, 'twitter_jot_nets');
110 Hook::register('cron' , __FILE__, 'twitter_cron');
111 Hook::register('support_follow' , __FILE__, 'twitter_support_follow');
112 Hook::register('follow' , __FILE__, 'twitter_follow');
113 Hook::register('unfollow' , __FILE__, 'twitter_unfollow');
114 Hook::register('block' , __FILE__, 'twitter_block');
115 Hook::register('unblock' , __FILE__, 'twitter_unblock');
116 Hook::register('expire' , __FILE__, 'twitter_expire');
117 Hook::register('prepare_body' , __FILE__, 'twitter_prepare_body');
118 Hook::register('check_item_notification', __FILE__, 'twitter_check_item_notification');
119 Hook::register('probe_detect' , __FILE__, 'twitter_probe_detect');
120 Hook::register('item_by_link' , __FILE__, 'twitter_item_by_link');
121 Hook::register('parse_link' , __FILE__, 'twitter_parse_link');
122 Logger::info('installed twitter');
127 function twitter_load_config(ConfigFileManager $loader)
129 DI::app()->getConfigCache()->load($loader->loadAddonConfig('twitter'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
132 function twitter_check_item_notification(array &$notification_data)
134 $own_id = DI::pConfig()->get($notification_data['uid'], 'twitter', 'own_id');
136 $own_user = Contact::selectFirst(['url'], ['uid' => $notification_data['uid'], 'alias' => 'twitter::'.$own_id]);
138 $notification_data['profiles'][] = $own_user['url'];
142 function twitter_support_follow(array &$data)
144 if ($data['protocol'] == Protocol::TWITTER) {
145 $data['result'] = true;
149 function twitter_follow(array &$contact)
151 Logger::info('Check if contact is twitter contact', ['url' => $contact['url']]);
153 if (!strstr($contact['url'], '://twitter.com') && !strstr($contact['url'], '@twitter.com')) {
157 // contact seems to be a twitter contact, so continue
158 $nickname = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $contact['url']);
159 $nickname = str_replace('@twitter.com', '', $nickname);
161 $uid = DI::userSession()->getLocalUserId();
163 if (!twitter_api_contact('friendships/create', ['network' => Protocol::TWITTER, 'nick' => $nickname], $uid)) {
168 $user = twitter_fetchuser($nickname);
170 $contact_id = twitter_fetch_contact($uid, $user, true);
172 $contact = Contact::getById($contact_id, ['name', 'nick', 'url', 'addr', 'batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'photo', 'priority', 'network', 'alias', 'pubkey']);
174 if (DBA::isResult($contact)) {
175 $contact['contact'] = $contact;
179 function twitter_unfollow(array &$hook_data)
181 $hook_data['result'] = twitter_api_contact('friendships/destroy', $hook_data['contact'], $hook_data['uid']);
184 function twitter_block(array &$hook_data)
186 $hook_data['result'] = twitter_api_contact('blocks/create', $hook_data['contact'], $hook_data['uid']);
188 if ($hook_data['result'] === true) {
189 $cdata = Contact::getPublicAndUserContactID($hook_data['contact']['id'], $hook_data['uid']);
190 Contact::remove($cdata['user']);
194 function twitter_unblock(array &$hook_data)
196 $hook_data['result'] = twitter_api_contact('blocks/destroy', $hook_data['contact'], $hook_data['uid']);
199 function twitter_api_contact(string $apiPath, array $contact, int $uid): ?bool
201 if ($contact['network'] !== Protocol::TWITTER) {
205 return (bool)twitter_api_call($uid, $apiPath, ['screen_name' => $contact['nick']]);
208 function twitter_jot_nets(array &$jotnets_fields)
210 if (!DI::userSession()->getLocalUserId()) {
214 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post')) {
215 $jotnets_fields[] = [
216 'type' => 'checkbox',
219 DI::l10n()->t('Post to Twitter'),
220 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default')
227 function twitter_settings_post()
229 if (!DI::userSession()->getLocalUserId()) {
232 // don't check twitter settings if twitter submit button is not clicked
233 if (empty($_POST['twitter-disconnect']) && empty($_POST['twitter-submit'])) {
237 if (!empty($_POST['twitter-disconnect'])) {
239 * if the twitter-disconnect checkbox is set, clear the OAuth key/secret pair
240 * from the user configuration
242 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumerkey');
243 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumersecret');
244 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
245 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
246 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post');
247 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default');
248 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
249 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'thread');
250 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts');
251 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'import');
252 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'create_user');
253 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow');
254 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'own_id');
256 if (isset($_POST['twitter-pin'])) {
257 // if the user supplied us with a PIN from Twitter, let the magic of OAuth happen
258 Logger::notice('got a Twitter PIN');
259 $ckey = DI::config()->get('twitter', 'consumerkey');
260 $csecret = DI::config()->get('twitter', 'consumersecret');
261 // the token and secret for which the PIN was generated were hidden in the settings
262 // form as token and token2, we need a new connection to Twitter using these token
263 // and secret to request a Access Token with the PIN
265 if (empty($_POST['twitter-pin'])) {
266 throw new Exception(DI::l10n()->t('You submitted an empty PIN, please Sign In with Twitter again to get a new one.'));
269 $connection = new TwitterOAuth($ckey, $csecret, $_POST['twitter-token'], $_POST['twitter-token2']);
270 $token = $connection->oauth('oauth/access_token', ['oauth_verifier' => $_POST['twitter-pin']]);
271 // ok, now that we have the Access Token, save them in the user config
272 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken', $token['oauth_token']);
273 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret', $token['oauth_token_secret']);
274 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', 1);
275 } catch(Exception $e) {
276 DI::sysmsg()->addNotice($e->getMessage());
277 } catch(TwitterOAuthException $e) {
278 DI::sysmsg()->addNotice($e->getMessage());
281 // if no PIN is supplied in the POST variables, the user has changed the setting
282 // to post a tweet for every new __public__ posting to the wall
283 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', intval($_POST['twitter-enable']));
284 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default', intval($_POST['twitter-default']));
285 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'thread', intval($_POST['twitter-thread']));
286 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts', intval($_POST['twitter-mirror']));
287 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'import', intval($_POST['twitter-import']));
288 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'create_user', intval($_POST['twitter-create_user']));
289 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow', intval($_POST['twitter-auto_follow']));
291 if (!intval($_POST['twitter-mirror'])) {
292 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
298 function twitter_settings(array &$data)
300 if (!DI::userSession()->getLocalUserId()) {
304 $user = User::getById(DI::userSession()->getLocalUserId());
306 DI::page()->registerStylesheet(__DIR__ . '/twitter.css', 'all');
309 * 1) Check that we have global consumer key & secret
310 * 2) If no OAuthtoken & stuff is present, generate button to get some
311 * 3) Checkbox for "Send public notices (280 chars only)
313 $ckey = DI::config()->get('twitter', 'consumerkey');
314 $csecret = DI::config()->get('twitter', 'consumersecret');
315 $otoken = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
316 $osecret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
318 $enabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
319 $defenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'));
320 $threadenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'thread'));
321 $mirrorenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts'));
322 $importenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'import'));
323 $create_userenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'create_user'));
324 $auto_followenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow'));
326 // Hide the submit button by default
329 if ((!$ckey) && (!$csecret)) {
330 /* no global consumer keys
331 * display warning and skip personal config
333 $html = '<p>' . DI::l10n()->t('No consumer key pair for Twitter found. Please contact your site administrator.') . '</p>';
335 // ok we have a consumer key pair now look into the OAuth stuff
336 if ((!$otoken) && (!$osecret)) {
337 /* the user has not yet connected the account to twitter...
338 * get a temporary OAuth key/secret pair and display a button with
339 * which the user can request a PIN to connect the account to a
340 * account at Twitter.
342 $connection = new TwitterOAuth($ckey, $csecret);
344 $result = $connection->oauth('oauth/request_token', ['oauth_callback' => 'oob']);
346 $html = '<p>' . DI::l10n()->t('At this Friendica instance the Twitter addon was enabled but you have not yet connected your account to your Twitter account. To do so click the button below to get a PIN from Twitter which you have to copy into the input box below and submit the form. Only your <strong>public</strong> posts will be posted to Twitter.') . '</p>';
347 $html .= '<a href="' . $connection->url('oauth/authorize', ['oauth_token' => $result['oauth_token']]) . '" target="_twitter"><img src="addon/twitter/lighter.png" alt="' . DI::l10n()->t('Log in with Twitter') . '"></a>';
348 $html .= '<div id="twitter-pin-wrapper">';
349 $html .= '<label id="twitter-pin-label" for="twitter-pin">' . DI::l10n()->t('Copy the PIN from Twitter here') . '</label>';
350 $html .= '<input id="twitter-pin" type="text" name="twitter-pin" />';
351 $html .= '<input id="twitter-token" type="hidden" name="twitter-token" value="' . $result['oauth_token'] . '" />';
352 $html .= '<input id="twitter-token2" type="hidden" name="twitter-token2" value="' . $result['oauth_token_secret'] . '" />';
356 } catch (TwitterOAuthException $e) {
357 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
361 * we have an OAuth key / secret pair for the user
362 * so let's give a chance to disable the postings to Twitter
364 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
366 $account = $connection->get('account/verify_credentials');
367 if (property_exists($account, 'screen_name') &&
368 property_exists($account, 'description') &&
369 property_exists($account, 'profile_image_url')
371 $connected = DI::l10n()->t('Currently connected to: <a href="https://twitter.com/%1$s" target="_twitter">%1$s</a>', $account->screen_name);
373 Logger::notice('Invalid twitter info (verify credentials).', ['auth' => TwitterOAuth::class]);
376 if ($user['hidewall']) {
377 $privacy_warning = DI::l10n()->t('<strong>Note</strong>: Due to your privacy settings (<em>Hide your profile details from unknown viewers?</em>) the link potentially included in public postings relayed to Twitter will lead the visitor to a blank page informing the visitor that the access to your profile has been restricted.');
380 $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/twitter/');
381 $html = Renderer::replaceMacros($t, [
383 'connected' => $connected ?? '',
384 'invalid' => DI::l10n()->t('Invalid Twitter info'),
385 'disconnect' => DI::l10n()->t('Disconnect'),
386 'privacy_warning' => $privacy_warning ?? '',
389 '$account' => $account,
390 '$enable' => ['twitter-enable', DI::l10n()->t('Allow posting to Twitter'), $enabled, DI::l10n()->t('If enabled all your <strong>public</strong> postings can be posted to the associated Twitter account. You can choose to do so by default (here) or for every posting separately in the posting options when writing the entry.')],
391 '$default' => ['twitter-default', DI::l10n()->t('Send public postings to Twitter by default'), $defenabled],
392 '$thread' => ['twitter-thread', DI::l10n()->t('Use threads instead of truncating the content'), $threadenabled],
393 '$mirror' => ['twitter-mirror', DI::l10n()->t('Mirror all posts from twitter that are no replies'), $mirrorenabled],
394 '$import' => ['twitter-import', DI::l10n()->t('Import the remote timeline'), $importenabled],
395 '$create_user' => ['twitter-create_user', DI::l10n()->t('Automatically create contacts'), $create_userenabled, DI::l10n()->t('This will automatically create a contact in Friendica as soon as you receive a message from an existing contact via the Twitter network. If you do not enable this, you need to manually add those Twitter contacts in Friendica from whom you would like to see posts here.')],
396 '$auto_follow' => ['twitter-auto_follow', DI::l10n()->t('Follow in fediverse'), $auto_followenabled, DI::l10n()->t('Automatically subscribe to the contact in the fediverse, when a fediverse account is mentioned in name or description and we are following the Twitter contact.')],
399 // Enable the default submit button
401 } catch (TwitterOAuthException $e) {
402 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
408 'connector' => 'twitter',
409 'title' => DI::l10n()->t('Twitter Import/Export/Mirror'),
410 'enabled' => $enabled,
411 'image' => 'images/twitter.png',
413 'submit' => $submit ?? null,
417 function twitter_hook_fork(array &$b)
419 DI::logger()->debug('twitter_hook_fork', $b);
421 if ($b['name'] != 'notifier_normal') {
427 // Deletion checks are done in twitter_delete_item()
428 if ($post['deleted']) {
432 // Editing is not supported by the addon
433 if ($post['created'] !== $post['edited']) {
434 DI::logger()->info('Editing is not supported by the addon');
435 $b['execute'] = false;
439 // if post comes from twitter don't send it back
440 if (($post['extid'] == Protocol::TWITTER) || twitter_get_id($post['extid'])) {
441 DI::logger()->info('If post comes from twitter don\'t send it back');
442 $b['execute'] = false;
446 if (substr($post['app'] ?? '', 0, 7) == 'Twitter') {
447 DI::logger()->info('No Twitter app');
448 $b['execute'] = false;
452 if (DI::pConfig()->get($post['uid'], 'twitter', 'import')) {
453 // Don't fork if it isn't a reply to a twitter post
454 if (($post['parent'] != $post['id']) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::TWITTER])) {
455 Logger::notice('No twitter parent found', ['item' => $post['id']]);
456 $b['execute'] = false;
460 // Comments are never exported when we don't import the twitter timeline
461 if (!strstr($post['postopts'] ?? '', 'twitter') || ($post['parent'] != $post['id']) || $post['private']) {
462 DI::logger()->info('Comments are never exported when we don\'t import the twitter timeline');
463 $b['execute'] = false;
469 function twitter_post_local(array &$b)
475 if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
479 $twitter_post = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
480 $twitter_enable = (($twitter_post && !empty($_REQUEST['twitter_enable'])) ? intval($_REQUEST['twitter_enable']) : 0);
482 // if API is used, default to the chosen settings
483 if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'))) {
487 if (!$twitter_enable) {
491 if (strlen($b['postopts'])) {
492 $b['postopts'] .= ',';
495 $b['postopts'] .= 'twitter';
498 function twitter_probe_detect(array &$hookData)
500 // Don't overwrite an existing result
501 if (isset($hookData['result'])) {
505 // Avoid a lookup for the wrong network
506 if (!in_array($hookData['network'], ['', Protocol::TWITTER])) {
510 if (preg_match('=([^@]+)@(?:mobile\.)?twitter\.com$=i', $hookData['uri'], $matches)) {
512 } elseif (preg_match('=^https?://(?:mobile\.)?twitter\.com/(.+)=i', $hookData['uri'], $matches)) {
513 if (strpos($matches[1], '/') !== false) {
514 // Status case: https://twitter.com/<nick>/status/<status id>
516 $hookData['result'] = false;
525 $user = twitter_fetchuser($nick);
528 $hookData['result'] = twitter_user_to_contact($user);
532 function twitter_item_by_link(array &$hookData)
534 // Don't overwrite an existing result
535 if (isset($hookData['item_id'])) {
540 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $hookData['uri'], $matches)) {
544 // From now on, any early return should abort the whole chain since we've established it was a Twitter URL
545 $hookData['item_id'] = false;
547 // Node-level configuration check
548 if (empty(DI::config()->get('twitter', 'consumerkey')) || empty(DI::config()->get('twitter', 'consumersecret'))) {
552 // No anonymous import
553 if (!$hookData['uid']) {
558 empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthtoken'))
559 || empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthsecret'))
561 DI::sysmsg()->addNotice(DI::l10n()->t('Please connect a Twitter account in your Social Network settings to import Twitter posts.'));
565 $status = twitter_statuses_show($matches[1]);
567 if (empty($status->id_str)) {
568 DI::sysmsg()->addNotice(DI::l10n()->t('Twitter post not found.'));
572 $item = twitter_createpost($hookData['uid'], $status, [], true, false, false);
574 $hookData['item_id'] = Item::insert($item);
578 function twitter_api_post(string $apiPath, string $pid, int $uid): ?object
584 return twitter_api_call($uid, $apiPath, ['id' => $pid]);
587 function twitter_api_call(int $uid, string $apiPath, array $parameters = []): ?object
589 $ckey = DI::config()->get('twitter', 'consumerkey');
590 $csecret = DI::config()->get('twitter', 'consumersecret');
591 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
592 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
594 // If the addon is not configured (general or for this user) quit here
595 if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
600 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
601 $result = $connection->post($apiPath, $parameters);
603 if ($connection->getLastHttpCode() != 200) {
604 throw new Exception($result->errors[0]->message ?? json_encode($result), $connection->getLastHttpCode());
607 if (!empty($result->errors)) {
608 throw new Exception($result->errors[0]->message, $result->errors[0]->code);
611 Logger::info('[twitter] API call successful', ['apiPath' => $apiPath, 'parameters' => $parameters]);
612 Logger::debug('[twitter] API call result', ['apiPath' => $apiPath, 'parameters' => $parameters, 'result' => $result]);
615 } catch (TwitterOAuthException $twitterOAuthException) {
616 Logger::notice('Unable to communicate with twitter', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $twitterOAuthException->getCode(), 'exception' => $twitterOAuthException]);
618 } catch (Exception $e) {
619 Logger::notice('[twitter] API call failed', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $e->getCode(), 'message' => $e->getMessage()]);
624 function twitter_get_id(string $uri)
626 if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
630 $id = substr($uri, 9);
631 if (!is_numeric($id)) {
638 function twitter_post_hook(array &$b)
640 DI::logger()->debug('Invoke post hook', $b);
643 twitter_delete_item($b);
648 if (!DI::pConfig()->get($b['uid'], 'twitter', 'import')
649 && ($b['private'] || ($b['created'] !== $b['edited']))) {
653 $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
657 if ($b['parent'] != $b['id']) {
658 Logger::debug('Got comment', ['item' => $b]);
660 // Looking if its a reply to a twitter post
661 if (!twitter_get_id($b['parent-uri']) &&
662 !twitter_get_id($b['extid']) &&
663 !twitter_get_id($b['thr-parent'])) {
664 Logger::info('No twitter post', ['parent' => $b['parent']]);
668 $condition = ['uri' => $b['thr-parent'], 'uid' => $b['uid']];
669 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
670 if (!DBA::isResult($thr_parent)) {
671 Logger::notice('No parent found', ['thr-parent' => $b['thr-parent']]);
675 if ($thr_parent['author-network'] == Protocol::TWITTER) {
676 $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
677 $nicknameplain = '@' . $thr_parent['author-nick'];
679 Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b['body']]);
680 if ((strpos($b['body'], $nickname) === false) && (strpos($b['body'], $nicknameplain) === false)) {
681 $b['body'] = $nickname . ' ' . $b['body'];
685 Logger::debug('Parent found', ['parent' => $thr_parent]);
687 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
691 // Dont't post if the post doesn't belong to us.
692 // This is a check for forum postings
693 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
694 if ($b['contact-id'] != $self['id']) {
699 if ($b['verb'] == Activity::LIKE) {
700 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
702 twitter_api_post('favorites/create', twitter_get_id($b['thr-parent']), $b['uid']);
707 if ($b['verb'] == Activity::ANNOUNCE) {
708 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
709 twitter_retweet($b['uid'], twitter_get_id($b['thr-parent']));
713 if ($b['created'] !== $b['edited']) {
717 // if post comes from twitter don't send it back
718 if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
722 if ($b['app'] == 'Twitter') {
726 Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
728 DI::pConfig()->load($b['uid'], 'twitter');
730 $ckey = DI::config()->get('twitter', 'consumerkey');
731 $csecret = DI::config()->get('twitter', 'consumersecret');
732 $otoken = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
733 $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
735 if ($ckey && $csecret && $otoken && $osecret) {
736 Logger::info('We have customer key and oauth stuff, going to send.');
738 // If it's a repeated message from twitter then do a native retweet and exit
739 if (twitter_is_retweet($b['uid'], $b['body'])) {
743 Codebird::setConsumerKey($ckey, $csecret);
744 $cb = Codebird::getInstance();
745 $cb->setToken($otoken, $osecret);
747 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
749 // Set the timeout for upload to 30 seconds
750 $connection->setTimeouts(10, 30);
754 // Handling non-native reshares
755 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
757 function (array $attributes, array $author_contact, $content, $is_quote_share) {
758 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
762 $b['body'] = twitter_update_mentions($b['body']);
764 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
765 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
766 $msg = $msgarr['text'];
768 if (($msg == '') && isset($msgarr['title'])) {
769 $msg = Plaintext::shorten($msgarr['title'], $max_char - 50, $b['uid']);
772 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
773 if (!empty($msgarr['url']) && (strpos($msg, $msgarr['url']) === false) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
774 $msg .= "\n" . $msgarr['url'];
778 Logger::notice('Empty message', ['id' => $b['id']]);
782 // and now tweet it :-)
785 if (!empty($msgarr['images']) || !empty($msgarr['remote_images'])) {
786 Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images'] ?? [], 'remote_images' => $msgarr['remote_images'] ?? []]);
789 foreach ($msgarr['images'] ?? [] as $image) {
790 if (count($media_ids) == 4) {
794 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
795 } catch (\Throwable $th) {
796 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
800 foreach ($msgarr['remote_images'] ?? [] as $image) {
801 if (count($media_ids) == 4) {
805 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
806 } catch (\Throwable $th) {
807 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
810 $post['media_ids'] = implode(',', $media_ids);
811 if (empty($post['media_ids'])) {
812 unset($post['media_ids']);
814 } catch (Exception $e) {
815 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
819 if (!DI::pConfig()->get($b['uid'], 'twitter', 'thread') || empty($msgarr['parts']) || (count($msgarr['parts']) == 1)) {
820 Logger::debug('Post single message', ['id' => $b['id']]);
822 $post['status'] = $msg;
825 $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
828 $result = $connection->post('statuses/update', $post);
829 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
831 if (!empty($result->source)) {
832 DI::keyValue()->set('twitter_application_name', strip_tags($result->source));
835 if (!empty($result->errors)) {
836 Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
838 } elseif ($thr_parent) {
839 Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
840 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
844 $in_reply_to_status_id = twitter_get_id($thr_parent['uri']);
846 $in_reply_to_status_id = 0;
849 Logger::debug('Post message thread', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
850 foreach ($msgarr['parts'] as $key => $part) {
851 $post['status'] = $part;
853 if ($in_reply_to_status_id) {
854 $post['in_reply_to_status_id'] = $in_reply_to_status_id;
857 $result = $connection->post('statuses/update', $post);
858 Logger::debug('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $result]);
860 if (!empty($result->errors)) {
861 Logger::warning('Send to Twitter failed', ['part' => $key, 'id' => $b['id'], 'error' => $result->errors]);
864 } elseif ($key == 0) {
865 Logger::debug('Updating extid', ['part' => $key, 'id' => $b['id'], 'extid' => $result->id_str]);
866 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
869 if (!empty($result->source)) {
870 $application_name = strip_tags($result->source);
873 $in_reply_to_status_id = $result->id_str;
874 unset($post['media_ids']);
877 if (!empty($application_name)) {
878 DI::keyValue()->set('twitter_application_name', strip_tags($application_name));
884 function twitter_upload_image($connection, $cb, array $image, array $item)
886 if (!empty($image['id'])) {
887 $photo = Photo::selectFirst([], ['id' => $image['id']]);
889 $photo = Photo::createPhotoForExternalResource($image['url']);
892 $tempfile = tempnam(System::getTempPath(), 'cache');
893 file_put_contents($tempfile, Photo::getImageForPhoto($photo));
895 Logger::info('Uploading', ['id' => $item['id'], 'image' => $image]);
896 $media = $connection->upload('media/upload', ['media' => $tempfile]);
900 if (isset($media->media_id_string)) {
901 $media_id = $media->media_id_string;
903 if (!empty($image['description'])) {
904 $data = ['media_id' => $media->media_id_string,
905 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
906 $ret = $cb->media_metadata_create($data);
907 Logger::info('Metadata create', ['id' => $item['id'], 'data' => $data, 'return' => $ret]);
910 Logger::error('Failed upload', ['id' => $item['id'], 'image' => $image['url'], 'return' => $media]);
911 throw new Exception('Failed upload of ' . $image['url']);
917 function twitter_delete_item(array $item)
919 if (!$item['deleted']) {
923 if ($item['parent'] != $item['id']) {
924 Logger::debug('Deleting comment/announce', ['item' => $item]);
926 // Looking if it's a reply to a twitter post
927 if (!twitter_get_id($item['parent-uri']) &&
928 !twitter_get_id($item['extid']) &&
929 !twitter_get_id($item['thr-parent'])) {
930 Logger::info('No twitter post', ['parent' => $item['parent']]);
934 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
935 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
936 if (!DBA::isResult($thr_parent)) {
937 Logger::notice('No parent found', ['thr-parent' => $item['thr-parent']]);
941 Logger::debug('Parent found', ['parent' => $thr_parent]);
943 if (!strstr($item['extid'], 'twitter')) {
944 DI::logger()->info('Not a Twitter post', ['extid' => $item['extid']]);
948 // Don't delete if the post doesn't belong to us.
949 // This is a check for forum postings
950 $self = DBA::selectFirst('contact', ['id'], ['uid' => $item['uid'], 'self' => true]);
951 if ($item['contact-id'] != $self['id']) {
952 DI::logger()->info('Don\'t delete if the post doesn\'t belong to the user', ['contact-id' => $item['contact-id'], 'self' => $self['id']]);
958 * @TODO Remaining caveat: Comments posted on Twitter and imported in Friendica do not trigger any Notifier task,
959 * possibly because they are private to the user and don't require any remote deletion notifications sent.
960 * Comments posted on Friendica and mirrored on Twitter trigger the Notifier task and the Twitter counter-part
961 * will be deleted accordingly.
963 if ($item['verb'] == Activity::POST) {
964 Logger::info('Delete post/comment', ['uid' => $item['uid'], 'id' => twitter_get_id($item['extid'])]);
965 twitter_api_post('statuses/destroy', twitter_get_id($item['extid']), $item['uid']);
969 if ($item['verb'] == Activity::LIKE) {
970 Logger::info('Unlike', ['uid' => $item['uid'], 'id' => twitter_get_id($item['thr-parent'])]);
971 twitter_api_post('favorites/destroy', twitter_get_id($item['thr-parent']), $item['uid']);
975 if ($item['verb'] == Activity::ANNOUNCE && !empty($thr_parent['uri'])) {
976 Logger::info('Unretweet', ['uid' => $item['uid'], 'extid' => $thr_parent['uri'], 'id' => twitter_get_id($thr_parent['uri'])]);
977 twitter_api_post('statuses/unretweet', twitter_get_id($thr_parent['uri']), $item['uid']);
982 function twitter_addon_admin_post()
984 DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
985 DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
988 function twitter_addon_admin(string &$o)
990 $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
992 $o = Renderer::replaceMacros($t, [
993 '$submit' => DI::l10n()->t('Save Settings'),
994 // name, label, value, help, [extra values]
995 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
996 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
1000 function twitter_cron()
1002 $last = DI::keyValue()->get('twitter_last_poll');
1004 $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
1005 if (!$poll_interval) {
1006 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
1010 $next = $last + ($poll_interval * 60);
1011 if ($next > time()) {
1012 Logger::notice('twitter: poll intervall not reached');
1016 Logger::notice('twitter: cron_start');
1018 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
1019 foreach ($pconfigs as $rr) {
1020 Logger::notice('Fetching', ['user' => $rr['uid']]);
1021 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
1024 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
1025 if ($abandon_days < 1) {
1029 $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
1031 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1032 foreach ($pconfigs as $rr) {
1033 if ($abandon_days != 0) {
1034 if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
1035 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
1040 Logger::notice('importing timeline', ['user' => $rr['uid']]);
1041 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
1044 // check for new contacts once a day
1045 $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
1046 if($last_contact_check)
1047 $next_contact_check = $last_contact_check + 86400;
1049 $next_contact_check = 0;
1051 if($next_contact_check <= time()) {
1052 pumpio_getallusers($rr["uid"]);
1053 DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1058 Logger::notice('twitter: cron_end');
1060 DI::keyValue()->set('twitter_last_poll', time());
1063 function twitter_expire()
1065 $days = DI::config()->get('twitter', 'expire');
1071 Logger::notice('Start deleting expired posts');
1073 $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
1074 while ($row = Post::fetch($r)) {
1075 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1076 Item::markForDeletionById($row['id']);
1080 Logger::notice('End deleting expired posts');
1082 Logger::notice('Start expiry');
1084 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1085 foreach ($pconfigs as $rr) {
1086 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1087 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1090 Logger::notice('End expiry');
1093 function twitter_prepare_body(array &$b)
1095 if ($b['item']['network'] != Protocol::TWITTER) {
1099 if ($b['preview']) {
1102 $item['plink'] = DI::baseUrl()->get() . '/display/' . $item['guid'];
1104 $condition = ['uri' => $item['thr-parent'], 'uid' => DI::userSession()->getLocalUserId()];
1105 $orig_post = Post::selectFirst(['author-link'], $condition);
1106 if (DBA::isResult($orig_post)) {
1107 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1108 $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1109 $nicknameplain = '@' . $nicknameplain;
1111 if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1112 $item['body'] = $nickname . ' ' . $item['body'];
1116 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1117 $msg = $msgarr['text'];
1119 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1120 $msg .= ' ' . $msgarr['url'];
1123 if (isset($msgarr['image'])) {
1124 $msg .= ' ' . $msgarr['image'];
1127 $b['html'] = nl2br(htmlspecialchars($msg));
1131 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1133 if ($twitterOAuth === null) {
1134 $ckey = DI::config()->get('twitter', 'consumerkey');
1135 $csecret = DI::config()->get('twitter', 'consumersecret');
1137 if (empty($ckey) || empty($csecret)) {
1138 return new stdClass();
1141 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1144 $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1146 return $twitterOAuth->get('statuses/show', $parameters);
1150 * Parse Twitter status URLs since Twitter removed OEmbed
1152 * @param array $b Expected format:
1154 * 'url' => [URL to parse],
1155 * 'format' => 'json'|'',
1156 * 'text' => Output parameter
1158 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1160 function twitter_parse_link(array &$b)
1162 // Only handle Twitter status URLs
1163 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1167 $status = twitter_statuses_show($matches[1]);
1169 if (empty($status->id)) {
1173 $item = twitter_createpost(0, $status, [], true, false, true);
1178 if ($b['format'] == 'json') {
1180 foreach ($status->extended_entities->media ?? [] as $media) {
1181 if (!empty($media->media_url_https)) {
1183 'src' => $media->media_url_https,
1184 'width' => $media->sizes->thumb->w,
1185 'height' => $media->sizes->thumb->h,
1193 'url' => $item['plink'],
1194 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1195 'text' => BBCode::toPlaintext($item['body'], false),
1196 'images' => $images,
1198 'contentType' => 'attachment',
1202 $b['text'] = BBCode::getShareOpeningTag(
1203 $item['author-name'],
1204 $item['author-link'],
1205 $item['author-avatar'],
1209 $b['text'] .= $item['body'] . '[/share]';
1214 /*********************
1218 *********************/
1222 * @brief Build the item array for the mirrored post
1224 * @param integer $uid User id
1225 * @param object $post Twitter object with the post
1227 * @return array item data to be posted
1229 function twitter_do_mirrorpost(int $uid, $post)
1231 $datarray['uid'] = $uid;
1232 $datarray['extid'] = 'twitter::' . $post->id;
1233 $datarray['title'] = '';
1235 if (!empty($post->retweeted_status)) {
1236 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1237 $item = twitter_createpost($uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1243 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1244 $item['author-name'],
1245 $item['author-link'],
1246 $item['author-avatar'],
1251 $datarray['body'] .= $item['body'] . '[/share]';
1253 $item = twitter_createpost($uid, $post, ['id' => 0], false, false, false, -1);
1259 $datarray['body'] = $item['body'];
1262 $datarray['app'] = $item['app'];
1263 $datarray['verb'] = $item['verb'];
1265 if (isset($item['location'])) {
1266 $datarray['location'] = $item['location'];
1269 if (isset($item['coord'])) {
1270 $datarray['coord'] = $item['coord'];
1277 * Fetches the Twitter user's own posts
1283 function twitter_fetchtimeline(int $uid): void
1285 $ckey = DI::config()->get('twitter', 'consumerkey');
1286 $csecret = DI::config()->get('twitter', 'consumersecret');
1287 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1288 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1289 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastid');
1291 $application_name = DI::keyValue()->get('twitter_application_name');
1293 if ($application_name == '') {
1294 $application_name = DI::baseUrl()->getHostname();
1297 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1299 // Ensure to have the own contact
1301 twitter_fetch_own_contact($uid);
1302 } catch (TwitterOAuthException $e) {
1303 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1308 'exclude_replies' => true,
1309 'trim_user' => false,
1310 'contributor_details' => true,
1311 'include_rts' => true,
1312 'tweet_mode' => 'extended',
1313 'include_ext_alt_text' => true,
1316 $first_time = ($lastid == '');
1318 if ($lastid != '') {
1319 $parameters['since_id'] = $lastid;
1323 $items = $connection->get('statuses/user_timeline', $parameters);
1324 } catch (TwitterOAuthException $e) {
1325 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1329 if (!is_array($items)) {
1330 Logger::notice('No items', ['user' => $uid]);
1334 $posts = array_reverse($items);
1336 Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1338 if (count($posts)) {
1339 foreach ($posts as $post) {
1340 if ($post->id_str > $lastid) {
1341 $lastid = $post->id_str;
1342 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1346 Logger::notice('First time, continue');
1350 if (stristr($post->source, $application_name)) {
1351 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1354 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1356 $mirrorpost = twitter_do_mirrorpost($uid, $post);
1358 if (empty($mirrorpost['body'])) {
1359 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1363 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1365 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1368 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1369 Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1372 function twitter_fix_avatar($avatar)
1374 $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1376 $info = Images::getInfoFromURLCached($new_avatar);
1378 $new_avatar = $avatar;
1384 function twitter_get_relation($uid, $target, $contact = [])
1386 if (isset($contact['rel'])) {
1387 $relation = $contact['rel'];
1392 $ckey = DI::config()->get('twitter', 'consumerkey');
1393 $csecret = DI::config()->get('twitter', 'consumersecret');
1394 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1395 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1396 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1398 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1399 $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1402 $status = $connection->get('friendships/show', $parameters);
1403 if ($connection->getLastHttpCode() !== 200) {
1404 throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1407 $following = $status->relationship->source->following;
1408 $followed = $status->relationship->source->followed_by;
1410 if ($following && !$followed) {
1411 $relation = Contact::SHARING;
1412 } elseif (!$following && $followed) {
1413 $relation = Contact::FOLLOWER;
1414 } elseif ($following && $followed) {
1415 $relation = Contact::FRIEND;
1416 } elseif (!$following && !$followed) {
1420 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1421 } catch (Throwable $e) {
1422 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1432 function twitter_user_to_contact($data)
1434 if (empty($data->id_str)) {
1438 $baseurl = 'https://twitter.com';
1439 $url = $baseurl . '/' . $data->screen_name;
1440 $addr = $data->screen_name . '@twitter.com';
1444 'nurl' => Strings::normaliseLink($url),
1445 'uri-id' => ItemURI::getIdByURI($url),
1446 'network' => Protocol::TWITTER,
1447 'alias' => 'twitter::' . $data->id_str,
1448 'baseurl' => $baseurl,
1449 'name' => $data->name,
1450 'nick' => $data->screen_name,
1452 'location' => $data->location,
1453 'about' => $data->description,
1454 'photo' => twitter_fix_avatar($data->profile_image_url_https),
1455 'header' => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1461 function twitter_get_contact($data, int $uid = 0)
1463 $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1464 if (DBA::isResult($contact)) {
1465 return $contact['id'];
1467 return twitter_fetch_contact($uid, $data, false);
1471 function twitter_fetch_contact($uid, $data, $create_user)
1473 $fields = twitter_user_to_contact($data);
1475 if (empty($fields)) {
1479 // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1480 $avatar = $fields['photo'];
1481 unset($fields['photo']);
1483 // Update the public contact
1484 $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1485 if (DBA::isResult($pcontact)) {
1486 $cid = $pcontact['id'];
1488 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1492 Contact::update($fields, ['id' => $cid]);
1493 Contact::updateAvatar($cid, $avatar);
1495 Logger::notice('No contact found', ['fields' => $fields]);
1498 $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1499 if (!DBA::isResult($contact) && empty($cid)) {
1500 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1502 } elseif (!$create_user) {
1506 if (!DBA::isResult($contact)) {
1507 $relation = twitter_get_relation($uid, $data->screen_name);
1509 // create contact record
1510 $fields['uid'] = $uid;
1511 $fields['created'] = DateTimeFormat::utcNow();
1512 $fields['poll'] = 'twitter::' . $data->id_str;
1513 $fields['rel'] = $relation;
1514 $fields['priority'] = 1;
1515 $fields['writable'] = true;
1516 $fields['blocked'] = false;
1517 $fields['readonly'] = false;
1518 $fields['pending'] = false;
1520 if (!Contact::insert($fields)) {
1524 $contact_id = DBA::lastInsertId();
1526 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1528 if ($contact['readonly'] || $contact['blocked']) {
1529 Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1533 $contact_id = $contact['id'];
1536 // Update the contact relation once per day
1537 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1538 $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1542 if ($contact['name'] != $data->name) {
1543 $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1547 if ($contact['nick'] != $data->screen_name) {
1548 $fields['uri-date'] = DateTimeFormat::utcNow();
1552 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1557 $fields['updated'] = DateTimeFormat::utcNow();
1558 Contact::update($fields, ['id' => $contact['id']]);
1559 Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1563 Contact::updateAvatar($contact_id, $avatar);
1565 if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1566 twitter_auto_follow($uid, $data);
1573 * Follow a fediverse account that is proived in the name or the profile
1575 * @param integer $uid
1576 * @param object $data
1578 function twitter_auto_follow(int $uid, object $data)
1580 $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1582 // Search for user@domain.tld in the name
1583 if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1584 if (twitter_add_contact($match[1], true, $uid)) {
1589 // Search for @user@domain.tld in the description
1590 if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1591 if (twitter_add_contact($match[1], true, $uid)) {
1596 // Search for user@domain.tld in the description
1597 // We don't probe here, since this could be a mail address
1598 if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1599 if (twitter_add_contact($match[1], false, $uid)) {
1604 // Search for profile links in the description
1605 foreach ($data->entities->description->urls as $url) {
1606 if (!empty($url->expanded_url)) {
1607 // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1608 twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1614 * Check if the provided address is a fediverse account and adds it
1616 * @param string $addr
1617 * @param boolean $probe
1618 * @param integer $uid
1621 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1623 $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1624 if (empty($contact)) {
1625 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1629 if (!in_array($contact['network'], Protocol::FEDERATED)) {
1630 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1634 if (Contact::isSharing($contact['id'], $uid)) {
1635 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1639 Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1640 Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1646 * @param string $screen_name
1647 * @return stdClass|null
1650 function twitter_fetchuser($screen_name)
1652 $ckey = DI::config()->get('twitter', 'consumerkey');
1653 $csecret = DI::config()->get('twitter', 'consumersecret');
1656 // Fetching user data
1657 $connection = new TwitterOAuth($ckey, $csecret);
1658 $parameters = ['screen_name' => $screen_name];
1659 $user = $connection->get('users/show', $parameters);
1660 } catch (TwitterOAuthException $e) {
1661 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1665 if (!is_object($user)) {
1673 * Replaces Twitter entities with Friendica-friendly links.
1675 * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1677 * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1678 * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1679 * index to be correct even after the last replacement.
1681 * @param string $body
1682 * @param stdClass $status
1684 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1686 function twitter_expand_entities($body, stdClass $status)
1689 $contains_urls = false;
1693 $replacementList = [];
1695 foreach ($status->entities->hashtags AS $hashtag) {
1696 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1697 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1699 $replacementList[$hashtag->indices[0]] = [
1700 'replace' => $replace,
1701 'length' => $hashtag->indices[1] - $hashtag->indices[0],
1705 foreach ($status->entities->user_mentions AS $mention) {
1706 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1707 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1709 $replacementList[$mention->indices[0]] = [
1710 'replace' => $replace,
1711 'length' => $mention->indices[1] - $mention->indices[0],
1715 foreach ($status->entities->urls ?? [] as $url) {
1716 $plain = str_replace($url->url, '', $plain);
1718 if ($url->url && $url->expanded_url && $url->display_url) {
1719 // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1720 if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1721 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1723 $replacementList[$url->indices[0]] = [
1725 'length' => $url->indices[1] - $url->indices[0],
1730 $contains_urls = true;
1732 $expanded_url = $url->expanded_url;
1734 // Quickfix: Workaround for URL with '[' and ']' in it
1735 if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1736 $expanded_url = $url->url;
1739 $replacementList[$url->indices[0]] = [
1740 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1741 'length' => $url->indices[1] - $url->indices[0],
1746 krsort($replacementList);
1748 foreach ($replacementList as $startIndex => $parameters) {
1749 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1752 $body = trim($body);
1754 return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1758 * Store entity attachments
1760 * @param integer $uriId
1761 * @param object $post Twitter object with the post
1763 function twitter_store_attachments(int $uriId, $post)
1765 if (!empty($post->extended_entities->media)) {
1766 foreach ($post->extended_entities->media AS $medium) {
1767 switch ($medium->type) {
1769 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1771 $attachment['url'] = $medium->media_url_https . '?name=large';
1772 $attachment['width'] = $medium->sizes->large->w;
1773 $attachment['height'] = $medium->sizes->large->h;
1775 if ($medium->sizes->small->w != $attachment['width']) {
1776 $attachment['preview'] = $medium->media_url_https . '?name=small';
1777 $attachment['preview-width'] = $medium->sizes->small->w;
1778 $attachment['preview-height'] = $medium->sizes->small->h;
1781 $attachment['name'] = $medium->display_url ?? null;
1782 $attachment['description'] = $medium->ext_alt_text ?? null;
1783 Logger::debug('Photo attachment', ['attachment' => $attachment]);
1784 Post\Media::insert($attachment);
1787 case 'animated_gif':
1788 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1789 if (is_array($medium->video_info->variants)) {
1791 // We take the video with the highest bitrate
1792 foreach ($medium->video_info->variants AS $variant) {
1793 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1794 $attachment['url'] = $variant->url;
1795 $bitrate = $variant->bitrate;
1800 $attachment['name'] = $medium->display_url ?? null;
1801 $attachment['preview'] = $medium->media_url_https . ':small';
1802 $attachment['preview-width'] = $medium->sizes->small->w;
1803 $attachment['preview-height'] = $medium->sizes->small->h;
1804 $attachment['description'] = $medium->ext_alt_text ?? null;
1805 Logger::debug('Video attachment', ['attachment' => $attachment]);
1806 Post\Media::insert($attachment);
1809 Logger::notice('Unknown media type', ['medium' => $medium]);
1814 if (!empty($post->entities->urls)) {
1815 foreach ($post->entities->urls as $url) {
1816 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1817 Logger::debug('Attached link', ['attachment' => $attachment]);
1818 Post\Media::insert($attachment);
1824 * @brief Fetch media entities and add media links to the body
1826 * @param object $post Twitter object with the post
1827 * @param array $postarray Array of the item that is about to be posted
1828 * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1830 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1832 // There are no media entities? So we quit.
1833 if (empty($post->extended_entities->media)) {
1837 // This is a pure media post, first search for all media urls
1839 foreach ($post->extended_entities->media AS $medium) {
1840 if (!isset($media[$medium->url])) {
1841 $media[$medium->url] = '';
1843 switch ($medium->type) {
1845 if (!empty($medium->ext_alt_text)) {
1846 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1847 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1849 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1852 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1853 $postarray['post-type'] = Item::PT_IMAGE;
1856 // Currently deactivated, since this causes the video to be display before the content
1857 // We have to figure out a better way for declaring the post type and the display style.
1858 //$postarray['post-type'] = Item::PT_VIDEO;
1859 case 'animated_gif':
1860 if (!empty($medium->ext_alt_text)) {
1861 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1862 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1864 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1867 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1868 if (is_array($medium->video_info->variants)) {
1870 // We take the video with the highest bitrate
1871 foreach ($medium->video_info->variants AS $variant) {
1872 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1873 $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1874 $bitrate = $variant->bitrate;
1883 foreach ($media AS $key => $value) {
1884 $postarray['body'] = str_replace($key, '', $postarray['body']);
1889 // Now we replace the media urls.
1890 foreach ($media AS $key => $value) {
1891 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1896 * Undocumented function
1898 * @param integer $uid User ID
1899 * @param object $post Incoming Twitter post
1900 * @param array $self
1901 * @param bool $create_user Should users be created?
1902 * @param bool $only_existing_contact Only import existing contacts if set to "true"
1903 * @param bool $noquote
1904 * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1905 * @return array item array
1907 function twitter_createpost(int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1910 $postarray['network'] = Protocol::TWITTER;
1911 $postarray['uid'] = $uid;
1912 $postarray['wall'] = 0;
1913 $postarray['uri'] = 'twitter::' . $post->id_str;
1914 $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1915 $postarray['source'] = json_encode($post);
1916 $postarray['direction'] = Conversation::PULL;
1918 if (empty($uriId)) {
1919 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1922 // Don't import our own comments
1923 if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1924 Logger::info('Item found', ['extid' => $postarray['uri']]);
1930 if ($post->in_reply_to_status_id_str != '') {
1931 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1933 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1934 if (!DBA::isResult($item)) {
1935 $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1938 if (DBA::isResult($item)) {
1939 $postarray['thr-parent'] = $item['uri'];
1940 $postarray['object-type'] = Activity\ObjectType::COMMENT;
1942 $postarray['object-type'] = Activity\ObjectType::NOTE;
1946 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1948 if ($post->user->id_str == $own_id) {
1949 $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1950 if (DBA::isResult($self)) {
1951 $contactid = $self['id'];
1953 $postarray['owner-id'] = Contact::getIdForURL($self['url']);
1954 $postarray['owner-name'] = $self['name'];
1955 $postarray['owner-link'] = $self['url'];
1956 $postarray['owner-avatar'] = $self['photo'];
1958 Logger::error('No self contact found', ['uid' => $uid]);
1962 // Don't create accounts of people who just comment something
1963 $create_user = false;
1965 $postarray['object-type'] = Activity\ObjectType::NOTE;
1968 if ($contactid == 0) {
1969 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1971 $postarray['owner-id'] = twitter_get_contact($post->user);
1972 $postarray['owner-name'] = $post->user->name;
1973 $postarray['owner-link'] = 'https://twitter.com/' . $post->user->screen_name;
1974 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1977 if (($contactid == 0) && !$only_existing_contact) {
1978 $contactid = $self['id'];
1979 } elseif ($contactid <= 0) {
1980 Logger::info('Contact ID is zero or less than zero.');
1984 $postarray['contact-id'] = $contactid;
1985 $postarray['verb'] = Activity::POST;
1986 $postarray['author-id'] = $postarray['owner-id'];
1987 $postarray['author-name'] = $postarray['owner-name'];
1988 $postarray['author-link'] = $postarray['owner-link'];
1989 $postarray['author-avatar'] = $postarray['owner-avatar'];
1990 $postarray['plink'] = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1991 $postarray['app'] = strip_tags($post->source);
1993 if ($post->user->protected) {
1994 $postarray['private'] = Item::PRIVATE;
1995 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1997 $postarray['private'] = Item::UNLISTED;
1998 $postarray['allow_cid'] = '';
2001 if (!empty($post->full_text)) {
2002 $postarray['body'] = $post->full_text;
2004 $postarray['body'] = $post->text;
2007 // When the post contains links then use the correct object type
2008 if (count($post->entities->urls) > 0) {
2009 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2012 // Search for media links
2013 twitter_media_entities($post, $postarray, $uriId);
2015 $converted = twitter_expand_entities($postarray['body'], $post);
2017 // When the post contains external links then images or videos are just "decorations".
2018 if (!empty($converted['urls'])) {
2019 $postarray['post-type'] = Item::PT_NOTE;
2022 $postarray['body'] = $converted['body'];
2023 $postarray['created'] = DateTimeFormat::utc($post->created_at);
2024 $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2027 twitter_store_tags($uriId, $converted['taglist']);
2028 twitter_store_attachments($uriId, $post);
2031 if (!empty($post->place->name)) {
2032 $postarray['location'] = $post->place->name;
2034 if (!empty($post->place->full_name)) {
2035 $postarray['location'] = $post->place->full_name;
2037 if (!empty($post->geo->coordinates)) {
2038 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2040 if (!empty($post->coordinates->coordinates)) {
2041 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2043 if (!empty($post->retweeted_status)) {
2044 $retweet = twitter_createpost($uid, $post->retweeted_status, $self, false, false, $noquote);
2046 if (empty($retweet)) {
2051 // Store the original tweet
2052 Item::insert($retweet);
2054 // CHange the other post into a reshare activity
2055 $postarray['verb'] = Activity::ANNOUNCE;
2056 $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2057 $postarray['object-type'] = Activity\ObjectType::NOTE;
2059 $postarray['thr-parent'] = $retweet['uri'];
2061 $retweet['source'] = $postarray['source'];
2062 $retweet['direction'] = $postarray['direction'];
2063 $retweet['private'] = $postarray['private'];
2064 $retweet['allow_cid'] = $postarray['allow_cid'];
2065 $retweet['contact-id'] = $postarray['contact-id'];
2066 $retweet['owner-id'] = $postarray['owner-id'];
2067 $retweet['owner-name'] = $postarray['owner-name'];
2068 $retweet['owner-link'] = $postarray['owner-link'];
2069 $retweet['owner-avatar'] = $postarray['owner-avatar'];
2071 $postarray = $retweet;
2075 if (!empty($post->quoted_status)) {
2077 // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2078 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2080 $quoted = twitter_createpost(0, $post->quoted_status, $self, false, false, true);
2081 if (!empty($quoted)) {
2082 Item::insert($quoted);
2083 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2084 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2086 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2087 $quoted['author-name'],
2088 $quoted['author-link'],
2089 $quoted['author-avatar'],
2095 $postarray['body'] .= $quoted['body'] . '[/share]';
2097 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2098 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2107 * Store tags and mentions
2109 * @param integer $uriId
2110 * @param array $taglist
2113 function twitter_store_tags(int $uriId, array $taglist)
2115 foreach ($taglist as $tag) {
2116 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2120 function twitter_fetchparentposts(int $uid, $post, TwitterOAuth $connection, array $self)
2122 Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2126 while (!empty($post->in_reply_to_status_id_str)) {
2128 $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2129 } catch (TwitterOAuthException $e) {
2130 Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2135 Logger::info("twitter_fetchparentposts: Can't fetch post");
2139 if (empty($post->id_str)) {
2140 Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2144 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2151 Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2153 $posts = array_reverse($posts);
2155 if (!empty($posts)) {
2156 foreach ($posts as $post) {
2157 $postarray = twitter_createpost($uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2159 if (empty($postarray)) {
2163 $item = Item::insert($postarray);
2165 $postarray['id'] = $item;
2167 Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2173 * Fetches the posts received by the Twitter user
2179 function twitter_fetchhometimeline(int $uid): void
2181 $ckey = DI::config()->get('twitter', 'consumerkey');
2182 $csecret = DI::config()->get('twitter', 'consumersecret');
2183 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2184 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2185 $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2186 $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2188 Logger::info('Fetching timeline', ['uid' => $uid]);
2190 $application_name = DI::keyValue()->get('twitter_application_name');
2192 if ($application_name == '') {
2193 $application_name = DI::baseUrl()->getHostname();
2196 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2199 $own_contact = twitter_fetch_own_contact($uid);
2200 } catch (TwitterOAuthException $e) {
2201 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2205 $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2206 if (DBA::isResult($contact)) {
2207 $own_id = $contact['nick'];
2209 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2213 $self = User::getOwnerDataById($uid);
2214 if ($self === false) {
2215 Logger::warning('Own contact not found', ['uid' => $uid]);
2220 'exclude_replies' => false,
2221 'trim_user' => false,
2222 'contributor_details' => true,
2223 'include_rts' => true,
2224 'tweet_mode' => 'extended',
2225 'include_ext_alt_text' => true,
2229 // Fetching timeline
2230 $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2232 $first_time = ($lastid == '');
2234 if ($lastid != '') {
2235 $parameters['since_id'] = $lastid;
2239 $items = $connection->get('statuses/home_timeline', $parameters);
2240 } catch (TwitterOAuthException $e) {
2241 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2245 if (!is_array($items)) {
2246 Logger::notice('home timeline is no array', ['items' => $items]);
2250 if (empty($items)) {
2251 Logger::info('No new timeline content', ['uid' => $uid]);
2255 $posts = array_reverse($items);
2257 Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2259 if (count($posts)) {
2260 foreach ($posts as $post) {
2261 if ($post->id_str > $lastid) {
2262 $lastid = $post->id_str;
2263 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2270 if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2271 Logger::info('Skip previously sent post');
2275 if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2276 Logger::info('Skip post that will be mirrored');
2280 if ($post->in_reply_to_status_id_str != '') {
2281 twitter_fetchparentposts($uid, $post, $connection, $self);
2284 Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2286 $postarray = twitter_createpost($uid, $post, $self, $create_user, true, false);
2288 if (empty($postarray)) {
2289 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2295 if (empty($postarray['thr-parent'])) {
2296 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2297 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2298 $notify = Worker::PRIORITY_MEDIUM;
2302 $postarray['wall'] = (bool)$notify;
2304 $item = Item::insert($postarray, $notify);
2305 $postarray['id'] = $item;
2307 Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2310 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2312 Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2314 // Fetching mentions
2315 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2317 $first_time = ($lastid == '');
2319 if ($lastid != '') {
2320 $parameters['since_id'] = $lastid;
2324 $items = $connection->get('statuses/mentions_timeline', $parameters);
2325 } catch (TwitterOAuthException $e) {
2326 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2330 if (!is_array($items)) {
2331 Logger::notice('mentions are no arrays', ['items' => $items]);
2335 $posts = array_reverse($items);
2337 Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2339 if (count($posts)) {
2340 foreach ($posts as $post) {
2341 if ($post->id_str > $lastid) {
2342 $lastid = $post->id_str;
2349 if ($post->in_reply_to_status_id_str != '') {
2350 twitter_fetchparentposts($uid, $post, $connection, $self);
2353 $postarray = twitter_createpost($uid, $post, $self, false, !$create_user, false);
2355 if (empty($postarray)) {
2359 $item = Item::insert($postarray);
2361 Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2365 DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2367 Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2370 function twitter_fetch_own_contact(int $uid)
2372 $ckey = DI::config()->get('twitter', 'consumerkey');
2373 $csecret = DI::config()->get('twitter', 'consumersecret');
2374 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2375 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2377 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2381 if ($own_id == '') {
2382 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2384 // Fetching user data
2385 // get() may throw TwitterOAuthException, but we will catch it later
2386 $user = $connection->get('account/verify_credentials');
2387 if (empty($user->id_str)) {
2391 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2393 $contact_id = twitter_fetch_contact($uid, $user, true);
2395 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2396 if (DBA::isResult($contact)) {
2397 $contact_id = $contact['id'];
2399 DI::pConfig()->delete($uid, 'twitter', 'own_id');
2406 function twitter_is_retweet(int $uid, string $body): bool
2408 $body = trim($body);
2410 // Skip if it isn't a pure repeated messages
2411 // Does it start with a share?
2412 if (strpos($body, '[share') > 0) {
2416 // Does it end with a share?
2417 if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2421 $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2422 // Skip if there is no shared message in there
2423 if ($body == $attributes) {
2428 preg_match("/link='(.*?)'/ism", $attributes, $matches);
2429 if (!empty($matches[1])) {
2430 $link = $matches[1];
2433 preg_match('/link="(.*?)"/ism', $attributes, $matches);
2434 if (!empty($matches[1])) {
2435 $link = $matches[1];
2438 $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2442 return twitter_retweet($uid, $id);
2445 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2447 Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2449 $result = twitter_api_post('statuses/retweet', $id, $uid);
2451 Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2453 if (!empty($item_id) && !empty($result->id_str)) {
2454 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2455 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2458 return !isset($result->errors);
2461 function twitter_update_mentions(string $body): string
2463 $URLSearchString = '^\[\]';
2464 $return = preg_replace_callback(
2465 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2466 function ($matches) {
2467 if (strpos($matches[1], 'twitter.com')) {
2468 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2470 $return = $matches[2] . ' (' . $matches[1] . ')';
2481 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2483 if (empty($author_contact)) {
2484 return $content . "\n\n" . $attributes['link'];
2487 if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2488 $mention = '@' . $author_contact['nick'];
2490 $mention = $author_contact['addr'];
2493 return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];