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\ConfigFileLoader;
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(App $a, ConfigFileLoader $loader)
129 $a->getConfigCache()->load($loader->loadAddonConfig('twitter'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
132 function twitter_check_item_notification(App $a, 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(App $a, array &$data)
144 if ($data['protocol'] == Protocol::TWITTER) {
145 $data['result'] = true;
149 function twitter_follow(App $a, 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 = $a->getLoggedInUserId();
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(App $a, array &$hook_data)
181 $hook_data['result'] = twitter_api_contact('friendships/destroy', $hook_data['contact'], $hook_data['uid']);
184 function twitter_block(App $a, 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(App $a, 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(App $a, 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(App $a)
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(App $a, 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(App $a, 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(App $a, 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(App $a, 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(App $a, 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($a, $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(App $a, 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($a, $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::config()->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::config()->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(App $a)
984 DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
985 DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
988 function twitter_addon_admin(App $a, 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(App $a)
1002 $last = DI::config()->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($a, $rr["uid"]);
1053 DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1058 Logger::notice('twitter: cron_end');
1060 DI::config()->set('twitter', 'last_poll', time());
1063 function twitter_expire(App $a)
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(App $a, 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
1153 * @param array $b Expected format:
1155 * 'url' => [URL to parse],
1156 * 'format' => 'json'|'',
1157 * 'text' => Output parameter
1159 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1161 function twitter_parse_link(App $a, array &$b)
1163 // Only handle Twitter status URLs
1164 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1168 $status = twitter_statuses_show($matches[1]);
1170 if (empty($status->id)) {
1174 $item = twitter_createpost($a, 0, $status, [], true, false, true);
1179 if ($b['format'] == 'json') {
1181 foreach ($status->extended_entities->media ?? [] as $media) {
1182 if (!empty($media->media_url_https)) {
1184 'src' => $media->media_url_https,
1185 'width' => $media->sizes->thumb->w,
1186 'height' => $media->sizes->thumb->h,
1194 'url' => $item['plink'],
1195 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1196 'text' => BBCode::toPlaintext($item['body'], false),
1197 'images' => $images,
1199 'contentType' => 'attachment',
1203 $b['text'] = BBCode::getShareOpeningTag(
1204 $item['author-name'],
1205 $item['author-link'],
1206 $item['author-avatar'],
1210 $b['text'] .= $item['body'] . '[/share]';
1215 /*********************
1219 *********************/
1223 * @brief Build the item array for the mirrored post
1225 * @param App $a Application class
1226 * @param integer $uid User id
1227 * @param object $post Twitter object with the post
1229 * @return array item data to be posted
1231 function twitter_do_mirrorpost(App $a, int $uid, $post)
1233 $datarray['uid'] = $uid;
1234 $datarray['extid'] = 'twitter::' . $post->id;
1235 $datarray['title'] = '';
1237 if (!empty($post->retweeted_status)) {
1238 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1239 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1245 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1246 $item['author-name'],
1247 $item['author-link'],
1248 $item['author-avatar'],
1253 $datarray['body'] .= $item['body'] . '[/share]';
1255 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1261 $datarray['body'] = $item['body'];
1264 $datarray['app'] = $item['app'];
1265 $datarray['verb'] = $item['verb'];
1267 if (isset($item['location'])) {
1268 $datarray['location'] = $item['location'];
1271 if (isset($item['coord'])) {
1272 $datarray['coord'] = $item['coord'];
1279 * Fetches the Twitter user's own posts
1286 function twitter_fetchtimeline(App $a, int $uid): void
1288 $ckey = DI::config()->get('twitter', 'consumerkey');
1289 $csecret = DI::config()->get('twitter', 'consumersecret');
1290 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1291 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1292 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastid');
1294 $application_name = DI::config()->get('twitter', 'application_name');
1296 if ($application_name == '') {
1297 $application_name = DI::baseUrl()->getHostname();
1300 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1302 // Ensure to have the own contact
1304 twitter_fetch_own_contact($a, $uid);
1305 } catch (TwitterOAuthException $e) {
1306 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1311 'exclude_replies' => true,
1312 'trim_user' => false,
1313 'contributor_details' => true,
1314 'include_rts' => true,
1315 'tweet_mode' => 'extended',
1316 'include_ext_alt_text' => true,
1319 $first_time = ($lastid == '');
1321 if ($lastid != '') {
1322 $parameters['since_id'] = $lastid;
1326 $items = $connection->get('statuses/user_timeline', $parameters);
1327 } catch (TwitterOAuthException $e) {
1328 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1332 if (!is_array($items)) {
1333 Logger::notice('No items', ['user' => $uid]);
1337 $posts = array_reverse($items);
1339 Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1341 if (count($posts)) {
1342 foreach ($posts as $post) {
1343 if ($post->id_str > $lastid) {
1344 $lastid = $post->id_str;
1345 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1349 Logger::notice('First time, continue');
1353 if (stristr($post->source, $application_name)) {
1354 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1357 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1359 $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1361 if (empty($mirrorpost['body'])) {
1362 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1366 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1368 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1371 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1372 Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1375 function twitter_fix_avatar($avatar)
1377 $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1379 $info = Images::getInfoFromURLCached($new_avatar);
1381 $new_avatar = $avatar;
1387 function twitter_get_relation($uid, $target, $contact = [])
1389 if (isset($contact['rel'])) {
1390 $relation = $contact['rel'];
1395 $ckey = DI::config()->get('twitter', 'consumerkey');
1396 $csecret = DI::config()->get('twitter', 'consumersecret');
1397 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1398 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1399 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1401 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1402 $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1405 $status = $connection->get('friendships/show', $parameters);
1406 if ($connection->getLastHttpCode() !== 200) {
1407 throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1410 $following = $status->relationship->source->following;
1411 $followed = $status->relationship->source->followed_by;
1413 if ($following && !$followed) {
1414 $relation = Contact::SHARING;
1415 } elseif (!$following && $followed) {
1416 $relation = Contact::FOLLOWER;
1417 } elseif ($following && $followed) {
1418 $relation = Contact::FRIEND;
1419 } elseif (!$following && !$followed) {
1423 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1424 } catch (Throwable $e) {
1425 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1435 function twitter_user_to_contact($data)
1437 if (empty($data->id_str)) {
1441 $baseurl = 'https://twitter.com';
1442 $url = $baseurl . '/' . $data->screen_name;
1443 $addr = $data->screen_name . '@twitter.com';
1447 'nurl' => Strings::normaliseLink($url),
1448 'uri-id' => ItemURI::getIdByURI($url),
1449 'network' => Protocol::TWITTER,
1450 'alias' => 'twitter::' . $data->id_str,
1451 'baseurl' => $baseurl,
1452 'name' => $data->name,
1453 'nick' => $data->screen_name,
1455 'location' => $data->location,
1456 'about' => $data->description,
1457 'photo' => twitter_fix_avatar($data->profile_image_url_https),
1458 'header' => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1464 function twitter_get_contact($data, int $uid = 0)
1466 $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1467 if (DBA::isResult($contact)) {
1468 return $contact['id'];
1470 return twitter_fetch_contact($uid, $data, false);
1474 function twitter_fetch_contact($uid, $data, $create_user)
1476 $fields = twitter_user_to_contact($data);
1478 if (empty($fields)) {
1482 // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1483 $avatar = $fields['photo'];
1484 unset($fields['photo']);
1486 // Update the public contact
1487 $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1488 if (DBA::isResult($pcontact)) {
1489 $cid = $pcontact['id'];
1491 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1495 Contact::update($fields, ['id' => $cid]);
1496 Contact::updateAvatar($cid, $avatar);
1498 Logger::notice('No contact found', ['fields' => $fields]);
1501 $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1502 if (!DBA::isResult($contact) && empty($cid)) {
1503 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1505 } elseif (!$create_user) {
1509 if (!DBA::isResult($contact)) {
1510 $relation = twitter_get_relation($uid, $data->screen_name);
1512 // create contact record
1513 $fields['uid'] = $uid;
1514 $fields['created'] = DateTimeFormat::utcNow();
1515 $fields['poll'] = 'twitter::' . $data->id_str;
1516 $fields['rel'] = $relation;
1517 $fields['priority'] = 1;
1518 $fields['writable'] = true;
1519 $fields['blocked'] = false;
1520 $fields['readonly'] = false;
1521 $fields['pending'] = false;
1523 if (!Contact::insert($fields)) {
1527 $contact_id = DBA::lastInsertId();
1529 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1531 if ($contact['readonly'] || $contact['blocked']) {
1532 Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1536 $contact_id = $contact['id'];
1539 // Update the contact relation once per day
1540 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1541 $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1545 if ($contact['name'] != $data->name) {
1546 $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1550 if ($contact['nick'] != $data->screen_name) {
1551 $fields['uri-date'] = DateTimeFormat::utcNow();
1555 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1560 $fields['updated'] = DateTimeFormat::utcNow();
1561 Contact::update($fields, ['id' => $contact['id']]);
1562 Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1566 Contact::updateAvatar($contact_id, $avatar);
1568 if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1569 twitter_auto_follow($uid, $data);
1576 * Follow a fediverse account that is proived in the name or the profile
1578 * @param integer $uid
1579 * @param object $data
1581 function twitter_auto_follow(int $uid, object $data)
1583 $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1585 // Search for user@domain.tld in the name
1586 if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1587 if (twitter_add_contact($match[1], true, $uid)) {
1592 // Search for @user@domain.tld in the description
1593 if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1594 if (twitter_add_contact($match[1], true, $uid)) {
1599 // Search for user@domain.tld in the description
1600 // We don't probe here, since this could be a mail address
1601 if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1602 if (twitter_add_contact($match[1], false, $uid)) {
1607 // Search for profile links in the description
1608 foreach ($data->entities->description->urls as $url) {
1609 if (!empty($url->expanded_url)) {
1610 // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1611 twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1617 * Check if the provided address is a fediverse account and adds it
1619 * @param string $addr
1620 * @param boolean $probe
1621 * @param integer $uid
1624 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1626 $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1627 if (empty($contact)) {
1628 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1632 if (!in_array($contact['network'], Protocol::FEDERATED)) {
1633 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1637 if (Contact::isSharing($contact['id'], $uid)) {
1638 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1642 Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1643 Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1649 * @param string $screen_name
1650 * @return stdClass|null
1653 function twitter_fetchuser($screen_name)
1655 $ckey = DI::config()->get('twitter', 'consumerkey');
1656 $csecret = DI::config()->get('twitter', 'consumersecret');
1659 // Fetching user data
1660 $connection = new TwitterOAuth($ckey, $csecret);
1661 $parameters = ['screen_name' => $screen_name];
1662 $user = $connection->get('users/show', $parameters);
1663 } catch (TwitterOAuthException $e) {
1664 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1668 if (!is_object($user)) {
1676 * Replaces Twitter entities with Friendica-friendly links.
1678 * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1680 * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1681 * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1682 * index to be correct even after the last replacement.
1684 * @param string $body
1685 * @param stdClass $status
1687 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1689 function twitter_expand_entities($body, stdClass $status)
1692 $contains_urls = false;
1696 $replacementList = [];
1698 foreach ($status->entities->hashtags AS $hashtag) {
1699 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1700 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1702 $replacementList[$hashtag->indices[0]] = [
1703 'replace' => $replace,
1704 'length' => $hashtag->indices[1] - $hashtag->indices[0],
1708 foreach ($status->entities->user_mentions AS $mention) {
1709 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1710 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1712 $replacementList[$mention->indices[0]] = [
1713 'replace' => $replace,
1714 'length' => $mention->indices[1] - $mention->indices[0],
1718 foreach ($status->entities->urls ?? [] as $url) {
1719 $plain = str_replace($url->url, '', $plain);
1721 if ($url->url && $url->expanded_url && $url->display_url) {
1722 // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1723 if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1724 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1726 $replacementList[$url->indices[0]] = [
1728 'length' => $url->indices[1] - $url->indices[0],
1733 $contains_urls = true;
1735 $expanded_url = $url->expanded_url;
1737 // Quickfix: Workaround for URL with '[' and ']' in it
1738 if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1739 $expanded_url = $url->url;
1742 $replacementList[$url->indices[0]] = [
1743 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1744 'length' => $url->indices[1] - $url->indices[0],
1749 krsort($replacementList);
1751 foreach ($replacementList as $startIndex => $parameters) {
1752 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1755 $body = trim($body);
1757 return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1761 * Store entity attachments
1763 * @param integer $uriId
1764 * @param object $post Twitter object with the post
1766 function twitter_store_attachments(int $uriId, $post)
1768 if (!empty($post->extended_entities->media)) {
1769 foreach ($post->extended_entities->media AS $medium) {
1770 switch ($medium->type) {
1772 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1774 $attachment['url'] = $medium->media_url_https . '?name=large';
1775 $attachment['width'] = $medium->sizes->large->w;
1776 $attachment['height'] = $medium->sizes->large->h;
1778 if ($medium->sizes->small->w != $attachment['width']) {
1779 $attachment['preview'] = $medium->media_url_https . '?name=small';
1780 $attachment['preview-width'] = $medium->sizes->small->w;
1781 $attachment['preview-height'] = $medium->sizes->small->h;
1784 $attachment['name'] = $medium->display_url ?? null;
1785 $attachment['description'] = $medium->ext_alt_text ?? null;
1786 Logger::debug('Photo attachment', ['attachment' => $attachment]);
1787 Post\Media::insert($attachment);
1790 case 'animated_gif':
1791 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1792 if (is_array($medium->video_info->variants)) {
1794 // We take the video with the highest bitrate
1795 foreach ($medium->video_info->variants AS $variant) {
1796 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1797 $attachment['url'] = $variant->url;
1798 $bitrate = $variant->bitrate;
1803 $attachment['name'] = $medium->display_url ?? null;
1804 $attachment['preview'] = $medium->media_url_https . ':small';
1805 $attachment['preview-width'] = $medium->sizes->small->w;
1806 $attachment['preview-height'] = $medium->sizes->small->h;
1807 $attachment['description'] = $medium->ext_alt_text ?? null;
1808 Logger::debug('Video attachment', ['attachment' => $attachment]);
1809 Post\Media::insert($attachment);
1812 Logger::notice('Unknown media type', ['medium' => $medium]);
1817 if (!empty($post->entities->urls)) {
1818 foreach ($post->entities->urls as $url) {
1819 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1820 Logger::debug('Attached link', ['attachment' => $attachment]);
1821 Post\Media::insert($attachment);
1827 * @brief Fetch media entities and add media links to the body
1829 * @param object $post Twitter object with the post
1830 * @param array $postarray Array of the item that is about to be posted
1831 * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1833 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1835 // There are no media entities? So we quit.
1836 if (empty($post->extended_entities->media)) {
1840 // This is a pure media post, first search for all media urls
1842 foreach ($post->extended_entities->media AS $medium) {
1843 if (!isset($media[$medium->url])) {
1844 $media[$medium->url] = '';
1846 switch ($medium->type) {
1848 if (!empty($medium->ext_alt_text)) {
1849 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1850 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1852 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1855 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1856 $postarray['post-type'] = Item::PT_IMAGE;
1859 // Currently deactivated, since this causes the video to be display before the content
1860 // We have to figure out a better way for declaring the post type and the display style.
1861 //$postarray['post-type'] = Item::PT_VIDEO;
1862 case 'animated_gif':
1863 if (!empty($medium->ext_alt_text)) {
1864 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1865 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1867 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1870 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1871 if (is_array($medium->video_info->variants)) {
1873 // We take the video with the highest bitrate
1874 foreach ($medium->video_info->variants AS $variant) {
1875 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1876 $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1877 $bitrate = $variant->bitrate;
1886 foreach ($media AS $key => $value) {
1887 $postarray['body'] = str_replace($key, '', $postarray['body']);
1892 // Now we replace the media urls.
1893 foreach ($media AS $key => $value) {
1894 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1899 * Undocumented function
1902 * @param integer $uid User ID
1903 * @param object $post Incoming Twitter post
1904 * @param array $self
1905 * @param bool $create_user Should users be created?
1906 * @param bool $only_existing_contact Only import existing contacts if set to "true"
1907 * @param bool $noquote
1908 * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1909 * @return array item array
1911 function twitter_createpost(App $a, int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1914 $postarray['network'] = Protocol::TWITTER;
1915 $postarray['uid'] = $uid;
1916 $postarray['wall'] = 0;
1917 $postarray['uri'] = 'twitter::' . $post->id_str;
1918 $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1919 $postarray['source'] = json_encode($post);
1920 $postarray['direction'] = Conversation::PULL;
1922 if (empty($uriId)) {
1923 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1926 // Don't import our own comments
1927 if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1928 Logger::info('Item found', ['extid' => $postarray['uri']]);
1934 if ($post->in_reply_to_status_id_str != '') {
1935 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1937 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1938 if (!DBA::isResult($item)) {
1939 $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1942 if (DBA::isResult($item)) {
1943 $postarray['thr-parent'] = $item['uri'];
1944 $postarray['object-type'] = Activity\ObjectType::COMMENT;
1946 $postarray['object-type'] = Activity\ObjectType::NOTE;
1950 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1952 if ($post->user->id_str == $own_id) {
1953 $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1954 if (DBA::isResult($self)) {
1955 $contactid = $self['id'];
1957 $postarray['owner-id'] = Contact::getIdForURL($self['url']);
1958 $postarray['owner-name'] = $self['name'];
1959 $postarray['owner-link'] = $self['url'];
1960 $postarray['owner-avatar'] = $self['photo'];
1962 Logger::error('No self contact found', ['uid' => $uid]);
1966 // Don't create accounts of people who just comment something
1967 $create_user = false;
1969 $postarray['object-type'] = Activity\ObjectType::NOTE;
1972 if ($contactid == 0) {
1973 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1975 $postarray['owner-id'] = twitter_get_contact($post->user);
1976 $postarray['owner-name'] = $post->user->name;
1977 $postarray['owner-link'] = 'https://twitter.com/' . $post->user->screen_name;
1978 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1981 if (($contactid == 0) && !$only_existing_contact) {
1982 $contactid = $self['id'];
1983 } elseif ($contactid <= 0) {
1984 Logger::info('Contact ID is zero or less than zero.');
1988 $postarray['contact-id'] = $contactid;
1989 $postarray['verb'] = Activity::POST;
1990 $postarray['author-id'] = $postarray['owner-id'];
1991 $postarray['author-name'] = $postarray['owner-name'];
1992 $postarray['author-link'] = $postarray['owner-link'];
1993 $postarray['author-avatar'] = $postarray['owner-avatar'];
1994 $postarray['plink'] = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1995 $postarray['app'] = strip_tags($post->source);
1997 if ($post->user->protected) {
1998 $postarray['private'] = Item::PRIVATE;
1999 $postarray['allow_cid'] = '<' . $self['id'] . '>';
2001 $postarray['private'] = Item::UNLISTED;
2002 $postarray['allow_cid'] = '';
2005 if (!empty($post->full_text)) {
2006 $postarray['body'] = $post->full_text;
2008 $postarray['body'] = $post->text;
2011 // When the post contains links then use the correct object type
2012 if (count($post->entities->urls) > 0) {
2013 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2016 // Search for media links
2017 twitter_media_entities($post, $postarray, $uriId);
2019 $converted = twitter_expand_entities($postarray['body'], $post);
2021 // When the post contains external links then images or videos are just "decorations".
2022 if (!empty($converted['urls'])) {
2023 $postarray['post-type'] = Item::PT_NOTE;
2026 $postarray['body'] = $converted['body'];
2027 $postarray['created'] = DateTimeFormat::utc($post->created_at);
2028 $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2031 twitter_store_tags($uriId, $converted['taglist']);
2032 twitter_store_attachments($uriId, $post);
2035 if (!empty($post->place->name)) {
2036 $postarray['location'] = $post->place->name;
2038 if (!empty($post->place->full_name)) {
2039 $postarray['location'] = $post->place->full_name;
2041 if (!empty($post->geo->coordinates)) {
2042 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2044 if (!empty($post->coordinates->coordinates)) {
2045 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2047 if (!empty($post->retweeted_status)) {
2048 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
2050 if (empty($retweet)) {
2055 // Store the original tweet
2056 Item::insert($retweet);
2058 // CHange the other post into a reshare activity
2059 $postarray['verb'] = Activity::ANNOUNCE;
2060 $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2061 $postarray['object-type'] = Activity\ObjectType::NOTE;
2063 $postarray['thr-parent'] = $retweet['uri'];
2065 $retweet['source'] = $postarray['source'];
2066 $retweet['direction'] = $postarray['direction'];
2067 $retweet['private'] = $postarray['private'];
2068 $retweet['allow_cid'] = $postarray['allow_cid'];
2069 $retweet['contact-id'] = $postarray['contact-id'];
2070 $retweet['owner-id'] = $postarray['owner-id'];
2071 $retweet['owner-name'] = $postarray['owner-name'];
2072 $retweet['owner-link'] = $postarray['owner-link'];
2073 $retweet['owner-avatar'] = $postarray['owner-avatar'];
2075 $postarray = $retweet;
2079 if (!empty($post->quoted_status)) {
2081 // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2082 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2084 $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
2085 if (!empty($quoted)) {
2086 Item::insert($quoted);
2087 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2088 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2090 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2091 $quoted['author-name'],
2092 $quoted['author-link'],
2093 $quoted['author-avatar'],
2099 $postarray['body'] .= $quoted['body'] . '[/share]';
2101 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2102 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2111 * Store tags and mentions
2113 * @param integer $uriId
2114 * @param array $taglist
2117 function twitter_store_tags(int $uriId, array $taglist)
2119 foreach ($taglist as $tag) {
2120 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2124 function twitter_fetchparentposts(App $a, int $uid, $post, TwitterOAuth $connection, array $self)
2126 Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2130 while (!empty($post->in_reply_to_status_id_str)) {
2132 $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2133 } catch (TwitterOAuthException $e) {
2134 Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2139 Logger::info("twitter_fetchparentposts: Can't fetch post");
2143 if (empty($post->id_str)) {
2144 Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2148 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2155 Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2157 $posts = array_reverse($posts);
2159 if (!empty($posts)) {
2160 foreach ($posts as $post) {
2161 $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2163 if (empty($postarray)) {
2167 $item = Item::insert($postarray);
2169 $postarray['id'] = $item;
2171 Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2177 * Fetches the posts received by the Twitter user
2184 function twitter_fetchhometimeline(App $a, int $uid): void
2186 $ckey = DI::config()->get('twitter', 'consumerkey');
2187 $csecret = DI::config()->get('twitter', 'consumersecret');
2188 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2189 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2190 $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2191 $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2193 Logger::info('Fetching timeline', ['uid' => $uid]);
2195 $application_name = DI::config()->get('twitter', 'application_name');
2197 if ($application_name == '') {
2198 $application_name = DI::baseUrl()->getHostname();
2201 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2204 $own_contact = twitter_fetch_own_contact($a, $uid);
2205 } catch (TwitterOAuthException $e) {
2206 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2210 $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2211 if (DBA::isResult($contact)) {
2212 $own_id = $contact['nick'];
2214 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2218 $self = User::getOwnerDataById($uid);
2219 if ($self === false) {
2220 Logger::warning('Own contact not found', ['uid' => $uid]);
2225 'exclude_replies' => false,
2226 'trim_user' => false,
2227 'contributor_details' => true,
2228 'include_rts' => true,
2229 'tweet_mode' => 'extended',
2230 'include_ext_alt_text' => true,
2234 // Fetching timeline
2235 $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2237 $first_time = ($lastid == '');
2239 if ($lastid != '') {
2240 $parameters['since_id'] = $lastid;
2244 $items = $connection->get('statuses/home_timeline', $parameters);
2245 } catch (TwitterOAuthException $e) {
2246 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2250 if (!is_array($items)) {
2251 Logger::notice('home timeline is no array', ['items' => $items]);
2255 if (empty($items)) {
2256 Logger::info('No new timeline content', ['uid' => $uid]);
2260 $posts = array_reverse($items);
2262 Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2264 if (count($posts)) {
2265 foreach ($posts as $post) {
2266 if ($post->id_str > $lastid) {
2267 $lastid = $post->id_str;
2268 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2275 if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2276 Logger::info('Skip previously sent post');
2280 if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2281 Logger::info('Skip post that will be mirrored');
2285 if ($post->in_reply_to_status_id_str != '') {
2286 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2289 Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2291 $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
2293 if (empty($postarray)) {
2294 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2300 if (empty($postarray['thr-parent'])) {
2301 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2302 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2303 $notify = Worker::PRIORITY_MEDIUM;
2307 $item = Item::insert($postarray, $notify);
2308 $postarray['id'] = $item;
2310 Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2313 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2315 Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2317 // Fetching mentions
2318 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2320 $first_time = ($lastid == '');
2322 if ($lastid != '') {
2323 $parameters['since_id'] = $lastid;
2327 $items = $connection->get('statuses/mentions_timeline', $parameters);
2328 } catch (TwitterOAuthException $e) {
2329 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2333 if (!is_array($items)) {
2334 Logger::notice('mentions are no arrays', ['items' => $items]);
2338 $posts = array_reverse($items);
2340 Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2342 if (count($posts)) {
2343 foreach ($posts as $post) {
2344 if ($post->id_str > $lastid) {
2345 $lastid = $post->id_str;
2352 if ($post->in_reply_to_status_id_str != '') {
2353 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2356 $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2358 if (empty($postarray)) {
2362 $item = Item::insert($postarray);
2364 Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2368 DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2370 Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2373 function twitter_fetch_own_contact(App $a, int $uid)
2375 $ckey = DI::config()->get('twitter', 'consumerkey');
2376 $csecret = DI::config()->get('twitter', 'consumersecret');
2377 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2378 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2380 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2384 if ($own_id == '') {
2385 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2387 // Fetching user data
2388 // get() may throw TwitterOAuthException, but we will catch it later
2389 $user = $connection->get('account/verify_credentials');
2390 if (empty($user->id_str)) {
2394 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2396 $contact_id = twitter_fetch_contact($uid, $user, true);
2398 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2399 if (DBA::isResult($contact)) {
2400 $contact_id = $contact['id'];
2402 DI::pConfig()->delete($uid, 'twitter', 'own_id');
2409 function twitter_is_retweet(App $a, int $uid, string $body): bool
2411 $body = trim($body);
2413 // Skip if it isn't a pure repeated messages
2414 // Does it start with a share?
2415 if (strpos($body, '[share') > 0) {
2419 // Does it end with a share?
2420 if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2424 $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2425 // Skip if there is no shared message in there
2426 if ($body == $attributes) {
2431 preg_match("/link='(.*?)'/ism", $attributes, $matches);
2432 if (!empty($matches[1])) {
2433 $link = $matches[1];
2436 preg_match('/link="(.*?)"/ism', $attributes, $matches);
2437 if (!empty($matches[1])) {
2438 $link = $matches[1];
2441 $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2445 return twitter_retweet($uid, $id);
2448 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2450 Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2452 $result = twitter_api_post('statuses/retweet', $id, $uid);
2454 Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2456 if (!empty($item_id) && !empty($result->id_str)) {
2457 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2458 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2461 return !isset($result->errors);
2464 function twitter_update_mentions(string $body): string
2466 $URLSearchString = '^\[\]';
2467 $return = preg_replace_callback(
2468 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2469 function ($matches) {
2470 if (strpos($matches[1], 'twitter.com')) {
2471 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2473 $return = $matches[2] . ' (' . $matches[1] . ')';
2484 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2486 if (empty($author_contact)) {
2487 return $content . "\n\n" . $attributes['link'];
2490 if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2491 $mention = '@' . $author_contact['nick'];
2493 $mention = $author_contact['addr'];
2496 return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];