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