]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
Twitter: Improved logging for the picture upload
[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->user["uid"];
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->user['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);
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         ];
1221
1222         return $fields;
1223 }
1224
1225 function twitter_fetch_contact($uid, $data, $create_user)
1226 {
1227         $fields = twitter_user_to_contact($data);
1228
1229         if (empty($fields)) {
1230                 return -1;
1231         }
1232
1233         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1234         $avatar = $fields['photo'];
1235         unset($fields['photo']);
1236
1237         // Update the public contact
1238         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => "twitter::" . $data->id_str]);
1239         if (DBA::isResult($pcontact)) {
1240                 $cid = $pcontact['id'];
1241         } else {
1242                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1243         }
1244
1245         if (!empty($cid)) {
1246                 DBA::update('contact', $fields, ['id' => $cid]);
1247                 Contact::updateAvatar($cid, $avatar);
1248         } else {
1249                 Logger::warning('No contact found', ['fields' => $fields]);
1250         }
1251
1252         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => "twitter::" . $data->id_str]);
1253         if (!DBA::isResult($contact) && empty($cid)) {
1254                 Logger::warning('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1255                 return 0;
1256         } elseif (!$create_user) {
1257                 return $cid;
1258         }
1259
1260         if (!DBA::isResult($contact)) {
1261                 $relation = twitter_get_relation($uid, $data->screen_name);
1262
1263                 // create contact record
1264                 $fields['uid'] = $uid;
1265                 $fields['created'] = DateTimeFormat::utcNow();
1266                 $fields['nurl'] = Strings::normaliseLink($fields['url']);
1267                 $fields['poll'] = 'twitter::' . $data->id_str;
1268                 $fields['rel'] = $relation;
1269                 $fields['priority'] = 1;
1270                 $fields['writable'] = true;
1271                 $fields['blocked'] = false;
1272                 $fields['readonly'] = false;
1273                 $fields['pending'] = false;
1274
1275                 if (!DBA::insert('contact', $fields)) {
1276                         return false;
1277                 }
1278
1279                 $contact_id = DBA::lastInsertId();
1280
1281                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1282
1283                 Contact::updateAvatar($contact_id, $avatar);
1284         } else {
1285                 if ($contact["readonly"] || $contact["blocked"]) {
1286                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact["nick"]]);
1287                         return -1;
1288                 }
1289
1290                 $contact_id = $contact['id'];
1291                 $update = false;
1292
1293                 // Update the contact relation once per day
1294                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1295                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1296                         $update = true;
1297                 }
1298
1299                 Contact::updateAvatar($contact['id'], $avatar);
1300
1301                 if ($contact['name'] != $data->name) {
1302                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1303                         $update = true;
1304                 }
1305
1306                 if ($contact['nick'] != $data->screen_name) {
1307                         $fields['uri-date'] = DateTimeFormat::utcNow();
1308                         $update = true;
1309                 }
1310
1311                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1312                         $update = true;
1313                 }
1314
1315                 if ($update) {
1316                         $fields['updated'] = DateTimeFormat::utcNow();
1317                         DBA::update('contact', $fields, ['id' => $contact['id']]);
1318                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1319                 }
1320         }
1321
1322         return $contact_id;
1323 }
1324
1325 /**
1326  * @param string $screen_name
1327  * @return stdClass|null
1328  * @throws Exception
1329  */
1330 function twitter_fetchuser($screen_name)
1331 {
1332         $ckey = DI::config()->get('twitter', 'consumerkey');
1333         $csecret = DI::config()->get('twitter', 'consumersecret');
1334
1335         try {
1336                 // Fetching user data
1337                 $connection = new TwitterOAuth($ckey, $csecret);
1338                 $parameters = ['screen_name' => $screen_name];
1339                 $user = $connection->get('users/show', $parameters);
1340         } catch (TwitterOAuthException $e) {
1341                 Logger::warning('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1342                 return null;
1343         }
1344
1345         if (!is_object($user)) {
1346                 return null;
1347         }
1348
1349         return $user;
1350 }
1351
1352 /**
1353  * Replaces Twitter entities with Friendica-friendly links.
1354  *
1355  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1356  *
1357  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1358  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1359  * index to be correct even after the last replacement.
1360  *
1361  * @param string   $body
1362  * @param stdClass $status
1363  * @return array
1364  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1365  */
1366 function twitter_expand_entities($body, stdClass $status)
1367 {
1368         $plain = $body;
1369         $contains_urls = false;
1370
1371         $taglist = [];
1372
1373         $replacementList = [];
1374
1375         foreach ($status->entities->hashtags AS $hashtag) {
1376                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1377                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1378
1379                 $replacementList[$hashtag->indices[0]] = [
1380                         'replace' => $replace,
1381                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1382                 ];
1383         }
1384
1385         foreach ($status->entities->user_mentions AS $mention) {
1386                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1387                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1388
1389                 $replacementList[$mention->indices[0]] = [
1390                         'replace' => $replace,
1391                         'length' => $mention->indices[1] - $mention->indices[0],
1392                 ];
1393         }
1394
1395         foreach ($status->entities->urls ?? [] as $url) {
1396                 $plain = str_replace($url->url, '', $plain);
1397
1398                 if ($url->url && $url->expanded_url && $url->display_url) {
1399                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1400                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1401                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1402                         ) {
1403                                 $replacementList[$url->indices[0]] = [
1404                                         'replace' => '',
1405                                         'length' => $url->indices[1] - $url->indices[0],
1406                                 ];
1407                                 continue;
1408                         }
1409
1410                         $contains_urls = true;
1411
1412                         $expanded_url = $url->expanded_url;
1413
1414                         // Quickfix: Workaround for URL with '[' and ']' in it
1415                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1416                                 $expanded_url = $url->url;
1417                         }
1418
1419                         $replacementList[$url->indices[0]] = [
1420                                 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1421                                 'length' => $url->indices[1] - $url->indices[0],
1422                         ];
1423                 }
1424         }
1425
1426         krsort($replacementList);
1427
1428         foreach ($replacementList as $startIndex => $parameters) {
1429                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1430         }
1431
1432         $body = trim($body);
1433
1434         return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1435 }
1436
1437 /**
1438  * Store entity attachments
1439  *
1440  * @param integer $uriid
1441  * @param object $post Twitter object with the post
1442  */
1443 function twitter_store_attachments(int $uriid, $post)
1444 {
1445         if (!empty($post->extended_entities->media)) {
1446                 foreach ($post->extended_entities->media AS $medium) {
1447                         switch ($medium->type) {
1448                                 case 'photo':
1449                                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::IMAGE];
1450
1451                                         $attachment['url'] = $medium->media_url_https . '?name=large';
1452                                         $attachment['width'] = $medium->sizes->large->w;
1453                                         $attachment['height'] = $medium->sizes->large->h;
1454
1455                                         if ($medium->sizes->small->w != $attachment['width']) {
1456                                                 $attachment['preview'] = $medium->media_url_https . '?name=small';
1457                                                 $attachment['preview-width'] = $medium->sizes->small->w;
1458                                                 $attachment['preview-height'] = $medium->sizes->small->h;
1459                                         }
1460
1461                                         $attachment['name'] = $medium->display_url ?? null;
1462                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1463                                         Logger::debug('Photo attachment', ['attachment' => $attachment]);
1464                                         Post\Media::insert($attachment);
1465                                         break;
1466                                 case 'video':
1467                                 case 'animated_gif':
1468                                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::VIDEO];
1469                                         if (is_array($medium->video_info->variants)) {
1470                                                 $bitrate = 0;
1471                                                 // We take the video with the highest bitrate
1472                                                 foreach ($medium->video_info->variants AS $variant) {
1473                                                         if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1474                                                                 $attachment['url'] = $variant->url;
1475                                                                 $bitrate = $variant->bitrate;
1476                                                         }
1477                                                 }
1478                                         }
1479
1480                                         $attachment['name'] = $medium->display_url ?? null;
1481                                         $attachment['preview'] = $medium->media_url_https . ':small';
1482                                         $attachment['preview-width'] = $medium->sizes->small->w;
1483                                         $attachment['preview-height'] = $medium->sizes->small->h;
1484                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1485                                         Logger::debug('Video attachment', ['attachment' => $attachment]);
1486                                         Post\Media::insert($attachment);
1487                                         break;
1488                                 default:
1489                                         Logger::notice('Unknown media type', ['medium' => $medium]);
1490                         }
1491                 }
1492         }
1493
1494         if (!empty($post->entities->urls)) {
1495                 foreach ($post->entities->urls as $url) {
1496                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1497                         Logger::debug('Attached link', ['attachment' => $attachment]);
1498                         Post\Media::insert($attachment);
1499                 }
1500         }
1501 }
1502
1503 /**
1504  * @brief Fetch media entities and add media links to the body
1505  *
1506  * @param object  $post      Twitter object with the post
1507  * @param array   $postarray Array of the item that is about to be posted
1508  * @param integer $uriid URI Id used to store tags. -1 = don't store tags for this post.
1509  */
1510 function twitter_media_entities($post, array &$postarray, int $uriid = -1)
1511 {
1512         // There are no media entities? So we quit.
1513         if (empty($post->extended_entities->media)) {
1514                 return;
1515         }
1516
1517         // This is a pure media post, first search for all media urls
1518         $media = [];
1519         foreach ($post->extended_entities->media AS $medium) {
1520                 if (!isset($media[$medium->url])) {
1521                         $media[$medium->url] = '';
1522                 }
1523                 switch ($medium->type) {
1524                         case 'photo':
1525                                 if (!empty($medium->ext_alt_text)) {
1526                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1527                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1528                                 } else {
1529                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1530                                 }
1531
1532                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1533                                 $postarray['post-type'] = Item::PT_IMAGE;
1534                                 break;
1535                         case 'video':
1536                                 // Currently deactivated, since this causes the video to be display before the content
1537                                 // We have to figure out a better way for declaring the post type and the display style.
1538                                 //$postarray['post-type'] = Item::PT_VIDEO;
1539                         case 'animated_gif':
1540                                 if (!empty($medium->ext_alt_text)) {
1541                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1542                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1543                                 } else {
1544                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1545                                 }
1546
1547                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1548                                 if (is_array($medium->video_info->variants)) {
1549                                         $bitrate = 0;
1550                                         // We take the video with the highest bitrate
1551                                         foreach ($medium->video_info->variants AS $variant) {
1552                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1553                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1554                                                         $bitrate = $variant->bitrate;
1555                                                 }
1556                                         }
1557                                 }
1558                                 break;
1559                 }
1560         }
1561
1562         if ($uriid != -1) {
1563                 foreach ($media AS $key => $value) {
1564                         $postarray['body'] = str_replace($key, '', $postarray['body']);
1565                 }
1566                 return;
1567         }
1568
1569         // Now we replace the media urls.
1570         foreach ($media AS $key => $value) {
1571                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1572         }
1573 }
1574
1575 /**
1576  * Undocumented function
1577  *
1578  * @param App $a
1579  * @param integer $uid User ID
1580  * @param object $post Incoming Twitter post
1581  * @param array $self
1582  * @param bool $create_user Should users be created?
1583  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1584  * @param bool $noquote
1585  * @param integer $uriid URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1586  * @return array item array
1587  */
1588 function twitter_createpost(App $a, $uid, $post, array $self, $create_user, $only_existing_contact, $noquote, int $uriid = 0)
1589 {
1590         $postarray = [];
1591         $postarray['network'] = Protocol::TWITTER;
1592         $postarray['uid'] = $uid;
1593         $postarray['wall'] = 0;
1594         $postarray['uri'] = "twitter::" . $post->id_str;
1595         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1596         $postarray['source'] = json_encode($post);
1597         $postarray['direction'] = Conversation::PULL;
1598
1599         if (empty($uriid)) {
1600                 $uriid = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1601         }
1602
1603         // Don't import our own comments
1604         if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1605                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1606                 return [];
1607         }
1608
1609         $contactid = 0;
1610
1611         if ($post->in_reply_to_status_id_str != "") {
1612                 $thr_parent = "twitter::" . $post->in_reply_to_status_id_str;
1613
1614                 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1615                 if (!DBA::isResult($item)) {
1616                         $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid]);
1617                 }
1618
1619                 if (DBA::isResult($item)) {
1620                         $postarray['thr-parent'] = $item['uri'];
1621                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1622                 } else {
1623                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1624                 }
1625
1626                 // Is it me?
1627                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1628
1629                 if ($post->user->id_str == $own_id) {
1630                         $r = q("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = %d LIMIT 1",
1631                                 intval($uid));
1632
1633                         if (DBA::isResult($r)) {
1634                                 $contactid = $r[0]["id"];
1635
1636                                 $postarray['owner-name']   = $r[0]["name"];
1637                                 $postarray['owner-link']   = $r[0]["url"];
1638                                 $postarray['owner-avatar'] = $r[0]["photo"];
1639                         } else {
1640                                 Logger::error('No self contact found', ['uid' => $uid]);
1641                                 return [];
1642                         }
1643                 }
1644                 // Don't create accounts of people who just comment something
1645                 $create_user = false;
1646         } else {
1647                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1648         }
1649
1650         if ($contactid == 0) {
1651                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1652
1653                 $postarray['owner-name'] = $post->user->name;
1654                 $postarray['owner-link'] = "https://twitter.com/" . $post->user->screen_name;
1655                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1656         }
1657
1658         if (($contactid == 0) && !$only_existing_contact) {
1659                 $contactid = $self['id'];
1660         } elseif ($contactid <= 0) {
1661                 Logger::info('Contact ID is zero or less than zero.');
1662                 return [];
1663         }
1664
1665         $postarray['contact-id'] = $contactid;
1666
1667         $postarray['verb'] = Activity::POST;
1668         $postarray['author-name'] = $postarray['owner-name'];
1669         $postarray['author-link'] = $postarray['owner-link'];
1670         $postarray['author-avatar'] = $postarray['owner-avatar'];
1671         $postarray['plink'] = "https://twitter.com/" . $post->user->screen_name . "/status/" . $post->id_str;
1672         $postarray['app'] = strip_tags($post->source);
1673
1674         if ($post->user->protected) {
1675                 $postarray['private'] = Item::PRIVATE;
1676                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1677         } else {
1678                 $postarray['private'] = Item::UNLISTED;
1679                 $postarray['allow_cid'] = '';
1680         }
1681
1682         if (!empty($post->full_text)) {
1683                 $postarray['body'] = $post->full_text;
1684         } else {
1685                 $postarray['body'] = $post->text;
1686         }
1687
1688         // When the post contains links then use the correct object type
1689         if (count($post->entities->urls) > 0) {
1690                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1691         }
1692
1693         // Search for media links
1694         twitter_media_entities($post, $postarray, $uriid);
1695
1696         $converted = twitter_expand_entities($postarray['body'], $post);
1697
1698         // When the post contains external links then images or videos are just "decorations".
1699         if (!empty($converted['urls'])) {
1700                 $postarray['post-type'] = Item::PT_NOTE;
1701         }
1702
1703         $postarray['body'] = $converted['body'];
1704         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1705         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1706
1707         if ($uriid > 0) {
1708                 twitter_store_tags($uriid, $converted['taglist']);
1709                 twitter_store_attachments($uriid, $post);
1710         }
1711
1712         if (!empty($post->place->name)) {
1713                 $postarray["location"] = $post->place->name;
1714         }
1715         if (!empty($post->place->full_name)) {
1716                 $postarray["location"] = $post->place->full_name;
1717         }
1718         if (!empty($post->geo->coordinates)) {
1719                 $postarray["coord"] = $post->geo->coordinates[0] . " " . $post->geo->coordinates[1];
1720         }
1721         if (!empty($post->coordinates->coordinates)) {
1722                 $postarray["coord"] = $post->coordinates->coordinates[1] . " " . $post->coordinates->coordinates[0];
1723         }
1724         if (!empty($post->retweeted_status)) {
1725                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1726
1727                 if (empty($retweet['body'])) {
1728                         return [];
1729                 }
1730
1731                 if (!$noquote) {
1732                         // Store the original tweet
1733                         Item::insert($retweet);
1734
1735                         // CHange the other post into a reshare activity
1736                         $postarray['verb'] = Activity::ANNOUNCE;
1737                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1738                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1739
1740                         $postarray['thr-parent'] = $retweet['uri'];
1741                 } else {
1742                         $retweet['source'] = $postarray['source'];
1743                         $retweet['direction'] = $postarray['direction'];
1744                         $retweet['private'] = $postarray['private'];
1745                         $retweet['allow_cid'] = $postarray['allow_cid'];
1746                         $retweet['contact-id'] = $postarray['contact-id'];
1747                         $retweet['owner-name'] = $postarray['owner-name'];
1748                         $retweet['owner-link'] = $postarray['owner-link'];
1749                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1750
1751                         $postarray = $retweet;
1752                 }
1753         }
1754
1755         if (!empty($post->quoted_status)) {
1756                 if ($noquote) {
1757                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1758                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1759                 } else {
1760                         $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
1761                         if (!empty($quoted['body'])) {
1762                                 Item::insert($quoted);
1763                                 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
1764                                 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriid, 'post' => $post]);
1765
1766                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1767                                                 $quoted['author-name'],
1768                                                 $quoted['author-link'],
1769                                                 $quoted['author-avatar'],
1770                                                 $quoted['plink'],
1771                                                 $quoted['created'],
1772                                                 $post['guid'] ?? ''
1773                                         );
1774
1775                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1776                         } else {
1777                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1778                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1779                         }
1780                 }
1781         }
1782
1783         return $postarray;
1784 }
1785
1786 /**
1787  * Store tags and mentions
1788  *
1789  * @param integer $uriid
1790  * @param array $taglist
1791  */
1792 function twitter_store_tags(int $uriid, array $taglist)
1793 {
1794         foreach ($taglist as $tag) {
1795                 Tag::storeByHash($uriid, $tag[0], $tag[1], $tag[2]);
1796         }
1797 }
1798
1799 function twitter_fetchparentposts(App $a, $uid, $post, TwitterOAuth $connection, array $self)
1800 {
1801         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
1802
1803         $posts = [];
1804
1805         while (!empty($post->in_reply_to_status_id_str)) {
1806                 try {
1807                         $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
1808                 } catch (TwitterOAuthException $e) {
1809                         Logger::warning('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
1810                         break;
1811                 }
1812
1813                 if (empty($post)) {
1814                         Logger::info("twitter_fetchparentposts: Can't fetch post");
1815                         break;
1816                 }
1817
1818                 if (empty($post->id_str)) {
1819                         Logger::info("twitter_fetchparentposts: This is not a post", ['post' => $post]);
1820                         break;
1821                 }
1822
1823                 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1824                         break;
1825                 }
1826
1827                 $posts[] = $post;
1828         }
1829
1830         Logger::log("twitter_fetchparentposts: Fetching " . count($posts) . " parents", Logger::DEBUG);
1831
1832         $posts = array_reverse($posts);
1833
1834         if (!empty($posts)) {
1835                 foreach ($posts as $post) {
1836                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1837
1838                         if (empty($postarray['body'])) {
1839                                 continue;
1840                         }
1841
1842                         $item = Item::insert($postarray);
1843
1844                         $postarray["id"] = $item;
1845
1846                         Logger::log('twitter_fetchparentpost: User ' . $self["nick"] . ' posted parent timeline item ' . $item);
1847                 }
1848         }
1849 }
1850
1851 function twitter_fetchhometimeline(App $a, $uid)
1852 {
1853         $ckey    = DI::config()->get('twitter', 'consumerkey');
1854         $csecret = DI::config()->get('twitter', 'consumersecret');
1855         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1856         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1857         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
1858         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
1859
1860         Logger::info('Fetching timeline', ['uid' => $uid]);
1861
1862         $application_name = DI::config()->get('twitter', 'application_name');
1863
1864         if ($application_name == "") {
1865                 $application_name = DI::baseUrl()->getHostname();
1866         }
1867
1868         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1869
1870         try {
1871                 $own_contact = twitter_fetch_own_contact($a, $uid);
1872         } catch (TwitterOAuthException $e) {
1873                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1874                 return;
1875         }
1876
1877         $r = q("SELECT * FROM `contact` WHERE `id` = %d AND `uid` = %d LIMIT 1",
1878                 intval($own_contact),
1879                 intval($uid));
1880
1881         if (DBA::isResult($r)) {
1882                 $own_id = $r[0]["nick"];
1883         } else {
1884                 Logger::warning('Own twitter contact not found', ['uid' => $uid]);
1885                 return;
1886         }
1887
1888         $self = User::getOwnerDataById($uid);
1889         if ($self === false) {
1890                 Logger::warning('Own contact not found', ['uid' => $uid]);
1891                 return;
1892         }
1893
1894         $parameters = ["exclude_replies" => false, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1895         //$parameters["count"] = 200;
1896         // Fetching timeline
1897         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
1898
1899         $first_time = ($lastid == "");
1900
1901         if ($lastid != "") {
1902                 $parameters["since_id"] = $lastid;
1903         }
1904
1905         try {
1906                 $items = $connection->get('statuses/home_timeline', $parameters);
1907         } catch (TwitterOAuthException $e) {
1908                 Logger::warning('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1909                 return;
1910         }
1911
1912         if (!is_array($items)) {
1913                 Logger::warning('home timeline is no array', ['items' => $items]);
1914                 return;
1915         }
1916
1917         if (empty($items)) {
1918                 Logger::notice('No new timeline content', ['uid' => $uid]);
1919                 return;
1920         }
1921
1922         $posts = array_reverse($items);
1923
1924         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
1925
1926         if (count($posts)) {
1927                 foreach ($posts as $post) {
1928                         if ($post->id_str > $lastid) {
1929                                 $lastid = $post->id_str;
1930                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1931                         }
1932
1933                         if ($first_time) {
1934                                 continue;
1935                         }
1936
1937                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
1938                                 Logger::info("Skip previously sent post");
1939                                 continue;
1940                         }
1941
1942                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == "") {
1943                                 Logger::info("Skip post that will be mirrored");
1944                                 continue;
1945                         }
1946
1947                         if ($post->in_reply_to_status_id_str != "") {
1948                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1949                         }
1950
1951                         Logger::log('Preparing post ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1952
1953                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
1954
1955                         if (empty($postarray['body']) || trim($postarray['body']) == "") {
1956                                 Logger::log('Empty body for post ' . $post->id_str . ' and user ' . $uid, Logger::DEBUG);
1957                                 continue;
1958                         }
1959
1960                         $notify = false;
1961
1962                         if (empty($postarray['thr-parent'])) {
1963                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
1964                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
1965                                         $notify = PRIORITY_MEDIUM;
1966                                 }
1967                         }
1968
1969                         $item = Item::insert($postarray, $notify);
1970                         $postarray["id"] = $item;
1971
1972                         Logger::log('User ' . $uid . ' posted home timeline item ' . $item);
1973                 }
1974         }
1975         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1976
1977         Logger::log('Last timeline ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1978
1979         // Fetching mentions
1980         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
1981
1982         $first_time = ($lastid == "");
1983
1984         if ($lastid != "") {
1985                 $parameters["since_id"] = $lastid;
1986         }
1987
1988         try {
1989                 $items = $connection->get('statuses/mentions_timeline', $parameters);
1990         } catch (TwitterOAuthException $e) {
1991                 Logger::warning('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
1992                 return;
1993         }
1994
1995         if (!is_array($items)) {
1996                 Logger::warning("mentions are no arrays", ['items' => $items]);
1997                 return;
1998         }
1999
2000         $posts = array_reverse($items);
2001
2002         Logger::log("Fetching mentions for user " . $uid . " " . sizeof($posts) . " items", Logger::DEBUG);
2003
2004         if (count($posts)) {
2005                 foreach ($posts as $post) {
2006                         if ($post->id_str > $lastid) {
2007                                 $lastid = $post->id_str;
2008                         }
2009
2010                         if ($first_time) {
2011                                 continue;
2012                         }
2013
2014                         if ($post->in_reply_to_status_id_str != "") {
2015                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2016                         }
2017
2018                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2019
2020                         if (empty($postarray['body'])) {
2021                                 continue;
2022                         }
2023
2024                         $item = Item::insert($postarray);
2025
2026                         Logger::log('User ' . $uid . ' posted mention timeline item ' . $item);
2027                 }
2028         }
2029
2030         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2031
2032         Logger::log('Last mentions ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
2033 }
2034
2035 function twitter_fetch_own_contact(App $a, $uid)
2036 {
2037         $ckey    = DI::config()->get('twitter', 'consumerkey');
2038         $csecret = DI::config()->get('twitter', 'consumersecret');
2039         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2040         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2041
2042         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2043
2044         $contact_id = 0;
2045
2046         if ($own_id == "") {
2047                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2048
2049                 // Fetching user data
2050                 // get() may throw TwitterOAuthException, but we will catch it later
2051                 $user = $connection->get('account/verify_credentials');
2052                 if (empty($user->id_str)) {
2053                         return false;
2054                 }
2055
2056                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2057
2058                 $contact_id = twitter_fetch_contact($uid, $user, true);
2059         } else {
2060                 $r = q("SELECT * FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
2061                         intval($uid),
2062                         DBA::escape("twitter::" . $own_id));
2063                 if (DBA::isResult($r)) {
2064                         $contact_id = $r[0]["id"];
2065                 } else {
2066                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2067                 }
2068         }
2069
2070         return $contact_id;
2071 }
2072
2073 function twitter_is_retweet(App $a, $uid, $body)
2074 {
2075         $body = trim($body);
2076
2077         // Skip if it isn't a pure repeated messages
2078         // Does it start with a share?
2079         if (strpos($body, "[share") > 0) {
2080                 return false;
2081         }
2082
2083         // Does it end with a share?
2084         if (strlen($body) > (strrpos($body, "[/share]") + 8)) {
2085                 return false;
2086         }
2087
2088         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2089         // Skip if there is no shared message in there
2090         if ($body == $attributes) {
2091                 return false;
2092         }
2093
2094         $link = "";
2095         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2096         if (!empty($matches[1])) {
2097                 $link = $matches[1];
2098         }
2099
2100         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2101         if (!empty($matches[1])) {
2102                 $link = $matches[1];
2103         }
2104
2105         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2106         if ($id == $link) {
2107                 return false;
2108         }
2109         return twitter_retweet($uid, $id);
2110 }
2111
2112 function twitter_retweet(int $uid, int $id, int $item_id = 0)
2113 {
2114         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2115
2116         $ckey    = DI::config()->get('twitter', 'consumerkey');
2117         $csecret = DI::config()->get('twitter', 'consumersecret');
2118         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2119         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2120
2121         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2122         $result = $connection->post('statuses/retweet/' . $id);
2123
2124         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2125
2126         if (!empty($item_id) && !empty($result->id_str)) {
2127                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2128                 Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $item_id]);
2129         }
2130
2131         return !isset($result->errors);
2132 }
2133
2134 function twitter_update_mentions($body)
2135 {
2136         $URLSearchString = "^\[\]";
2137         $return = preg_replace_callback(
2138                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2139                 function ($matches) {
2140                         if (strpos($matches[1], 'twitter.com')) {
2141                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2142                         } else {
2143                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2144                         }
2145
2146                         return $return;
2147                 },
2148                 $body
2149         );
2150
2151         return $return;
2152 }
2153
2154 function twitter_convert_share(array $attributes, array $author_contact, $content, $is_quote_share)
2155 {
2156         if (empty($author_contact)) {
2157                 return $content . "\n\n" . $attributes['link'];
2158         }
2159
2160         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2161                 $mention = '@' . $author_contact['nick'];
2162         } else {
2163                 $mention = $author_contact['addr'];
2164         }
2165
2166         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2167 }