]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
[twitter] Add support for multiple images in parse_url hook function
[friendica-addons.git] / twitter / twitter.php
1 <?php
2 /**
3  * Name: Twitter Connector
4  * Description: Bidirectional (posting, relaying and reading) connector for Twitter.
5  * Version: 1.1.0
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>
9  *
10  * Copyright (c) 2011-2013 Tobias Diekershoff, Michael Vogel, Hypolite Petovan
11  * All rights reserved.
12  *
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.
23  *
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.
34  *
35  */
36 /*   Twitter Addon for Friendica
37  *
38  *   Author: Tobias Diekershoff
39  *           tobias.diekershoff@gmx.net
40  *
41  *   License:3-clause BSD license
42  *
43  *   Configuration:
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
46  *
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.
50  *
51  *     Add this key pair to your global config/addon.config.php or use the admin panel.
52  *
53  *      'twitter' => [
54  *                  'consumerkey' => '',
55  *              'consumersecret' => '',
56  *      ],
57  *
58  *     To activate the addon itself add it to the system.addon
59  *     setting. After this, your user can configure their Twitter account settings
60  *     from "Settings -> Addon Settings".
61  *
62  *     Requirements: PHP5, curl
63  */
64
65 use Abraham\TwitterOAuth\TwitterOAuth;
66 use Abraham\TwitterOAuth\TwitterOAuthException;
67 use Codebird\Codebird;
68 use Friendica\App;
69 use Friendica\Content\OEmbed;
70 use Friendica\Content\PageInfo;
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;
79 use Friendica\DI;
80 use Friendica\Model\Contact;
81 use Friendica\Model\Conversation;
82 use Friendica\Model\Group;
83 use Friendica\Model\Item;
84 use Friendica\Model\ItemContent;
85 use Friendica\Model\ItemURI;
86 use Friendica\Model\Tag;
87 use Friendica\Model\User;
88 use Friendica\Protocol\Activity;
89 use Friendica\Util\ConfigFileLoader;
90 use Friendica\Util\DateTimeFormat;
91 use Friendica\Util\Images;
92 use Friendica\Util\Strings;
93
94 require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
95
96 define('TWITTER_DEFAULT_POLL_INTERVAL', 5); // given in minutes
97
98 function twitter_install()
99 {
100         //  we need some hooks, for the configuration and for sending tweets
101         Hook::register('load_config'            , __FILE__, 'twitter_load_config');
102         Hook::register('connector_settings'     , __FILE__, 'twitter_settings');
103         Hook::register('connector_settings_post', __FILE__, 'twitter_settings_post');
104         Hook::register('hook_fork'              , __FILE__, 'twitter_hook_fork');
105         Hook::register('post_local'             , __FILE__, 'twitter_post_local');
106         Hook::register('notifier_normal'        , __FILE__, 'twitter_post_hook');
107         Hook::register('jot_networks'           , __FILE__, 'twitter_jot_nets');
108         Hook::register('cron'                   , __FILE__, 'twitter_cron');
109         Hook::register('follow'                 , __FILE__, 'twitter_follow');
110         Hook::register('expire'                 , __FILE__, 'twitter_expire');
111         Hook::register('prepare_body'           , __FILE__, 'twitter_prepare_body');
112         Hook::register('check_item_notification', __FILE__, 'twitter_check_item_notification');
113         Hook::register('probe_detect'           , __FILE__, 'twitter_probe_detect');
114         Hook::register('parse_link'             , __FILE__, 'twitter_parse_link');
115         Logger::info("installed twitter");
116 }
117
118 // Hook functions
119
120 function twitter_load_config(App $a, ConfigFileLoader $loader)
121 {
122         $a->getConfigCache()->load($loader->loadAddonConfig('twitter'));
123 }
124
125 function twitter_check_item_notification(App $a, array &$notification_data)
126 {
127         $own_id = DI::pConfig()->get($notification_data["uid"], 'twitter', 'own_id');
128
129         $own_user = q("SELECT `url` FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
130                         intval($notification_data["uid"]),
131                         DBA::escape("twitter::".$own_id)
132         );
133
134         if ($own_user) {
135                 $notification_data["profiles"][] = $own_user[0]["url"];
136         }
137 }
138
139 function twitter_follow(App $a, array &$contact)
140 {
141         Logger::info('Check if contact is twitter contact', ['url' => $contact["url"]]);
142
143         if (!strstr($contact["url"], "://twitter.com") && !strstr($contact["url"], "@twitter.com")) {
144                 return;
145         }
146
147         // contact seems to be a twitter contact, so continue
148         $nickname = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $contact["url"]);
149         $nickname = str_replace("@twitter.com", "", $nickname);
150
151         $uid = $a->user["uid"];
152
153         $ckey = DI::config()->get('twitter', 'consumerkey');
154         $csecret = DI::config()->get('twitter', 'consumersecret');
155         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
156         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
157
158         // If the addon is not configured (general or for this user) quit here
159         if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
160                 $contact = false;
161                 return;
162         }
163
164         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
165         $connection->post('friendships/create', ['screen_name' => $nickname]);
166
167         $user = twitter_fetchuser($nickname);
168
169         $contact_id = twitter_fetch_contact($uid, $user, true);
170
171         $contact = Contact::getById($contact_id, ['name', 'nick', 'url', 'addr', 'batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'photo', 'priority', 'network', 'alias', 'pubkey']);
172
173         if (DBA::isResult($contact)) {
174                 $contact["contact"] = $contact;
175         }
176 }
177
178 function twitter_jot_nets(App $a, array &$jotnets_fields)
179 {
180         if (!local_user()) {
181                 return;
182         }
183
184         if (DI::pConfig()->get(local_user(), 'twitter', 'post')) {
185                 $jotnets_fields[] = [
186                         'type' => 'checkbox',
187                         'field' => [
188                                 'twitter_enable',
189                                 DI::l10n()->t('Post to Twitter'),
190                                 DI::pConfig()->get(local_user(), 'twitter', 'post_by_default')
191                         ]
192                 ];
193         }
194 }
195
196
197 function twitter_settings_post(App $a)
198 {
199         if (!local_user()) {
200                 return;
201         }
202         // don't check twitter settings if twitter submit button is not clicked
203         if (empty($_POST['twitter-disconnect']) && empty($_POST['twitter-submit'])) {
204                 return;
205         }
206
207         if (!empty($_POST['twitter-disconnect'])) {
208                 /*               * *
209                  * if the twitter-disconnect checkbox is set, clear the OAuth key/secret pair
210                  * from the user configuration
211                  */
212                 DI::pConfig()->delete(local_user(), 'twitter', 'consumerkey');
213                 DI::pConfig()->delete(local_user(), 'twitter', 'consumersecret');
214                 DI::pConfig()->delete(local_user(), 'twitter', 'oauthtoken');
215                 DI::pConfig()->delete(local_user(), 'twitter', 'oauthsecret');
216                 DI::pConfig()->delete(local_user(), 'twitter', 'post');
217                 DI::pConfig()->delete(local_user(), 'twitter', 'post_by_default');
218                 DI::pConfig()->delete(local_user(), 'twitter', 'lastid');
219                 DI::pConfig()->delete(local_user(), 'twitter', 'mirror_posts');
220                 DI::pConfig()->delete(local_user(), 'twitter', 'import');
221                 DI::pConfig()->delete(local_user(), 'twitter', 'create_user');
222                 DI::pConfig()->delete(local_user(), 'twitter', 'own_id');
223         } else {
224                 if (isset($_POST['twitter-pin'])) {
225                         //  if the user supplied us with a PIN from Twitter, let the magic of OAuth happen
226                         Logger::notice('got a Twitter PIN');
227                         $ckey    = DI::config()->get('twitter', 'consumerkey');
228                         $csecret = DI::config()->get('twitter', 'consumersecret');
229                         //  the token and secret for which the PIN was generated were hidden in the settings
230                         //  form as token and token2, we need a new connection to Twitter using these token
231                         //  and secret to request a Access Token with the PIN
232                         try {
233                                 if (empty($_POST['twitter-pin'])) {
234                                         throw new Exception(DI::l10n()->t('You submitted an empty PIN, please Sign In with Twitter again to get a new one.'));
235                                 }
236
237                                 $connection = new TwitterOAuth($ckey, $csecret, $_POST['twitter-token'], $_POST['twitter-token2']);
238                                 $token = $connection->oauth("oauth/access_token", ["oauth_verifier" => $_POST['twitter-pin']]);
239                                 //  ok, now that we have the Access Token, save them in the user config
240                                 DI::pConfig()->set(local_user(), 'twitter', 'oauthtoken', $token['oauth_token']);
241                                 DI::pConfig()->set(local_user(), 'twitter', 'oauthsecret', $token['oauth_token_secret']);
242                                 DI::pConfig()->set(local_user(), 'twitter', 'post', 1);
243                         } catch(Exception $e) {
244                                 notice($e->getMessage());
245                         } catch(TwitterOAuthException $e) {
246                                 notice($e->getMessage());
247                         }
248                         //  reload the Addon Settings page, if we don't do it see Bug #42
249                         DI::baseUrl()->redirect('settings/connectors');
250                 } else {
251                         //  if no PIN is supplied in the POST variables, the user has changed the setting
252                         //  to post a tweet for every new __public__ posting to the wall
253                         DI::pConfig()->set(local_user(), 'twitter', 'post', intval($_POST['twitter-enable']));
254                         DI::pConfig()->set(local_user(), 'twitter', 'post_by_default', intval($_POST['twitter-default']));
255                         DI::pConfig()->set(local_user(), 'twitter', 'mirror_posts', intval($_POST['twitter-mirror']));
256                         DI::pConfig()->set(local_user(), 'twitter', 'import', intval($_POST['twitter-import']));
257                         DI::pConfig()->set(local_user(), 'twitter', 'create_user', intval($_POST['twitter-create_user']));
258
259                         if (!intval($_POST['twitter-mirror'])) {
260                                 DI::pConfig()->delete(local_user(), 'twitter', 'lastid');
261                         }
262                 }
263         }
264 }
265
266 function twitter_settings(App $a, &$s)
267 {
268         if (!local_user()) {
269                 return;
270         }
271         DI::page()['htmlhead'] .= '<link rel="stylesheet"  type="text/css" href="' . DI::baseUrl()->get() . '/addon/twitter/twitter.css' . '" media="all" />' . "\r\n";
272         /*       * *
273          * 1) Check that we have global consumer key & secret
274          * 2) If no OAuthtoken & stuff is present, generate button to get some
275          * 3) Checkbox for "Send public notices (280 chars only)
276          */
277         $ckey    = DI::config()->get('twitter', 'consumerkey');
278         $csecret = DI::config()->get('twitter', 'consumersecret');
279         $otoken  = DI::pConfig()->get(local_user(), 'twitter', 'oauthtoken');
280         $osecret = DI::pConfig()->get(local_user(), 'twitter', 'oauthsecret');
281
282         $enabled            = intval(DI::pConfig()->get(local_user(), 'twitter', 'post'));
283         $defenabled         = intval(DI::pConfig()->get(local_user(), 'twitter', 'post_by_default'));
284         $mirrorenabled      = intval(DI::pConfig()->get(local_user(), 'twitter', 'mirror_posts'));
285         $importenabled      = intval(DI::pConfig()->get(local_user(), 'twitter', 'import'));
286         $create_userenabled = intval(DI::pConfig()->get(local_user(), 'twitter', 'create_user'));
287
288         $css = (($enabled) ? '' : '-disabled');
289
290         $s .= '<span id="settings_twitter_inflated" class="settings-block fakelink" style="display: block;" onclick="openClose(\'settings_twitter_expanded\'); openClose(\'settings_twitter_inflated\');">';
291         $s .= '<img class="connector' . $css . '" src="images/twitter.png" /><h3 class="connector">' . DI::l10n()->t('Twitter Import/Export/Mirror') . '</h3>';
292         $s .= '</span>';
293         $s .= '<div id="settings_twitter_expanded" class="settings-block" style="display: none;">';
294         $s .= '<span class="fakelink" onclick="openClose(\'settings_twitter_expanded\'); openClose(\'settings_twitter_inflated\');">';
295         $s .= '<img class="connector' . $css . '" src="images/twitter.png" /><h3 class="connector">' . DI::l10n()->t('Twitter Import/Export/Mirror') . '</h3>';
296         $s .= '</span>';
297
298         if ((!$ckey) && (!$csecret)) {
299                 /* no global consumer keys
300                  * display warning and skip personal config
301                  */
302                 $s .= '<p>' . DI::l10n()->t('No consumer key pair for Twitter found. Please contact your site administrator.') . '</p>';
303         } else {
304                 // ok we have a consumer key pair now look into the OAuth stuff
305                 if ((!$otoken) && (!$osecret)) {
306                         /* the user has not yet connected the account to twitter...
307                          * get a temporary OAuth key/secret pair and display a button with
308                          * which the user can request a PIN to connect the account to a
309                          * account at Twitter.
310                          */
311                         $connection = new TwitterOAuth($ckey, $csecret);
312                         try {
313                                 $result = $connection->oauth('oauth/request_token', ['oauth_callback' => 'oob']);
314                                 $s .= '<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>';
315                                 $s .= '<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>';
316                                 $s .= '<div id="twitter-pin-wrapper">';
317                                 $s .= '<label id="twitter-pin-label" for="twitter-pin">' . DI::l10n()->t('Copy the PIN from Twitter here') . '</label>';
318                                 $s .= '<input id="twitter-pin" type="text" name="twitter-pin" />';
319                                 $s .= '<input id="twitter-token" type="hidden" name="twitter-token" value="' . $result['oauth_token'] . '" />';
320                                 $s .= '<input id="twitter-token2" type="hidden" name="twitter-token2" value="' . $result['oauth_token_secret'] . '" />';
321                                 $s .= '</div><div class="clear"></div>';
322                                 $s .= '<div class="settings-submit-wrapper" ><input type="submit" name="twitter-submit" class="settings-submit" value="' . DI::l10n()->t('Save Settings') . '" /></div>';
323                         } catch (TwitterOAuthException $e) {
324                                 $s .= '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
325                         }
326                 } else {
327                         /*                       * *
328                          *  we have an OAuth key / secret pair for the user
329                          *  so let's give a chance to disable the postings to Twitter
330                          */
331                         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
332                         try {
333                                 $details = $connection->get('account/verify_credentials');
334
335                                 $field_checkbox = Renderer::getMarkupTemplate('field_checkbox.tpl');
336
337                                 if (property_exists($details, 'screen_name') &&
338                                     property_exists($details, 'description') &&
339                                     property_exists($details, 'profile_image_url')) {
340                                         $s .= '<div id="twitter-info" >
341                                         <p>' . DI::l10n()->t('Currently connected to: ') . '<a href="https://twitter.com/' . $details->screen_name . '" target="_twitter">' . $details->screen_name . '</a>
342                                                 <button type="submit" name="twitter-disconnect" value="1">' . DI::l10n()->t('Disconnect') . '</button>
343                                         </p>
344                                         <p id="twitter-info-block">
345                                                 <a href="https://twitter.com/' . $details->screen_name . '" target="_twitter"><img id="twitter-avatar" src="' . $details->profile_image_url . '" /></a>
346                                                 <em>' . $details->description . '</em>
347                                         </p>
348                                 </div>';
349                                 } else {
350                                         $s .= '<div id="twitter-info" >
351                                         <p>Invalid Twitter info</p>
352                                         <button type="submit" name="twitter-disconnect" value="1">' . DI::l10n()->t('Disconnect') . '</button>
353                                         </div>';
354                                         Logger::info('Invalid twitter info (verify credentials).', ['auth' => TwitterOAuth::class]);
355                                 }
356                                 $s .= '<div class="clear"></div>';
357
358                                 $s .= Renderer::replaceMacros($field_checkbox, [
359                                         '$field' => ['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.')]
360                                 ]);
361                                 if ($a->user['hidewall']) {
362                                         $s .= '<p>' . 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.') . '</p>';
363                                 }
364                                 $s .= Renderer::replaceMacros($field_checkbox, [
365                                         '$field' => ['twitter-default', DI::l10n()->t('Send public postings to Twitter by default'), $defenabled, '']
366                                 ]);
367                                 $s .= Renderer::replaceMacros($field_checkbox, [
368                                         '$field' => ['twitter-mirror', DI::l10n()->t('Mirror all posts from twitter that are no replies'), $mirrorenabled, '']
369                                 ]);
370                                 $s .= Renderer::replaceMacros($field_checkbox, [
371                                         '$field' => ['twitter-import', DI::l10n()->t('Import the remote timeline'), $importenabled, '']
372                                 ]);
373                                 $s .= Renderer::replaceMacros($field_checkbox, [
374                                         '$field' => ['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. However if enabled, you cannot merely remove a twitter contact from the Friendica contact list, as it will recreate this contact when they post again.')]
375                                 ]);
376                                 $s .= '<div class="clear"></div>';
377                                 $s .= '<div class="settings-submit-wrapper" ><input type="submit" name="twitter-submit" class="settings-submit" value="' . DI::l10n()->t('Save Settings') . '" /></div>';
378                         } catch (TwitterOAuthException $e) {
379                                 $s .= '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
380                         }
381                 }
382         }
383         $s .= '</div><div class="clear"></div>';
384 }
385
386 function twitter_hook_fork(App $a, array &$b)
387 {
388         if ($b['name'] != 'notifier_normal') {
389                 return;
390         }
391
392         $post = $b['data'];
393
394         // Deleting and editing is not supported by the addon (deleting could, but isn't by now)
395         if ($post['deleted'] || ($post['created'] !== $post['edited'])) {
396                 $b['execute'] = false;
397                 return;
398         }
399
400         // if post comes from twitter don't send it back
401         if ($post['extid'] == Protocol::TWITTER) {
402                 $b['execute'] = false;
403                 return;
404         }
405
406         if ($post['app'] == 'Twitter') {
407                 $b['execute'] = false;
408                 return;
409         }
410
411         if (DI::pConfig()->get($post['uid'], 'twitter', 'import')) {
412                 // Don't fork if it isn't a reply to a twitter post
413                 if (($post['parent'] != $post['id']) && !Item::exists(['id' => $post['parent'], 'network' => Protocol::TWITTER])) {
414                         Logger::notice('No twitter parent found', ['item' => $post['id']]);
415                         $b['execute'] = false;
416                         return;
417                 }
418         } else {
419                 // Comments are never exported when we don't import the twitter timeline
420                 if (!strstr($post['postopts'], 'twitter') || ($post['parent'] != $post['id']) || $post['private']) {
421                         $b['execute'] = false;
422                         return;
423                 }
424         }
425 }
426
427 function twitter_post_local(App $a, array &$b)
428 {
429         if ($b['edit']) {
430                 return;
431         }
432
433         if (!local_user() || (local_user() != $b['uid'])) {
434                 return;
435         }
436
437         $twitter_post = intval(DI::pConfig()->get(local_user(), 'twitter', 'post'));
438         $twitter_enable = (($twitter_post && !empty($_REQUEST['twitter_enable'])) ? intval($_REQUEST['twitter_enable']) : 0);
439
440         // if API is used, default to the chosen settings
441         if ($b['api_source'] && intval(DI::pConfig()->get(local_user(), 'twitter', 'post_by_default'))) {
442                 $twitter_enable = 1;
443         }
444
445         if (!$twitter_enable) {
446                 return;
447         }
448
449         if (strlen($b['postopts'])) {
450                 $b['postopts'] .= ',';
451         }
452
453         $b['postopts'] .= 'twitter';
454 }
455
456 function twitter_probe_detect(App $a, array &$hookData)
457 {
458         // Don't overwrite an existing result
459         if ($hookData['result']) {
460                 return;
461         }
462
463         // Avoid a lookup for the wrong network
464         if (!in_array($hookData['network'], ['', Protocol::TWITTER])) {
465                 return;
466         }
467
468         if (preg_match('=([^@]+)@(?:mobile\.)?twitter\.com$=i', $hookData['uri'], $matches)) {
469                 $nick = $matches[1];
470         } elseif (preg_match('=^https?://(?:mobile\.)?twitter\.com/(.+)=i', $hookData['uri'], $matches)) {
471                 $nick = $matches[1];
472         } else {
473                 return;
474         }
475
476         $user = twitter_fetchuser($nick);
477
478         if ($user) {
479                 $hookData['result'] = twitter_user_to_contact($user);
480         }
481 }
482
483 function twitter_action(App $a, $uid, $pid, $action)
484 {
485         $ckey = DI::config()->get('twitter', 'consumerkey');
486         $csecret = DI::config()->get('twitter', 'consumersecret');
487         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
488         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
489
490         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
491
492         $post = ['id' => $pid];
493
494         Logger::debug('before action', ['action' => $action, 'pid' => $pid, 'data' => $post]);
495
496         switch ($action) {
497                 case 'delete':
498                         // To-Do: $result = $connection->post('statuses/destroy', $post);
499                         $result = [];
500                         break;
501                 case 'like':
502                         $result = $connection->post('favorites/create', $post);
503                         if ($connection->getLastHttpCode() != 200) {
504                                 Logger::error('Unable to create favorite', ['result' => $result]);
505                         }
506                         break;
507                 case 'unlike':
508                         $result = $connection->post('favorites/destroy', $post);
509                         if ($connection->getLastHttpCode() != 200) {
510                                 Logger::error('Unable to destroy favorite', ['result' => $result]);
511                         }
512                         break;
513                 default:
514                         Logger::warning('Unhandled action', ['action' => $action]);
515                         $result = [];
516         }
517
518         Logger::info('after action', ['action' => $action, 'result' => $result]);
519 }
520
521 function twitter_post_hook(App $a, array &$b)
522 {
523         // Post to Twitter
524         if (!DI::pConfig()->get($b["uid"], 'twitter', 'import')
525                 && ($b['deleted'] || $b['private'] || ($b['created'] !== $b['edited']))) {
526                 return;
527         }
528
529         if ($b['parent'] != $b['id']) {
530                 Logger::log("twitter_post_hook: parameter " . print_r($b, true), Logger::DATA);
531
532                 // Looking if its a reply to a twitter post
533                 if ((substr($b["parent-uri"], 0, 9) != "twitter::")
534                         && (substr($b["extid"], 0, 9) != "twitter::")
535                         && (substr($b["thr-parent"], 0, 9) != "twitter::"))
536                 {
537                         Logger::log("twitter_post_hook: no twitter post " . $b["parent"]);
538                         return;
539                 }
540
541                 $condition = ['uri' => $b["thr-parent"], 'uid' => $b["uid"]];
542                 $orig_post = Item::selectFirst([], $condition);
543                 if (!DBA::isResult($orig_post)) {
544                         Logger::log("twitter_post_hook: no parent found " . $b["thr-parent"]);
545                         return;
546                 } else {
547                         $iscomment = true;
548                 }
549
550
551                 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post["author-link"]);
552                 $nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
553                 $nicknameplain = "@" . $nicknameplain;
554
555                 Logger::log("twitter_post_hook: comparing " . $nickname . " and " . $nicknameplain . " with " . $b["body"], Logger::DEBUG);
556                 if ((strpos($b["body"], $nickname) === false) && (strpos($b["body"], $nicknameplain) === false)) {
557                         $b["body"] = $nickname . " " . $b["body"];
558                 }
559
560                 Logger::log("twitter_post_hook: parent found " . print_r($orig_post, true), Logger::DATA);
561         } else {
562                 $iscomment = false;
563
564                 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
565                         return;
566                 }
567
568                 // Dont't post if the post doesn't belong to us.
569                 // This is a check for forum postings
570                 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
571                 if ($b['contact-id'] != $self['id']) {
572                         return;
573                 }
574         }
575
576         if (($b['verb'] == Activity::POST) && $b['deleted']) {
577                 twitter_action($a, $b["uid"], substr($orig_post["uri"], 9), "delete");
578         }
579
580         if ($b['verb'] == Activity::LIKE) {
581                 Logger::log("twitter_post_hook: parameter 2 " . substr($b["thr-parent"], 9), Logger::DEBUG);
582                 if ($b['deleted']) {
583                         twitter_action($a, $b["uid"], substr($b["thr-parent"], 9), "unlike");
584                 } else {
585                         twitter_action($a, $b["uid"], substr($b["thr-parent"], 9), "like");
586                 }
587
588                 return;
589         }
590
591         if ($b['verb'] == Activity::ANNOUNCE) {
592                 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => substr($b['thr-parent'], 9)]);
593                 if ($b['deleted']) {
594                         twitter_action($a, $b["uid"], substr($orig_post["extid"], 9), "delete");
595                 } else {
596                         twitter_retweet($b["uid"], substr($b["thr-parent"], 9));
597                 }
598
599                 return;
600         }
601
602         if ($b['deleted'] || ($b['created'] !== $b['edited'])) {
603                 return;
604         }
605
606         // if post comes from twitter don't send it back
607         if ($b['extid'] == Protocol::TWITTER) {
608                 return;
609         }
610
611         if ($b['app'] == "Twitter") {
612                 return;
613         }
614
615         Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
616
617         DI::pConfig()->load($b['uid'], 'twitter');
618
619         $ckey    = DI::config()->get('twitter', 'consumerkey');
620         $csecret = DI::config()->get('twitter', 'consumersecret');
621         $otoken  = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
622         $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
623
624         if ($ckey && $csecret && $otoken && $osecret) {
625                 Logger::log('twitter: we have customer key and oauth stuff, going to send.', Logger::DEBUG);
626
627                 // If it's a repeated message from twitter then do a native retweet and exit
628                 if (twitter_is_retweet($a, $b['uid'], $b['body'])) {
629                         return;
630                 }
631
632                 Codebird::setConsumerKey($ckey, $csecret);
633                 $cb = Codebird::getInstance();
634                 $cb->setToken($otoken, $osecret);
635
636                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
637
638                 // Set the timeout for upload to 30 seconds
639                 $connection->setTimeouts(10, 30);
640
641                 $max_char = 280;
642
643                 // Handling non-native reshares
644                 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
645                         $b['body'],
646                         function (array $attributes, array $author_contact, $content, $is_quote_share) {
647                                 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
648                         }
649                 );
650
651                 $b['body'] = twitter_update_mentions($b['body']);
652
653                 $msgarr = ItemContent::getPlaintextPost($b, $max_char, true, BBCode::TWITTER);
654                 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
655                 $msg = $msgarr["text"];
656
657                 if (($msg == "") && isset($msgarr["title"])) {
658                         $msg = Plaintext::shorten($msgarr["title"], $max_char - 50);
659                 }
660
661                 if (!empty($msgarr['url']) && ($msgarr['url'] == $b['plink']) && !empty($msgarr['images']) && (count($msgarr['images']) <= 4)) {
662                         $url_added = false;
663                 } elseif (isset($msgarr["url"]) && ($msgarr["type"] != "photo")) {
664                         $msg .= "\n" . $msgarr["url"];
665                         $url_added = true;
666                 } else {
667                         $url_added = false;
668                 }
669
670                 if (empty($msg)) {
671                         Logger::info('Empty message', ['id' => $b['id']]);
672                         return;
673                 }
674
675                 // and now tweet it :-)
676                 $post = [];
677
678                 if (!empty($msgarr['images'])) {
679                         Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images']]);
680                         try {
681                                 $media_ids = [];
682                                 foreach ($msgarr['images'] as $image) {
683                                         if (count($media_ids) == 4) {
684                                                 continue;
685                                         }
686
687                                         $img_str = DI::httpRequest()->fetch($image['url']);
688
689                                         $tempfile = tempnam(get_temppath(), 'cache');
690                                         file_put_contents($tempfile, $img_str);
691
692                                         Logger::info('Uploading', ['id' => $b['id'], 'image' => $image['url']]);
693                                         $media = $connection->upload('media/upload', ['media' => $tempfile]);
694
695                                         unlink($tempfile);
696
697                                         if (isset($media->media_id_string)) {
698                                                 $media_ids[] = $media->media_id_string;
699
700                                                 if (!empty($image['description'])) {
701                                                         $data = ['media_id' => $media->media_id_string,
702                                                                 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
703                                                         $ret = $cb->media_metadata_create($data);
704                                                         Logger::info('Metadata create', ['id' => $b['id'], 'data' => $data, 'return' => json_encode($ret)]);
705                                                 }
706                                         } else {
707                                                 Logger::error('Failed upload', ['id' => $b['id'], 'image' => $image['url']]);
708                                                 throw new Exception('Failed upload of ' . $image['url']);
709                                         }
710                                 }
711                                 $post['media_ids'] = implode(',', $media_ids);
712                                 if (empty($post['media_ids'])) {
713                                         unset($post['media_ids']);
714                                 }
715                         } catch (Exception $e) {
716                                 Logger::info('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
717                         }
718                 }
719
720                 $post['status'] = $msg;
721
722                 if ($iscomment) {
723                         $post["in_reply_to_status_id"] = substr($orig_post["uri"], 9);
724                 }
725
726                 $url = 'statuses/update';
727                 $result = $connection->post($url, $post);
728                 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
729
730                 if (!empty($result->source)) {
731                         DI::config()->set("twitter", "application_name", strip_tags($result->source));
732                 }
733
734                 if (!empty($result->errors)) {
735                         Logger::info('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
736                         Worker::defer();
737                 } elseif ($iscomment) {
738                         Logger::info('Update extid', ['id' => $b['id'], 'extid' => $result->id_str]);
739                         Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $b['id']]);
740                 }
741         }
742 }
743
744 function twitter_addon_admin_post(App $a)
745 {
746         $consumerkey    = !empty($_POST['consumerkey'])    ? Strings::escapeTags(trim($_POST['consumerkey']))    : '';
747         $consumersecret = !empty($_POST['consumersecret']) ? Strings::escapeTags(trim($_POST['consumersecret'])) : '';
748         DI::config()->set('twitter', 'consumerkey', $consumerkey);
749         DI::config()->set('twitter', 'consumersecret', $consumersecret);
750 }
751
752 function twitter_addon_admin(App $a, &$o)
753 {
754         $t = Renderer::getMarkupTemplate("admin.tpl", "addon/twitter/");
755
756         $o = Renderer::replaceMacros($t, [
757                 '$submit' => DI::l10n()->t('Save Settings'),
758                 // name, label, value, help, [extra values]
759                 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
760                 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
761         ]);
762 }
763
764 function twitter_cron(App $a)
765 {
766         $last = DI::config()->get('twitter', 'last_poll');
767
768         $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
769         if (!$poll_interval) {
770                 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
771         }
772
773         if ($last) {
774                 $next = $last + ($poll_interval * 60);
775                 if ($next > time()) {
776                         Logger::notice('twitter: poll intervall not reached');
777                         return;
778                 }
779         }
780         Logger::notice('twitter: cron_start');
781
782         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'mirror_posts' AND `v` = '1'");
783         if (DBA::isResult($r)) {
784                 foreach ($r as $rr) {
785                         Logger::notice('Fetching', ['user' => $rr['uid']]);
786                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 1, (int) $rr['uid']);
787                 }
788         }
789
790         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
791         if ($abandon_days < 1) {
792                 $abandon_days = 0;
793         }
794
795         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
796
797         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1'");
798         if (DBA::isResult($r)) {
799                 foreach ($r as $rr) {
800                         if ($abandon_days != 0) {
801                                 $user = q("SELECT `login_date` FROM `user` WHERE uid=%d AND `login_date` >= '%s'", $rr['uid'], $abandon_limit);
802                                 if (!DBA::isResult($user)) {
803                                         Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
804                                         continue;
805                                 }
806                         }
807
808                         Logger::notice('importing timeline', ['user' => $rr['uid']]);
809                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 2, (int) $rr['uid']);
810                         /*
811                           // To-Do
812                           // check for new contacts once a day
813                           $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
814                           if($last_contact_check)
815                           $next_contact_check = $last_contact_check + 86400;
816                           else
817                           $next_contact_check = 0;
818
819                           if($next_contact_check <= time()) {
820                           pumpio_getallusers($a, $rr["uid"]);
821                           DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
822                           }
823                          */
824                 }
825         }
826
827         Logger::notice('twitter: cron_end');
828
829         DI::config()->set('twitter', 'last_poll', time());
830 }
831
832 function twitter_expire(App $a)
833 {
834         $days = DI::config()->get('twitter', 'expire');
835
836         if ($days == 0) {
837                 return;
838         }
839
840         $r = Item::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
841         while ($row = DBA::fetch($r)) {
842                 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
843                 DBA::delete('item', ['id' => $row['id']]);
844         }
845         DBA::close($r);
846
847         Logger::notice('twitter_expire: expire_start');
848
849         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1' ORDER BY RAND()");
850         if (DBA::isResult($r)) {
851                 foreach ($r as $rr) {
852                         Logger::notice('twitter_expire', ['user' => $rr['uid']]);
853                         Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
854                 }
855         }
856
857         Logger::notice('twitter_expire: expire_end');
858 }
859
860 function twitter_prepare_body(App $a, array &$b)
861 {
862         if ($b["item"]["network"] != Protocol::TWITTER) {
863                 return;
864         }
865
866         if ($b["preview"]) {
867                 $max_char = 280;
868                 $item = $b["item"];
869                 $item["plink"] = DI::baseUrl()->get() . "/display/" . $item["guid"];
870
871                 $condition = ['uri' => $item["thr-parent"], 'uid' => local_user()];
872                 $orig_post = Item::selectFirst(['author-link'], $condition);
873                 if (DBA::isResult($orig_post)) {
874                         $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post["author-link"]);
875                         $nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
876                         $nicknameplain = "@" . $nicknameplain;
877
878                         if ((strpos($item["body"], $nickname) === false) && (strpos($item["body"], $nicknameplain) === false)) {
879                                 $item["body"] = $nickname . " " . $item["body"];
880                         }
881                 }
882
883                 $msgarr = ItemContent::getPlaintextPost($item, $max_char, true, BBCode::TWITTER);
884                 $msg = $msgarr["text"];
885
886                 if (isset($msgarr["url"]) && ($msgarr["type"] != "photo")) {
887                         $msg .= " " . $msgarr["url"];
888                 }
889
890                 if (isset($msgarr["image"])) {
891                         $msg .= " " . $msgarr["image"];
892                 }
893
894                 $b['html'] = nl2br(htmlspecialchars($msg));
895         }
896 }
897
898 /**
899  * Parse Twitter status URLs since Twitter removed OEmbed
900  *
901  * @param App   $a
902  * @param array $b Expected format:
903  *                 [
904  *                      'url' => [URL to parse],
905  *                      'format' => 'json'|'',
906  *                      'text' => Output parameter
907  *                 ]
908  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
909  */
910 function twitter_parse_link(App $a, array &$b)
911 {
912         // Only handle Twitter status URLs
913         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
914                 return;
915         }
916
917         $ckey = DI::config()->get('twitter', 'consumerkey');
918         $csecret = DI::config()->get('twitter', 'consumersecret');
919
920         if (empty($ckey) || empty($csecret)) {
921                 return;
922         }
923
924         $connection = new TwitterOAuth($ckey, $csecret);
925
926         $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $matches[1], 'include_ext_alt_text' => true];
927
928         $status = $connection->get('statuses/show', $parameters);
929
930         if (empty($status->id)) {
931                 return;
932         }
933
934         $item = twitter_createpost($a, 0, $status, [], true, false, true);
935
936         if ($b['format'] == 'json') {
937                 $images = [];
938                 foreach ($status->extended_entities->media ?? [] as $media) {
939                         if (!empty($media->media_url_https)) {
940                                 $images[] = [
941                                         'src'    => $media->media_url_https,
942                                         'width'  => $media->sizes->thumb->w,
943                                         'height' => $media->sizes->thumb->h,
944                                 ];
945                         }
946                 }
947
948                 $b['text'] = [
949                         'data' => [
950                                 'type' => 'link',
951                                 'url' => $item['plink'],
952                                 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
953                                 'text' => BBCode::toPlaintext($item['body'], false),
954                                 'images' => $images,
955                         ],
956                         'contentType' => 'attachment',
957                         'success' => true,
958                 ];
959         } else {
960                 $b['text'] = BBCode::getShareOpeningTag(
961                         $item['author-name'],
962                         $item['author-link'],
963                         $item['author-avatar'],
964                         $item['plink'],
965                         $item['created']
966                 );
967                 $b['text'] .= $item['body'] . '[/share]';
968         }
969 }
970
971
972 /*********************
973  *
974  * General functions
975  *
976  *********************/
977
978
979 /**
980  * @brief Build the item array for the mirrored post
981  *
982  * @param App $a Application class
983  * @param integer $uid User id
984  * @param object $post Twitter object with the post
985  *
986  * @return array item data to be posted
987  */
988 function twitter_do_mirrorpost(App $a, $uid, $post)
989 {
990         $datarray['api_source'] = true;
991         $datarray['profile_uid'] = $uid;
992         $datarray['extid'] = Protocol::TWITTER;
993         $datarray['message_id'] = Item::newURI($uid, Protocol::TWITTER . ':' . $post->id);
994         $datarray['protocol'] = Conversation::PARCEL_TWITTER;
995         $datarray['source'] = json_encode($post);
996         $datarray['title'] = '';
997
998         if (!empty($post->retweeted_status)) {
999                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1000                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1001
1002                 if (empty($item['body'])) {
1003                         return [];
1004                 }
1005
1006                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1007                         $item['author-name'],
1008                         $item['author-link'],
1009                         $item['author-avatar'],
1010                         $item['plink'],
1011                         $item['created']
1012                 );
1013
1014                 $datarray['body'] .= $item['body'] . '[/share]';
1015         } else {
1016                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1017
1018                 if (empty($item['body'])) {
1019                         return [];
1020                 }
1021
1022                 $datarray['body'] = $item['body'];
1023         }
1024
1025         $datarray['source'] = $item['app'];
1026         $datarray['verb'] = $item['verb'];
1027
1028         if (isset($item['location'])) {
1029                 $datarray['location'] = $item['location'];
1030         }
1031
1032         if (isset($item['coord'])) {
1033                 $datarray['coord'] = $item['coord'];
1034         }
1035
1036         return $datarray;
1037 }
1038
1039 function twitter_fetchtimeline(App $a, $uid)
1040 {
1041         $ckey    = DI::config()->get('twitter', 'consumerkey');
1042         $csecret = DI::config()->get('twitter', 'consumersecret');
1043         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1044         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1045         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1046
1047         $application_name = DI::config()->get('twitter', 'application_name');
1048
1049         if ($application_name == "") {
1050                 $application_name = DI::baseUrl()->getHostname();
1051         }
1052
1053         $has_picture = false;
1054
1055         require_once 'mod/item.php';
1056         require_once 'mod/share.php';
1057
1058         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1059
1060         $parameters = ["exclude_replies" => true, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1061
1062         $first_time = ($lastid == "");
1063
1064         if ($lastid != "") {
1065                 $parameters["since_id"] = $lastid;
1066         }
1067
1068         try {
1069                 $items = $connection->get('statuses/user_timeline', $parameters);
1070         } catch (TwitterOAuthException $e) {
1071                 Logger::notice('Error fetching timeline', ['user' => $uid, 'message' => $e->getMessage()]);
1072                 return;
1073         }
1074
1075         if (!is_array($items)) {
1076                 Logger::notice('No items', ['user' => $uid]);
1077                 return;
1078         }
1079
1080         $posts = array_reverse($items);
1081
1082         Logger::log('Starting from ID ' . $lastid . ' for user ' . $uid, Logger::DEBUG);
1083
1084         if (count($posts)) {
1085                 foreach ($posts as $post) {
1086                         if ($post->id_str > $lastid) {
1087                                 $lastid = $post->id_str;
1088                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1089                         }
1090
1091                         if ($first_time) {
1092                                 continue;
1093                         }
1094
1095                         if (!stristr($post->source, $application_name)) {
1096                                 $_SESSION["authenticated"] = true;
1097                                 $_SESSION["uid"] = $uid;
1098
1099                                 Logger::log('Preparing Twitter ID ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1100
1101                                 $_REQUEST = twitter_do_mirrorpost($a, $uid, $post);
1102
1103                                 if (empty($_REQUEST['body'])) {
1104                                         continue;
1105                                 }
1106
1107                                 Logger::log('Posting Twitter ID ' . $post->id_str . ' for user ' . $uid);
1108
1109                                 item_post($a);
1110                         }
1111                 }
1112         }
1113         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1114         Logger::log('Last ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1115 }
1116
1117 function twitter_fix_avatar($avatar)
1118 {
1119         $new_avatar = str_replace("_normal.", ".", $avatar);
1120
1121         $info = Images::getInfoFromURLCached($new_avatar);
1122         if (!$info) {
1123                 $new_avatar = $avatar;
1124         }
1125
1126         return $new_avatar;
1127 }
1128
1129 function twitter_get_relation($uid, $target, $contact = [])
1130 {
1131         if (isset($contact['rel'])) {
1132                 $relation = $contact['rel'];
1133         } else {
1134                 $relation = 0;
1135         }
1136
1137         $ckey = DI::config()->get('twitter', 'consumerkey');
1138         $csecret = DI::config()->get('twitter', 'consumersecret');
1139         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1140         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1141         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1142
1143         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1144         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1145
1146         try {
1147                 $status = $connection->get('friendships/show', $parameters);
1148                 if ($connection->getLastHttpCode() !== 200) {
1149                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1150                 }
1151
1152                 $following = $status->relationship->source->following;
1153                 $followed = $status->relationship->source->followed_by;
1154
1155                 if ($following && !$followed) {
1156                         $relation = Contact::SHARING;
1157                 } elseif (!$following && $followed) {
1158                         $relation = Contact::FOLLOWER;
1159                 } elseif ($following && $followed) {
1160                         $relation = Contact::FRIEND;
1161                 } elseif (!$following && !$followed) {
1162                         $relation = 0;
1163                 }
1164
1165                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1166         } catch (Throwable $e) {
1167                 Logger::error('Error fetching friendship status', ['user' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1168         }
1169
1170         return $relation;
1171 }
1172
1173 /**
1174  * @param $data
1175  * @return array
1176  */
1177 function twitter_user_to_contact($data)
1178 {
1179         if (empty($data->id_str)) {
1180                 return [];
1181         }
1182
1183         $baseurl = 'https://twitter.com';
1184         $url = $baseurl . '/' . $data->screen_name;
1185         $addr = $data->screen_name . '@twitter.com';
1186
1187         $fields = [
1188                 'url'      => $url,
1189                 'network'  => Protocol::TWITTER,
1190                 'alias'    => 'twitter::' . $data->id_str,
1191                 'baseurl'  => $baseurl,
1192                 'name'     => $data->name,
1193                 'nick'     => $data->screen_name,
1194                 'addr'     => $addr,
1195                 'location' => $data->location,
1196                 'about'    => $data->description,
1197                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1198         ];
1199
1200         return $fields;
1201 }
1202
1203 function twitter_fetch_contact($uid, $data, $create_user)
1204 {
1205         $fields = twitter_user_to_contact($data);
1206
1207         if (empty($fields)) {
1208                 return -1;
1209         }
1210
1211         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1212         $avatar = $fields['photo'];
1213         unset($fields['photo']);
1214
1215         // Update the public contact
1216         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => "twitter::" . $data->id_str]);
1217         if (DBA::isResult($pcontact)) {
1218                 $cid = $pcontact['id'];
1219         } else {
1220                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1221         }
1222
1223         if (!empty($cid)) {
1224                 DBA::update('contact', $fields, ['id' => $cid]);
1225                 Contact::updateAvatar($cid, $avatar);
1226         }
1227
1228         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => "twitter::" . $data->id_str]);
1229         if (!DBA::isResult($contact) && !$create_user) {
1230                 Logger::info('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1231                 return 0;
1232         }
1233
1234         if (!DBA::isResult($contact)) {
1235                 $relation = twitter_get_relation($uid, $data->screen_name);
1236
1237                 // create contact record
1238                 $fields['uid'] = $uid;
1239                 $fields['created'] = DateTimeFormat::utcNow();
1240                 $fields['nurl'] = Strings::normaliseLink($fields['url']);
1241                 $fields['poll'] = 'twitter::' . $data->id_str;
1242                 $fields['rel'] = $relation;
1243                 $fields['priority'] = 1;
1244                 $fields['writable'] = true;
1245                 $fields['blocked'] = false;
1246                 $fields['readonly'] = false;
1247                 $fields['pending'] = false;
1248
1249                 if (!DBA::insert('contact', $fields)) {
1250                         return false;
1251                 }
1252
1253                 $contact_id = DBA::lastInsertId();
1254
1255                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1256
1257                 Contact::updateAvatar($contact_id, $avatar);
1258         } else {
1259                 if ($contact["readonly"] || $contact["blocked"]) {
1260                         Logger::log("twitter_fetch_contact: Contact '" . $contact["nick"] . "' is blocked or readonly.", Logger::DEBUG);
1261                         return -1;
1262                 }
1263
1264                 $contact_id = $contact['id'];
1265                 $update = false;
1266
1267                 // Update the contact relation once per day
1268                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1269                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1270                         $update = true;
1271                 }
1272
1273                 Contact::updateAvatar($contact['id'], $avatar);
1274
1275                 if ($contact['name'] != $data->name) {
1276                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1277                         $update = true;
1278                 }
1279
1280                 if ($contact['nick'] != $data->screen_name) {
1281                         $fields['uri-date'] = DateTimeFormat::utcNow();
1282                         $update = true;
1283                 }
1284
1285                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1286                         $update = true;
1287                 }
1288
1289                 if ($update) {
1290                         $fields['updated'] = DateTimeFormat::utcNow();
1291                         DBA::update('contact', $fields, ['id' => $contact['id']]);
1292                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1293                 }
1294         }
1295
1296         return $contact_id;
1297 }
1298
1299 /**
1300  * @param string $screen_name
1301  * @return stdClass|null
1302  * @throws Exception
1303  */
1304 function twitter_fetchuser($screen_name)
1305 {
1306         $ckey = DI::config()->get('twitter', 'consumerkey');
1307         $csecret = DI::config()->get('twitter', 'consumersecret');
1308
1309         try {
1310                 // Fetching user data
1311                 $connection = new TwitterOAuth($ckey, $csecret);
1312                 $parameters = ['screen_name' => $screen_name];
1313                 $user = $connection->get('users/show', $parameters);
1314         } catch (TwitterOAuthException $e) {
1315                 Logger::log('twitter_fetchuser: Error fetching user ' . $screen_name . ': ' . $e->getMessage());
1316                 return null;
1317         }
1318
1319         if (!is_object($user)) {
1320                 return null;
1321         }
1322
1323         return $user;
1324 }
1325
1326 /**
1327  * Replaces Twitter entities with Friendica-friendly links.
1328  *
1329  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1330  *
1331  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1332  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1333  * index to be correct even after the last replacement.
1334  *
1335  * @param string   $body
1336  * @param stdClass $status
1337  * @param string   $picture
1338  * @return array
1339  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1340  */
1341 function twitter_expand_entities($body, stdClass $status, $picture)
1342 {
1343         $plain = $body;
1344
1345         $taglist = [];
1346
1347         $replacementList = [];
1348
1349         foreach ($status->entities->hashtags AS $hashtag) {
1350                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1351                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1352
1353                 $replacementList[$hashtag->indices[0]] = [
1354                         'replace' => $replace,
1355                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1356                 ];
1357         }
1358
1359         foreach ($status->entities->user_mentions AS $mention) {
1360                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1361                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1362
1363                 $replacementList[$mention->indices[0]] = [
1364                         'replace' => $replace,
1365                         'length' => $mention->indices[1] - $mention->indices[0],
1366                 ];
1367         }
1368
1369         // This URL if set will be used to add an attachment at the bottom of the post
1370         $attachmentUrl = '';
1371
1372         foreach ($status->entities->urls ?? [] as $url) {
1373                 $plain = str_replace($url->url, '', $plain);
1374
1375                 if ($url->url && $url->expanded_url && $url->display_url) {
1376
1377                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1378                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1379                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1380                         ) {
1381                                 $replacementList[$url->indices[0]] = [
1382                                         'replace' => '',
1383                                         'length' => $url->indices[1] - $url->indices[0],
1384                                 ];
1385                                 continue;
1386                         }
1387
1388                         $expanded_url = $url->expanded_url;
1389
1390                         $final_url = DI::httpRequest()->finalUrl($url->expanded_url);
1391
1392                         $oembed_data = OEmbed::fetchURL($final_url);
1393
1394                         if (empty($oembed_data) || empty($oembed_data->type)) {
1395                                 continue;
1396                         }
1397
1398                         // Quickfix: Workaround for URL with '[' and ']' in it
1399                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1400                                 $expanded_url = $url->url;
1401                         }
1402
1403                         if ($oembed_data->type == 'video') {
1404                                 $attachmentUrl = $expanded_url;
1405                                 $replace = '';
1406                         } elseif (($oembed_data->type == 'photo') && isset($oembed_data->url)) {
1407                                 $replace = '[url=' . $expanded_url . '][img]' . $oembed_data->url . '[/img][/url]';
1408                         } elseif ($oembed_data->type != 'link') {
1409                                 $replace = '[url=' . $expanded_url . ']' . $url->display_url . '[/url]';
1410                         } else {
1411                                 $img_str = DI::httpRequest()->fetch($final_url, 4);
1412
1413                                 $tempfile = tempnam(get_temppath(), 'cache');
1414                                 file_put_contents($tempfile, $img_str);
1415
1416                                 // See http://php.net/manual/en/function.exif-imagetype.php#79283
1417                                 if (filesize($tempfile) > 11) {
1418                                         $mime = image_type_to_mime_type(exif_imagetype($tempfile));
1419                                 } else {
1420                                         $mime = false;
1421                                 }
1422
1423                                 unlink($tempfile);
1424
1425                                 if (substr($mime, 0, 6) == 'image/') {
1426                                         $replace = '[img]' . $final_url . '[/img]';
1427                                 } else {
1428                                         $attachmentUrl = $expanded_url;
1429                                         $replace = '';
1430                                 }
1431                         }
1432
1433                         $replacementList[$url->indices[0]] = [
1434                                 'replace' => $replace,
1435                                 'length' => $url->indices[1] - $url->indices[0],
1436                         ];
1437                 }
1438         }
1439
1440         krsort($replacementList);
1441
1442         foreach ($replacementList as $startIndex => $parameters) {
1443                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1444         }
1445
1446         // Footer will be taken care of with a share block in the case of a quote
1447         if (empty($status->quoted_status)) {
1448                 $footer = '';
1449                 if ($attachmentUrl) {
1450                         $footer = "\n" . PageInfo::getFooterFromUrl($attachmentUrl, false, $picture);
1451                 }
1452
1453                 if (trim($footer)) {
1454                         $body .= $footer;
1455                 } elseif ($picture) {
1456                         $body .= "\n\n[img]" . $picture . "[/img]\n";
1457                 } else {
1458                         $body = PageInfo::searchAndAppendToBody($body);
1459                 }
1460         }
1461
1462         return ['body' => $body, 'plain' => trim($plain), 'taglist' => $taglist];
1463 }
1464
1465 /**
1466  * @brief Fetch media entities and add media links to the body
1467  *
1468  * @param object $post Twitter object with the post
1469  * @param array $postarray Array of the item that is about to be posted
1470  *
1471  * @return $picture string Image URL or empty string
1472  */
1473 function twitter_media_entities($post, array &$postarray)
1474 {
1475         // There are no media entities? So we quit.
1476         if (empty($post->extended_entities->media)) {
1477                 return '';
1478         }
1479
1480         // When the post links to an external page, we only take one picture.
1481         // We only do this when there is exactly one media.
1482         if ((count($post->entities->urls) > 0) && (count($post->extended_entities->media) == 1)) {
1483                 $medium = $post->extended_entities->media[0];
1484                 $picture = '';
1485                 foreach ($post->entities->urls as $link) {
1486                         // Let's make sure the external link url matches the media url
1487                         if ($medium->url == $link->url && isset($medium->media_url_https)) {
1488                                 $picture = $medium->media_url_https;
1489                                 $postarray['body'] = str_replace($medium->url, '', $postarray['body']);
1490                                 return $picture;
1491                         }
1492                 }
1493         }
1494
1495
1496
1497         // This is a pure media post, first search for all media urls
1498         $media = [];
1499         foreach ($post->extended_entities->media AS $medium) {
1500                 if (!isset($media[$medium->url])) {
1501                         $media[$medium->url] = '';
1502                 }
1503                 switch ($medium->type) {
1504                         case 'photo':
1505                                 if (!empty($medium->ext_alt_text)) {
1506                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1507                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1508                                 } else {
1509                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1510                                 }
1511
1512                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1513                                 break;
1514                         case 'video':
1515                         case 'animated_gif':
1516                                 if (!empty($medium->ext_alt_text)) {
1517                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1518                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1519                                 } else {
1520                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1521                                 }
1522
1523                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1524                                 if (is_array($medium->video_info->variants)) {
1525                                         $bitrate = 0;
1526                                         // We take the video with the highest bitrate
1527                                         foreach ($medium->video_info->variants AS $variant) {
1528                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1529                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1530                                                         $bitrate = $variant->bitrate;
1531                                                 }
1532                                         }
1533                                 }
1534                                 break;
1535                         // The following code will only be activated for test reasons
1536                         //default:
1537                         //      $postarray['body'] .= print_r($medium, true);
1538                 }
1539         }
1540
1541         // Now we replace the media urls.
1542         foreach ($media AS $key => $value) {
1543                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1544         }
1545
1546         return '';
1547 }
1548
1549 /**
1550  * Undocumented function
1551  *
1552  * @param App $a
1553  * @param integer $uid User ID
1554  * @param object $post Incoming Twitter post
1555  * @param array $self
1556  * @param bool $create_user Should users be created?
1557  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1558  * @param bool $noquote
1559  * @param integer $uriid URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1560  * @return array item array
1561  */
1562 function twitter_createpost(App $a, $uid, $post, array $self, $create_user, $only_existing_contact, $noquote, int $uriid = 0)
1563 {
1564         $postarray = [];
1565         $postarray['network'] = Protocol::TWITTER;
1566         $postarray['uid'] = $uid;
1567         $postarray['wall'] = 0;
1568         $postarray['uri'] = "twitter::" . $post->id_str;
1569         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1570         $postarray['source'] = json_encode($post);
1571
1572         if (empty($uriid)) {
1573                 $uriid = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1574         }
1575
1576         // Don't import our own comments
1577         if (Item::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1578                 Logger::log("Item with extid " . $postarray['uri'] . " found.", Logger::DEBUG);
1579                 return [];
1580         }
1581
1582         $contactid = 0;
1583
1584         if ($post->in_reply_to_status_id_str != "") {
1585                 $parent = "twitter::" . $post->in_reply_to_status_id_str;
1586
1587                 $fields = ['uri', 'parent-uri', 'parent'];
1588                 $parent_item = Item::selectFirst($fields, ['uri' => $parent, 'uid' => $uid]);
1589                 if (!DBA::isResult($parent_item)) {
1590                         $parent_item = Item::selectFirst($fields, ['extid' => $parent, 'uid' => $uid]);
1591                 }
1592
1593                 if (DBA::isResult($parent_item)) {
1594                         $postarray['thr-parent'] = $parent_item['uri'];
1595                         $postarray['parent-uri'] = $parent_item['parent-uri'];
1596                         $postarray['parent'] = $parent_item['parent'];
1597                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1598                 } else {
1599                         $postarray['thr-parent'] = $postarray['uri'];
1600                         $postarray['parent-uri'] = $postarray['uri'];
1601                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1602                 }
1603
1604                 // Is it me?
1605                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1606
1607                 if ($post->user->id_str == $own_id) {
1608                         $r = q("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = %d LIMIT 1",
1609                                 intval($uid));
1610
1611                         if (DBA::isResult($r)) {
1612                                 $contactid = $r[0]["id"];
1613
1614                                 $postarray['owner-name']   = $r[0]["name"];
1615                                 $postarray['owner-link']   = $r[0]["url"];
1616                                 $postarray['owner-avatar'] = $r[0]["photo"];
1617                         } else {
1618                                 Logger::log("No self contact for user " . $uid, Logger::DEBUG);
1619                                 return [];
1620                         }
1621                 }
1622                 // Don't create accounts of people who just comment something
1623                 $create_user = false;
1624         } else {
1625                 $postarray['parent-uri'] = $postarray['uri'];
1626                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1627         }
1628
1629         if ($contactid == 0) {
1630                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1631
1632                 $postarray['owner-name'] = $post->user->name;
1633                 $postarray['owner-link'] = "https://twitter.com/" . $post->user->screen_name;
1634                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1635         }
1636
1637         if (($contactid == 0) && !$only_existing_contact) {
1638                 $contactid = $self['id'];
1639         } elseif ($contactid <= 0) {
1640                 Logger::log("Contact ID is zero or less than zero.", Logger::DEBUG);
1641                 return [];
1642         }
1643
1644         $postarray['contact-id'] = $contactid;
1645
1646         $postarray['verb'] = Activity::POST;
1647         $postarray['author-name'] = $postarray['owner-name'];
1648         $postarray['author-link'] = $postarray['owner-link'];
1649         $postarray['author-avatar'] = $postarray['owner-avatar'];
1650         $postarray['plink'] = "https://twitter.com/" . $post->user->screen_name . "/status/" . $post->id_str;
1651         $postarray['app'] = strip_tags($post->source);
1652
1653         if ($post->user->protected) {
1654                 $postarray['private'] = 1;
1655                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1656         } else {
1657                 $postarray['private'] = 0;
1658                 $postarray['allow_cid'] = '';
1659         }
1660
1661         if (!empty($post->full_text)) {
1662                 $postarray['body'] = $post->full_text;
1663         } else {
1664                 $postarray['body'] = $post->text;
1665         }
1666
1667         // When the post contains links then use the correct object type
1668         if (count($post->entities->urls) > 0) {
1669                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1670         }
1671
1672         // Search for media links
1673         $picture = twitter_media_entities($post, $postarray);
1674
1675         $converted = twitter_expand_entities($postarray['body'], $post, $picture);
1676         $postarray['body'] = $converted['body'];
1677         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1678         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1679
1680         if ($uriid > 0) {
1681                 twitter_store_tags($uriid, $converted['taglist']);
1682         }
1683
1684         $statustext = $converted["plain"];
1685
1686         if (!empty($post->place->name)) {
1687                 $postarray["location"] = $post->place->name;
1688         }
1689         if (!empty($post->place->full_name)) {
1690                 $postarray["location"] = $post->place->full_name;
1691         }
1692         if (!empty($post->geo->coordinates)) {
1693                 $postarray["coord"] = $post->geo->coordinates[0] . " " . $post->geo->coordinates[1];
1694         }
1695         if (!empty($post->coordinates->coordinates)) {
1696                 $postarray["coord"] = $post->coordinates->coordinates[1] . " " . $post->coordinates->coordinates[0];
1697         }
1698         if (!empty($post->retweeted_status)) {
1699                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1700
1701                 if (empty($retweet['body'])) {
1702                         return [];
1703                 }
1704
1705                 if (!$noquote) {
1706                         // Store the original tweet
1707                         Item::insert($retweet);
1708
1709                         // CHange the other post into a reshare activity
1710                         $postarray['verb'] = Activity::ANNOUNCE;
1711                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1712                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1713
1714                         $postarray['thr-parent'] = $retweet['uri'];
1715                         $postarray['parent-uri'] = $retweet['uri'];
1716                 } else {
1717                         $retweet['source'] = $postarray['source'];
1718                         $retweet['private'] = $postarray['private'];
1719                         $retweet['allow_cid'] = $postarray['allow_cid'];
1720                         $retweet['contact-id'] = $postarray['contact-id'];
1721                         $retweet['owner-name'] = $postarray['owner-name'];
1722                         $retweet['owner-link'] = $postarray['owner-link'];
1723                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1724
1725                         $postarray = $retweet;
1726                 }
1727         }
1728
1729         if (!empty($post->quoted_status)) {
1730                 if ($noquote) {
1731                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1732                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1733                 } else {
1734                         $quoted = twitter_createpost($a, $uid, $post->quoted_status, $self, false, false, true, $uriid);
1735                         if (!empty($quoted['body'])) {
1736                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1737                                                 $quoted['author-name'],
1738                                                 $quoted['author-link'],
1739                                                 $quoted['author-avatar'],
1740                                                 $quoted['plink'],
1741                                                 $quoted['created']
1742                                         );
1743
1744                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1745                         } else {
1746                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1747                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1748                         }
1749                 }
1750         }
1751
1752         return $postarray;
1753 }
1754
1755 /**
1756  * Store tags and mentions
1757  *
1758  * @param integer $uriid
1759  * @param array $taglist
1760  */
1761 function twitter_store_tags(int $uriid, array $taglist)
1762 {
1763         foreach ($taglist as $tag) {
1764                 Tag::storeByHash($uriid, $tag[0], $tag[1], $tag[2]);
1765         }
1766 }
1767
1768 function twitter_fetchparentposts(App $a, $uid, $post, TwitterOAuth $connection, array $self)
1769 {
1770         Logger::log("twitter_fetchparentposts: Fetching for user " . $uid . " and post " . $post->id_str, Logger::DEBUG);
1771
1772         $posts = [];
1773
1774         while (!empty($post->in_reply_to_status_id_str)) {
1775                 $parameters = ["trim_user" => false, "tweet_mode" => "extended", "id" => $post->in_reply_to_status_id_str, "include_ext_alt_text" => true];
1776
1777                 try {
1778                         $post = $connection->get('statuses/show', $parameters);
1779                 } catch (TwitterOAuthException $e) {
1780                         Logger::log('twitter_fetchparentposts: Error fetching for user ' . $uid . ' and post ' . $post->id_str . ': ' . $e->getMessage());
1781                         break;
1782                 }
1783
1784                 if (empty($post)) {
1785                         Logger::log("twitter_fetchparentposts: Can't fetch post " . $parameters['id'], Logger::DEBUG);
1786                         break;
1787                 }
1788
1789                 if (empty($post->id_str)) {
1790                         Logger::log("twitter_fetchparentposts: This is not a post " . json_encode($post), Logger::DEBUG);
1791                         break;
1792                 }
1793
1794                 if (Item::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1795                         break;
1796                 }
1797
1798                 $posts[] = $post;
1799         }
1800
1801         Logger::log("twitter_fetchparentposts: Fetching " . count($posts) . " parents", Logger::DEBUG);
1802
1803         $posts = array_reverse($posts);
1804
1805         if (!empty($posts)) {
1806                 foreach ($posts as $post) {
1807                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1808
1809                         if (empty($postarray['body'])) {
1810                                 continue;
1811                         }
1812
1813                         $item = Item::insert($postarray);
1814
1815                         $postarray["id"] = $item;
1816
1817                         Logger::log('twitter_fetchparentpost: User ' . $self["nick"] . ' posted parent timeline item ' . $item);
1818                 }
1819         }
1820 }
1821
1822 function twitter_fetchhometimeline(App $a, $uid)
1823 {
1824         $ckey    = DI::config()->get('twitter', 'consumerkey');
1825         $csecret = DI::config()->get('twitter', 'consumersecret');
1826         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1827         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1828         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
1829         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
1830
1831         Logger::log("Fetching timeline for user " . $uid, Logger::DEBUG);
1832
1833         $application_name = DI::config()->get('twitter', 'application_name');
1834
1835         if ($application_name == "") {
1836                 $application_name = DI::baseUrl()->getHostname();
1837         }
1838
1839         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1840
1841         try {
1842                 $own_contact = twitter_fetch_own_contact($a, $uid);
1843         } catch (TwitterOAuthException $e) {
1844                 Logger::log('Error fetching own contact for user ' . $uid . ': ' . $e->getMessage());
1845                 return;
1846         }
1847
1848         $r = q("SELECT * FROM `contact` WHERE `id` = %d AND `uid` = %d LIMIT 1",
1849                 intval($own_contact),
1850                 intval($uid));
1851
1852         if (DBA::isResult($r)) {
1853                 $own_id = $r[0]["nick"];
1854         } else {
1855                 Logger::log("Own twitter contact not found for user " . $uid);
1856                 return;
1857         }
1858
1859         $self = User::getOwnerDataById($uid);
1860         if ($self === false) {
1861                 Logger::log("Own contact not found for user " . $uid);
1862                 return;
1863         }
1864
1865         $parameters = ["exclude_replies" => false, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1866         //$parameters["count"] = 200;
1867         // Fetching timeline
1868         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
1869
1870         $first_time = ($lastid == "");
1871
1872         if ($lastid != "") {
1873                 $parameters["since_id"] = $lastid;
1874         }
1875
1876         try {
1877                 $items = $connection->get('statuses/home_timeline', $parameters);
1878         } catch (TwitterOAuthException $e) {
1879                 Logger::log('Error fetching home timeline for user ' . $uid . ': ' . $e->getMessage());
1880                 return;
1881         }
1882
1883         if (!is_array($items)) {
1884                 Logger::log('No array while fetching home timeline for user ' . $uid . ': ' . print_r($items, true));
1885                 return;
1886         }
1887
1888         if (empty($items)) {
1889                 Logger::log('No new timeline content for user ' . $uid, Logger::INFO);
1890                 return;
1891         }
1892
1893         $posts = array_reverse($items);
1894
1895         Logger::log('Fetching timeline from ID ' . $lastid . ' for user ' . $uid . ' ' . sizeof($posts) . ' items', Logger::DEBUG);
1896
1897         if (count($posts)) {
1898                 foreach ($posts as $post) {
1899                         if ($post->id_str > $lastid) {
1900                                 $lastid = $post->id_str;
1901                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1902                         }
1903
1904                         if ($first_time) {
1905                                 continue;
1906                         }
1907
1908                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
1909                                 Logger::log("Skip previously sent post", Logger::DEBUG);
1910                                 continue;
1911                         }
1912
1913                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == "") {
1914                                 Logger::log("Skip post that will be mirrored", Logger::DEBUG);
1915                                 continue;
1916                         }
1917
1918                         if ($post->in_reply_to_status_id_str != "") {
1919                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1920                         }
1921
1922                         Logger::log('Preparing post ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1923
1924                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
1925
1926                         if (empty($postarray['body']) || trim($postarray['body']) == "") {
1927                                 Logger::log('Empty body for post ' . $post->id_str . ' and user ' . $uid, Logger::DEBUG);
1928                                 continue;
1929                         }
1930
1931                         $notify = false;
1932
1933                         if (($postarray['uri'] == $postarray['parent-uri']) && ($postarray['author-link'] == $postarray['owner-link'])) {
1934                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
1935                                 if (DBA::isResult($contact)) {
1936                                         $notify = Item::isRemoteSelf($contact, $postarray);
1937                                 }
1938                         }
1939
1940                         $item = Item::insert($postarray, $notify);
1941                         $postarray["id"] = $item;
1942
1943                         Logger::log('User ' . $uid . ' posted home timeline item ' . $item);
1944                 }
1945         }
1946         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1947
1948         Logger::log('Last timeline ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1949
1950         // Fetching mentions
1951         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
1952
1953         $first_time = ($lastid == "");
1954
1955         if ($lastid != "") {
1956                 $parameters["since_id"] = $lastid;
1957         }
1958
1959         try {
1960                 $items = $connection->get('statuses/mentions_timeline', $parameters);
1961         } catch (TwitterOAuthException $e) {
1962                 Logger::log('Error fetching mentions: ' . $e->getMessage());
1963                 return;
1964         }
1965
1966         if (!is_array($items)) {
1967                 Logger::log("Error fetching mentions: " . print_r($items, true), Logger::DEBUG);
1968                 return;
1969         }
1970
1971         $posts = array_reverse($items);
1972
1973         Logger::log("Fetching mentions for user " . $uid . " " . sizeof($posts) . " items", Logger::DEBUG);
1974
1975         if (count($posts)) {
1976                 foreach ($posts as $post) {
1977                         if ($post->id_str > $lastid) {
1978                                 $lastid = $post->id_str;
1979                         }
1980
1981                         if ($first_time) {
1982                                 continue;
1983                         }
1984
1985                         if ($post->in_reply_to_status_id_str != "") {
1986                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1987                         }
1988
1989                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
1990
1991                         if (empty($postarray['body'])) {
1992                                 continue;
1993                         }
1994
1995                         $item = Item::insert($postarray);
1996
1997                         Logger::log('User ' . $uid . ' posted mention timeline item ' . $item);
1998                 }
1999         }
2000
2001         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2002
2003         Logger::log('Last mentions ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
2004 }
2005
2006 function twitter_fetch_own_contact(App $a, $uid)
2007 {
2008         $ckey    = DI::config()->get('twitter', 'consumerkey');
2009         $csecret = DI::config()->get('twitter', 'consumersecret');
2010         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2011         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2012
2013         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2014
2015         $contact_id = 0;
2016
2017         if ($own_id == "") {
2018                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2019
2020                 // Fetching user data
2021                 // get() may throw TwitterOAuthException, but we will catch it later
2022                 $user = $connection->get('account/verify_credentials');
2023                 if (empty($user->id_str)) {
2024                         return false;
2025                 }
2026
2027                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2028
2029                 $contact_id = twitter_fetch_contact($uid, $user, true);
2030         } else {
2031                 $r = q("SELECT * FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
2032                         intval($uid),
2033                         DBA::escape("twitter::" . $own_id));
2034                 if (DBA::isResult($r)) {
2035                         $contact_id = $r[0]["id"];
2036                 } else {
2037                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2038                 }
2039         }
2040
2041         return $contact_id;
2042 }
2043
2044 function twitter_is_retweet(App $a, $uid, $body)
2045 {
2046         $body = trim($body);
2047
2048         // Skip if it isn't a pure repeated messages
2049         // Does it start with a share?
2050         if (strpos($body, "[share") > 0) {
2051                 return false;
2052         }
2053
2054         // Does it end with a share?
2055         if (strlen($body) > (strrpos($body, "[/share]") + 8)) {
2056                 return false;
2057         }
2058
2059         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2060         // Skip if there is no shared message in there
2061         if ($body == $attributes) {
2062                 return false;
2063         }
2064
2065         $link = "";
2066         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2067         if (!empty($matches[1])) {
2068                 $link = $matches[1];
2069         }
2070
2071         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2072         if (!empty($matches[1])) {
2073                 $link = $matches[1];
2074         }
2075
2076         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2077         if ($id == $link) {
2078                 return false;
2079         }
2080         return twitter_retweet($uid, $id);
2081 }
2082
2083 function twitter_retweet(int $uid, int $id, int $item_id = 0)
2084 {
2085         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2086
2087         $ckey    = DI::config()->get('twitter', 'consumerkey');
2088         $csecret = DI::config()->get('twitter', 'consumersecret');
2089         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2090         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2091
2092         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2093         $result = $connection->post('statuses/retweet/' . $id);
2094
2095         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2096
2097         if (!empty($item_id) && !empty($result->id_str)) {
2098                 Logger::info('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2099                 Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $item_id]);
2100         }
2101
2102         return !isset($result->errors);
2103 }
2104
2105 function twitter_update_mentions($body)
2106 {
2107         $URLSearchString = "^\[\]";
2108         $return = preg_replace_callback(
2109                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2110                 function ($matches) {
2111                         if (strpos($matches[1], 'twitter.com')) {
2112                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2113                         } else {
2114                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2115                         }
2116
2117                         return $return;
2118                 },
2119                 $body
2120         );
2121
2122         return $return;
2123 }
2124
2125 function twitter_convert_share(array $attributes, array $author_contact, $content, $is_quote_share)
2126 {
2127         if (empty($author_contact)) {
2128                 return $content . "\n\n" . $attributes['link'];
2129         }
2130
2131         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2132                 $mention = '@' . $author_contact['nick'];
2133         } else {
2134                 $mention = $author_contact['addr'];
2135         }
2136
2137         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2138 }