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