]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
Revert indentation
[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         switch ($action) {
499                 case 'delete':
500                         // To-Do: $result = $connection->post('statuses/destroy', $post);
501                         $result = [];
502                         break;
503                 case 'like':
504                         $result = $connection->post('favorites/create', $post);
505                         if ($connection->getLastHttpCode() != 200) {
506                                 Logger::warning('Unable to create favorite', ['result' => $result]);
507                         }
508                         break;
509                 case 'unlike':
510                         $result = $connection->post('favorites/destroy', $post);
511                         if ($connection->getLastHttpCode() != 200) {
512                                 Logger::warning('Unable to destroy favorite', ['result' => $result]);
513                         }
514                         break;
515                 default:
516                         Logger::warning('Unhandled action', ['action' => $action]);
517                         $result = [];
518         }
519
520         Logger::info('after action', ['action' => $action, 'result' => $result]);
521 }
522
523 function twitter_get_id(string $uri)
524 {
525         if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
526                 return 0;
527         }
528
529         $id = substr($uri, 9);
530         if (!is_numeric($id)) {
531                 return 0;
532         }
533
534         return (int)$id;
535 }
536
537 function twitter_post_hook(App $a, array &$b)
538 {
539         // Post to Twitter
540         if (!DI::pConfig()->get($b["uid"], 'twitter', 'import')
541                 && ($b['deleted'] || $b['private'] || ($b['created'] !== $b['edited']))) {
542                 return;
543         }
544
545         $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], $b['body']);
546
547         $thr_parent = null;
548
549         if ($b['parent'] != $b['id']) {
550                 Logger::debug('Got comment', ['item' => $b]);
551
552                 // Looking if its a reply to a twitter post
553                 if (!twitter_get_id($b["parent-uri"]) &&
554                         !twitter_get_id($b["extid"]) &&
555                         !twitter_get_id($b["thr-parent"])) {
556                         Logger::info('No twitter post', ['parent' => $b["parent"]]);
557                         return;
558                 }
559
560                 $condition = ['uri' => $b["thr-parent"], 'uid' => $b["uid"]];
561                 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
562                 if (!DBA::isResult($thr_parent)) {
563                         Logger::warning('No parent found', ['thr-parent' => $b["thr-parent"]]);
564                         return;
565                 }
566
567                 if ($thr_parent['author-network'] == Protocol::TWITTER) {
568                         $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
569                         $nicknameplain = '@' . $thr_parent['author-nick'];
570
571                         Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b["body"]]);
572                         if ((strpos($b["body"], $nickname) === false) && (strpos($b["body"], $nicknameplain) === false)) {
573                                 $b["body"] = $nickname . " " . $b["body"];
574                         }
575                 }
576
577                 Logger::debug('Parent found', ['parent' => $thr_parent]);
578         } else {
579                 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
580                         return;
581                 }
582
583                 // Dont't post if the post doesn't belong to us.
584                 // This is a check for forum postings
585                 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
586                 if ($b['contact-id'] != $self['id']) {
587                         return;
588                 }
589         }
590
591         if (($b['verb'] == Activity::POST) && $b['deleted']) {
592                 twitter_action($a, $b['uid'], twitter_get_id($thr_parent['uri']), 'delete');
593         }
594
595         if ($b['verb'] == Activity::LIKE) {
596                 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b["thr-parent"])]);
597                 if ($b['deleted']) {
598                         twitter_action($a, $b["uid"], twitter_get_id($b["thr-parent"]), "unlike");
599                 } else {
600                         twitter_action($a, $b["uid"], twitter_get_id($b["thr-parent"]), "like");
601                 }
602
603                 return;
604         }
605
606         if ($b['verb'] == Activity::ANNOUNCE) {
607                 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b["thr-parent"])]);
608                 if ($b['deleted']) {
609                         twitter_action($a, $b['uid'], twitter_get_id($thr_parent['extid']), 'delete');
610                 } else {
611                         twitter_retweet($b["uid"], twitter_get_id($b["thr-parent"]));
612                 }
613
614                 return;
615         }
616
617         if ($b['deleted'] || ($b['created'] !== $b['edited'])) {
618                 return;
619         }
620
621         // if post comes from twitter don't send it back
622         if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
623                 return;
624         }
625
626         if ($b['app'] == "Twitter") {
627                 return;
628         }
629
630         Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
631
632         DI::pConfig()->load($b['uid'], 'twitter');
633
634         $ckey    = DI::config()->get('twitter', 'consumerkey');
635         $csecret = DI::config()->get('twitter', 'consumersecret');
636         $otoken  = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
637         $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
638
639         if ($ckey && $csecret && $otoken && $osecret) {
640                 Logger::info('We have customer key and oauth stuff, going to send.');
641
642                 // If it's a repeated message from twitter then do a native retweet and exit
643                 if (twitter_is_retweet($a, $b['uid'], $b['body'])) {
644                         return;
645                 }
646
647                 Codebird::setConsumerKey($ckey, $csecret);
648                 $cb = Codebird::getInstance();
649                 $cb->setToken($otoken, $osecret);
650
651                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
652
653                 // Set the timeout for upload to 30 seconds
654                 $connection->setTimeouts(10, 30);
655
656                 $max_char = 280;
657
658                 // Handling non-native reshares
659                 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
660                         $b['body'],
661                         function (array $attributes, array $author_contact, $content, $is_quote_share) {
662                                 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
663                         }
664                 );
665
666                 $b['body'] = twitter_update_mentions($b['body']);
667
668                 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
669                 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
670                 $msg = $msgarr["text"];
671
672                 if (($msg == "") && isset($msgarr["title"])) {
673                         $msg = Plaintext::shorten($msgarr["title"], $max_char - 50);
674                 }
675
676                 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
677                 if (!empty($msgarr['url']) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
678                         $msg .= "\n" . $msgarr['url'];
679                 }
680
681                 if (empty($msg)) {
682                         Logger::notice('Empty message', ['id' => $b['id']]);
683                         return;
684                 }
685
686                 // and now tweet it :-)
687                 $post = [];
688
689                 if (!empty($msgarr['images'])) {
690                         Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images']]);
691                         try {
692                                 $media_ids = [];
693                                 foreach ($msgarr['images'] as $image) {
694                                         if (count($media_ids) == 4) {
695                                                 continue;
696                                         }
697
698                                         $img_str = DI::httpRequest()->fetch($image['url']);
699
700                                         $tempfile = tempnam(get_temppath(), 'cache');
701                                         file_put_contents($tempfile, $img_str);
702
703                                         Logger::info('Uploading', ['id' => $b['id'], 'image' => $image['url']]);
704                                         $media = $connection->upload('media/upload', ['media' => $tempfile]);
705
706                                         unlink($tempfile);
707
708                                         if (isset($media->media_id_string)) {
709                                                 $media_ids[] = $media->media_id_string;
710
711                                                 if (!empty($image['description'])) {
712                                                         $data = ['media_id' => $media->media_id_string,
713                                                                 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
714                                                         $ret = $cb->media_metadata_create($data);
715                                                         Logger::info('Metadata create', ['id' => $b['id'], 'data' => $data, 'return' => json_encode($ret)]);
716                                                 }
717                                         } else {
718                                                 Logger::error('Failed upload', ['id' => $b['id'], 'image' => $image['url']]);
719                                                 throw new Exception('Failed upload of ' . $image['url']);
720                                         }
721                                 }
722                                 $post['media_ids'] = implode(',', $media_ids);
723                                 if (empty($post['media_ids'])) {
724                                         unset($post['media_ids']);
725                                 }
726                         } catch (Exception $e) {
727                                 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
728                         }
729                 }
730
731                 $post['status'] = $msg;
732
733                 if ($thr_parent) {
734                         $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
735                 }
736
737                 $url = 'statuses/update';
738                 $result = $connection->post($url, $post);
739                 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
740
741                 if (!empty($result->source)) {
742                         DI::config()->set("twitter", "application_name", strip_tags($result->source));
743                 }
744
745                 if (!empty($result->errors)) {
746                         Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
747                         Worker::defer();
748                 } elseif ($thr_parent) {
749                         Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
750                         Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $b['id']]);
751                 }
752         }
753 }
754
755 function twitter_addon_admin_post(App $a)
756 {
757         $consumerkey    = !empty($_POST['consumerkey'])    ? Strings::escapeTags(trim($_POST['consumerkey']))    : '';
758         $consumersecret = !empty($_POST['consumersecret']) ? Strings::escapeTags(trim($_POST['consumersecret'])) : '';
759         DI::config()->set('twitter', 'consumerkey', $consumerkey);
760         DI::config()->set('twitter', 'consumersecret', $consumersecret);
761 }
762
763 function twitter_addon_admin(App $a, &$o)
764 {
765         $t = Renderer::getMarkupTemplate("admin.tpl", "addon/twitter/");
766
767         $o = Renderer::replaceMacros($t, [
768                 '$submit' => DI::l10n()->t('Save Settings'),
769                 // name, label, value, help, [extra values]
770                 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
771                 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
772         ]);
773 }
774
775 function twitter_cron(App $a)
776 {
777         $last = DI::config()->get('twitter', 'last_poll');
778
779         $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
780         if (!$poll_interval) {
781                 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
782         }
783
784         if ($last) {
785                 $next = $last + ($poll_interval * 60);
786                 if ($next > time()) {
787                         Logger::notice('twitter: poll intervall not reached');
788                         return;
789                 }
790         }
791         Logger::notice('twitter: cron_start');
792
793         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'mirror_posts' AND `v` = '1'");
794         if (DBA::isResult($r)) {
795                 foreach ($r as $rr) {
796                         Logger::notice('Fetching', ['user' => $rr['uid']]);
797                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 1, (int) $rr['uid']);
798                 }
799         }
800
801         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
802         if ($abandon_days < 1) {
803                 $abandon_days = 0;
804         }
805
806         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
807
808         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1'");
809         if (DBA::isResult($r)) {
810                 foreach ($r as $rr) {
811                         if ($abandon_days != 0) {
812                                 $user = q("SELECT `login_date` FROM `user` WHERE uid=%d AND `login_date` >= '%s'", $rr['uid'], $abandon_limit);
813                                 if (!DBA::isResult($user)) {
814                                         Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
815                                         continue;
816                                 }
817                         }
818
819                         Logger::notice('importing timeline', ['user' => $rr['uid']]);
820                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 2, (int) $rr['uid']);
821                         /*
822                           // To-Do
823                           // check for new contacts once a day
824                           $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
825                           if($last_contact_check)
826                           $next_contact_check = $last_contact_check + 86400;
827                           else
828                           $next_contact_check = 0;
829
830                           if($next_contact_check <= time()) {
831                           pumpio_getallusers($a, $rr["uid"]);
832                           DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
833                           }
834                          */
835                 }
836         }
837
838         Logger::notice('twitter: cron_end');
839
840         DI::config()->set('twitter', 'last_poll', time());
841 }
842
843 function twitter_expire(App $a)
844 {
845         $days = DI::config()->get('twitter', 'expire');
846
847         if ($days == 0) {
848                 return;
849         }
850
851         Logger::notice('Start deleting expired posts');
852
853         $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
854         while ($row = Post::fetch($r)) {
855                 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
856                 Item::markForDeletionById($row['id']);
857         }
858         DBA::close($r);
859
860         Logger::notice('End deleting expired posts');
861
862         Logger::notice('Start expiry');
863
864         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1' ORDER BY RAND()");
865         if (DBA::isResult($r)) {
866                 foreach ($r as $rr) {
867                         Logger::notice('twitter_expire', ['user' => $rr['uid']]);
868                         Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
869                 }
870         }
871
872         Logger::notice('End expiry');
873 }
874
875 function twitter_prepare_body(App $a, array &$b)
876 {
877         if ($b["item"]["network"] != Protocol::TWITTER) {
878                 return;
879         }
880
881         if ($b["preview"]) {
882                 $max_char = 280;
883                 $item = $b["item"];
884                 $item["plink"] = DI::baseUrl()->get() . "/display/" . $item["guid"];
885
886                 $condition = ['uri' => $item["thr-parent"], 'uid' => local_user()];
887                 $orig_post = Post::selectFirst(['author-link'], $condition);
888                 if (DBA::isResult($orig_post)) {
889                         $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post["author-link"]);
890                         $nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
891                         $nicknameplain = "@" . $nicknameplain;
892
893                         if ((strpos($item["body"], $nickname) === false) && (strpos($item["body"], $nicknameplain) === false)) {
894                                 $item["body"] = $nickname . " " . $item["body"];
895                         }
896                 }
897
898                 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
899                 $msg = $msgarr["text"];
900
901                 if (isset($msgarr["url"]) && ($msgarr["type"] != "photo")) {
902                         $msg .= " " . $msgarr["url"];
903                 }
904
905                 if (isset($msgarr["image"])) {
906                         $msg .= " " . $msgarr["image"];
907                 }
908
909                 $b['html'] = nl2br(htmlspecialchars($msg));
910         }
911 }
912
913 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
914 {
915         if ($twitterOAuth === null) {
916                 $ckey = DI::config()->get('twitter', 'consumerkey');
917                 $csecret = DI::config()->get('twitter', 'consumersecret');
918
919                 if (empty($ckey) || empty($csecret)) {
920                         return new stdClass();
921                 }
922
923                 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
924         }
925
926         $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
927
928         return $twitterOAuth->get('statuses/show', $parameters);
929 }
930
931 /**
932  * Parse Twitter status URLs since Twitter removed OEmbed
933  *
934  * @param App   $a
935  * @param array $b Expected format:
936  *                 [
937  *                      'url' => [URL to parse],
938  *                      'format' => 'json'|'',
939  *                      'text' => Output parameter
940  *                 ]
941  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
942  */
943 function twitter_parse_link(App $a, array &$b)
944 {
945         // Only handle Twitter status URLs
946         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
947                 return;
948         }
949
950         $status = twitter_statuses_show($matches[1]);
951
952         if (empty($status->id)) {
953                 return;
954         }
955
956         $item = twitter_createpost($a, 0, $status, [], true, false, true);
957
958         if ($b['format'] == 'json') {
959                 $images = [];
960                 foreach ($status->extended_entities->media ?? [] as $media) {
961                         if (!empty($media->media_url_https)) {
962                                 $images[] = [
963                                         'src'    => $media->media_url_https,
964                                         'width'  => $media->sizes->thumb->w,
965                                         'height' => $media->sizes->thumb->h,
966                                 ];
967                         }
968                 }
969
970                 $b['text'] = [
971                         'data' => [
972                                 'type' => 'link',
973                                 'url' => $item['plink'],
974                                 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
975                                 'text' => BBCode::toPlaintext($item['body'], false),
976                                 'images' => $images,
977                         ],
978                         'contentType' => 'attachment',
979                         'success' => true,
980                 ];
981         } else {
982                 $b['text'] = BBCode::getShareOpeningTag(
983                         $item['author-name'],
984                         $item['author-link'],
985                         $item['author-avatar'],
986                         $item['plink'],
987                         $item['created']
988                 );
989                 $b['text'] .= $item['body'] . '[/share]';
990         }
991 }
992
993
994 /*********************
995  *
996  * General functions
997  *
998  *********************/
999
1000
1001 /**
1002  * @brief Build the item array for the mirrored post
1003  *
1004  * @param App $a Application class
1005  * @param integer $uid User id
1006  * @param object $post Twitter object with the post
1007  *
1008  * @return array item data to be posted
1009  */
1010 function twitter_do_mirrorpost(App $a, $uid, $post)
1011 {
1012         $datarray['uid'] = $uid;
1013         $datarray['extid'] = 'twitter::' . $post->id;
1014         $datarray['title'] = '';
1015
1016         if (!empty($post->retweeted_status)) {
1017                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1018                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1019
1020                 if (empty($item['body'])) {
1021                         return [];
1022                 }
1023
1024                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1025                         $item['author-name'],
1026                         $item['author-link'],
1027                         $item['author-avatar'],
1028                         $item['plink'],
1029                         $item['created']
1030                 );
1031
1032                 $datarray['body'] .= $item['body'] . '[/share]';
1033         } else {
1034                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1035
1036                 if (empty($item['body'])) {
1037                         return [];
1038                 }
1039
1040                 $datarray['body'] = $item['body'];
1041         }
1042
1043         $datarray['app'] = $item['app'];
1044         $datarray['verb'] = $item['verb'];
1045
1046         if (isset($item['location'])) {
1047                 $datarray['location'] = $item['location'];
1048         }
1049
1050         if (isset($item['coord'])) {
1051                 $datarray['coord'] = $item['coord'];
1052         }
1053
1054         return $datarray;
1055 }
1056
1057 function twitter_fetchtimeline(App $a, $uid)
1058 {
1059         $ckey    = DI::config()->get('twitter', 'consumerkey');
1060         $csecret = DI::config()->get('twitter', 'consumersecret');
1061         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1062         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1063         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1064
1065         $application_name = DI::config()->get('twitter', 'application_name');
1066
1067         if ($application_name == "") {
1068                 $application_name = DI::baseUrl()->getHostname();
1069         }
1070
1071         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1072
1073         // Ensure to have the own contact
1074         try {
1075                 twitter_fetch_own_contact($a, $uid);
1076         } catch (TwitterOAuthException $e) {
1077                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1078                 return;
1079         }
1080
1081         $parameters = ["exclude_replies" => true, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1082
1083         $first_time = ($lastid == "");
1084
1085         if ($lastid != "") {
1086                 $parameters["since_id"] = $lastid;
1087         }
1088
1089         try {
1090                 $items = $connection->get('statuses/user_timeline', $parameters);
1091         } catch (TwitterOAuthException $e) {
1092                 Logger::warning('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1093                 return;
1094         }
1095
1096         if (!is_array($items)) {
1097                 Logger::notice('No items', ['user' => $uid]);
1098                 return;
1099         }
1100
1101         $posts = array_reverse($items);
1102
1103         Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1104
1105         if (count($posts)) {
1106                 foreach ($posts as $post) {
1107                         if ($post->id_str > $lastid) {
1108                                 $lastid = $post->id_str;
1109                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1110                         }
1111
1112                         if ($first_time) {
1113                                 continue;
1114                         }
1115
1116                         if (!stristr($post->source, $application_name)) {
1117                                 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1118
1119                                 $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1120
1121                                 if (empty($mirrorpost['body'])) {
1122                                         continue;
1123                                 }
1124
1125                                 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1126
1127                                 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, PRIORITY_MEDIUM, true);
1128                         }
1129                 }
1130         }
1131         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1132         Logger::log('Last ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1133 }
1134
1135 function twitter_fix_avatar($avatar)
1136 {
1137         $new_avatar = str_replace("_normal.", ".", $avatar);
1138
1139         $info = Images::getInfoFromURLCached($new_avatar);
1140         if (!$info) {
1141                 $new_avatar = $avatar;
1142         }
1143
1144         return $new_avatar;
1145 }
1146
1147 function twitter_get_relation($uid, $target, $contact = [])
1148 {
1149         if (isset($contact['rel'])) {
1150                 $relation = $contact['rel'];
1151         } else {
1152                 $relation = 0;
1153         }
1154
1155         $ckey = DI::config()->get('twitter', 'consumerkey');
1156         $csecret = DI::config()->get('twitter', 'consumersecret');
1157         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1158         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1159         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1160
1161         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1162         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1163
1164         try {
1165                 $status = $connection->get('friendships/show', $parameters);
1166                 if ($connection->getLastHttpCode() !== 200) {
1167                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1168                 }
1169
1170                 $following = $status->relationship->source->following;
1171                 $followed = $status->relationship->source->followed_by;
1172
1173                 if ($following && !$followed) {
1174                         $relation = Contact::SHARING;
1175                 } elseif (!$following && $followed) {
1176                         $relation = Contact::FOLLOWER;
1177                 } elseif ($following && $followed) {
1178                         $relation = Contact::FRIEND;
1179                 } elseif (!$following && !$followed) {
1180                         $relation = 0;
1181                 }
1182
1183                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1184         } catch (Throwable $e) {
1185                 Logger::warning('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1186         }
1187
1188         return $relation;
1189 }
1190
1191 /**
1192  * @param $data
1193  * @return array
1194  */
1195 function twitter_user_to_contact($data)
1196 {
1197         if (empty($data->id_str)) {
1198                 return [];
1199         }
1200
1201         $baseurl = 'https://twitter.com';
1202         $url = $baseurl . '/' . $data->screen_name;
1203         $addr = $data->screen_name . '@twitter.com';
1204
1205         $fields = [
1206                 'url'      => $url,
1207                 'network'  => Protocol::TWITTER,
1208                 'alias'    => 'twitter::' . $data->id_str,
1209                 'baseurl'  => $baseurl,
1210                 'name'     => $data->name,
1211                 'nick'     => $data->screen_name,
1212                 'addr'     => $addr,
1213                 'location' => $data->location,
1214                 'about'    => $data->description,
1215                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1216         ];
1217
1218         return $fields;
1219 }
1220
1221 function twitter_fetch_contact($uid, $data, $create_user)
1222 {
1223         $fields = twitter_user_to_contact($data);
1224
1225         if (empty($fields)) {
1226                 return -1;
1227         }
1228
1229         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1230         $avatar = $fields['photo'];
1231         unset($fields['photo']);
1232
1233         // Update the public contact
1234         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => "twitter::" . $data->id_str]);
1235         if (DBA::isResult($pcontact)) {
1236                 $cid = $pcontact['id'];
1237         } else {
1238                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1239         }
1240
1241         if (!empty($cid)) {
1242                 DBA::update('contact', $fields, ['id' => $cid]);
1243                 Contact::updateAvatar($cid, $avatar);
1244         } else {
1245                 Logger::warning('No contact found', ['fields' => $fields]);
1246         }
1247
1248         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => "twitter::" . $data->id_str]);
1249         if (!DBA::isResult($contact) && empty($cid)) {
1250                 Logger::warning('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1251                 return 0;
1252         } elseif (!$create_user) {
1253                 return $cid;
1254         }
1255
1256         if (!DBA::isResult($contact)) {
1257                 $relation = twitter_get_relation($uid, $data->screen_name);
1258
1259                 // create contact record
1260                 $fields['uid'] = $uid;
1261                 $fields['created'] = DateTimeFormat::utcNow();
1262                 $fields['nurl'] = Strings::normaliseLink($fields['url']);
1263                 $fields['poll'] = 'twitter::' . $data->id_str;
1264                 $fields['rel'] = $relation;
1265                 $fields['priority'] = 1;
1266                 $fields['writable'] = true;
1267                 $fields['blocked'] = false;
1268                 $fields['readonly'] = false;
1269                 $fields['pending'] = false;
1270
1271                 if (!DBA::insert('contact', $fields)) {
1272                         return false;
1273                 }
1274
1275                 $contact_id = DBA::lastInsertId();
1276
1277                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1278
1279                 Contact::updateAvatar($contact_id, $avatar);
1280         } else {
1281                 if ($contact["readonly"] || $contact["blocked"]) {
1282                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact["nick"]]);
1283                         return -1;
1284                 }
1285
1286                 $contact_id = $contact['id'];
1287                 $update = false;
1288
1289                 // Update the contact relation once per day
1290                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1291                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1292                         $update = true;
1293                 }
1294
1295                 Contact::updateAvatar($contact['id'], $avatar);
1296
1297                 if ($contact['name'] != $data->name) {
1298                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1299                         $update = true;
1300                 }
1301
1302                 if ($contact['nick'] != $data->screen_name) {
1303                         $fields['uri-date'] = DateTimeFormat::utcNow();
1304                         $update = true;
1305                 }
1306
1307                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1308                         $update = true;
1309                 }
1310
1311                 if ($update) {
1312                         $fields['updated'] = DateTimeFormat::utcNow();
1313                         DBA::update('contact', $fields, ['id' => $contact['id']]);
1314                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1315                 }
1316         }
1317
1318         return $contact_id;
1319 }
1320
1321 /**
1322  * @param string $screen_name
1323  * @return stdClass|null
1324  * @throws Exception
1325  */
1326 function twitter_fetchuser($screen_name)
1327 {
1328         $ckey = DI::config()->get('twitter', 'consumerkey');
1329         $csecret = DI::config()->get('twitter', 'consumersecret');
1330
1331         try {
1332                 // Fetching user data
1333                 $connection = new TwitterOAuth($ckey, $csecret);
1334                 $parameters = ['screen_name' => $screen_name];
1335                 $user = $connection->get('users/show', $parameters);
1336         } catch (TwitterOAuthException $e) {
1337                 Logger::warning('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1338                 return null;
1339         }
1340
1341         if (!is_object($user)) {
1342                 return null;
1343         }
1344
1345         return $user;
1346 }
1347
1348 /**
1349  * Replaces Twitter entities with Friendica-friendly links.
1350  *
1351  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1352  *
1353  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1354  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1355  * index to be correct even after the last replacement.
1356  *
1357  * @param string   $body
1358  * @param stdClass $status
1359  * @return array
1360  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1361  */
1362 function twitter_expand_entities($body, stdClass $status)
1363 {
1364         $plain = $body;
1365         $contains_urls = false;
1366
1367         $taglist = [];
1368
1369         $replacementList = [];
1370
1371         foreach ($status->entities->hashtags AS $hashtag) {
1372                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1373                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1374
1375                 $replacementList[$hashtag->indices[0]] = [
1376                         'replace' => $replace,
1377                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1378                 ];
1379         }
1380
1381         foreach ($status->entities->user_mentions AS $mention) {
1382                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1383                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1384
1385                 $replacementList[$mention->indices[0]] = [
1386                         'replace' => $replace,
1387                         'length' => $mention->indices[1] - $mention->indices[0],
1388                 ];
1389         }
1390
1391         foreach ($status->entities->urls ?? [] as $url) {
1392                 $plain = str_replace($url->url, '', $plain);
1393
1394                 if ($url->url && $url->expanded_url && $url->display_url) {
1395                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1396                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1397                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1398                         ) {
1399                                 $replacementList[$url->indices[0]] = [
1400                                         'replace' => '',
1401                                         'length' => $url->indices[1] - $url->indices[0],
1402                                 ];
1403                                 continue;
1404                         }
1405
1406                         $contains_urls = true;
1407
1408                         $expanded_url = $url->expanded_url;
1409
1410                         // Quickfix: Workaround for URL with '[' and ']' in it
1411                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1412                                 $expanded_url = $url->url;
1413                         }
1414
1415                         $replacementList[$url->indices[0]] = [
1416                                 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1417                                 'length' => $url->indices[1] - $url->indices[0],
1418                         ];
1419                 }
1420         }
1421
1422         krsort($replacementList);
1423
1424         foreach ($replacementList as $startIndex => $parameters) {
1425                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1426         }
1427
1428         $body = trim($body);
1429
1430         return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1431 }
1432
1433 /**
1434  * Store entity attachments
1435  *
1436  * @param integer $uriid
1437  * @param object $post Twitter object with the post
1438  */
1439 function twitter_store_attachments(int $uriid, $post)
1440 {
1441         if (!empty($post->extended_entities->media)) {
1442                 foreach ($post->extended_entities->media AS $medium) {
1443                         switch ($medium->type) {
1444                                 case 'photo':
1445                                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::IMAGE];
1446
1447                                         $attachment['url'] = $medium->media_url_https . '?name=large';
1448                                         $attachment['width'] = $medium->sizes->large->w;
1449                                         $attachment['height'] = $medium->sizes->large->h;
1450
1451                                         if ($medium->sizes->small->w != $attachment['width']) {
1452                                                 $attachment['preview'] = $medium->media_url_https . '?name=small';
1453                                                 $attachment['preview-width'] = $medium->sizes->small->w;
1454                                                 $attachment['preview-height'] = $medium->sizes->small->h;
1455                                         }
1456
1457                                         $attachment['name'] = $medium->display_url ?? null;
1458                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1459                                         Logger::debug('Photo attachment', ['attachment' => $attachment]);
1460                                         Post\Media::insert($attachment);
1461                                         break;
1462                                 case 'video':
1463                                 case 'animated_gif':
1464                                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::VIDEO];
1465                                         if (is_array($medium->video_info->variants)) {
1466                                                 $bitrate = 0;
1467                                                 // We take the video with the highest bitrate
1468                                                 foreach ($medium->video_info->variants AS $variant) {
1469                                                         if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1470                                                                 $attachment['url'] = $variant->url;
1471                                                                 $bitrate = $variant->bitrate;
1472                                                         }
1473                                                 }
1474                                         }
1475
1476                                         $attachment['name'] = $medium->display_url ?? null;
1477                                         $attachment['preview'] = $medium->media_url_https . ':small';
1478                                         $attachment['preview-width'] = $medium->sizes->small->w;
1479                                         $attachment['preview-height'] = $medium->sizes->small->h;
1480                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1481                                         Logger::debug('Video attachment', ['attachment' => $attachment]);
1482                                         Post\Media::insert($attachment);
1483                                         break;
1484                                 default:
1485                                         Logger::notice('Unknown media type', ['medium' => $medium]);
1486                         }
1487                 }
1488         }
1489
1490         if (!empty($post->entities->urls)) {
1491                 foreach ($post->entities->urls as $url) {
1492                         $attachment = ['uri-id' => $uriid, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1493                         Logger::debug('Attached link', ['attachment' => $attachment]);
1494                         Post\Media::insert($attachment);
1495                 }
1496         }
1497 }
1498
1499 /**
1500  * @brief Fetch media entities and add media links to the body
1501  *
1502  * @param object  $post      Twitter object with the post
1503  * @param array   $postarray Array of the item that is about to be posted
1504  * @param integer $uriid URI Id used to store tags. -1 = don't store tags for this post.
1505  */
1506 function twitter_media_entities($post, array &$postarray, int $uriid = -1)
1507 {
1508         // There are no media entities? So we quit.
1509         if (empty($post->extended_entities->media)) {
1510                 return;
1511         }
1512
1513         // This is a pure media post, first search for all media urls
1514         $media = [];
1515         foreach ($post->extended_entities->media AS $medium) {
1516                 if (!isset($media[$medium->url])) {
1517                         $media[$medium->url] = '';
1518                 }
1519                 switch ($medium->type) {
1520                         case 'photo':
1521                                 if (!empty($medium->ext_alt_text)) {
1522                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1523                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1524                                 } else {
1525                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1526                                 }
1527
1528                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1529                                 $postarray['post-type'] = Item::PT_IMAGE;
1530                                 break;
1531                         case 'video':
1532                                 // Currently deactivated, since this causes the video to be display before the content
1533                                 // We have to figure out a better way for declaring the post type and the display style.
1534                                 //$postarray['post-type'] = Item::PT_VIDEO;
1535                         case 'animated_gif':
1536                                 if (!empty($medium->ext_alt_text)) {
1537                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1538                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1539                                 } else {
1540                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1541                                 }
1542
1543                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1544                                 if (is_array($medium->video_info->variants)) {
1545                                         $bitrate = 0;
1546                                         // We take the video with the highest bitrate
1547                                         foreach ($medium->video_info->variants AS $variant) {
1548                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1549                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1550                                                         $bitrate = $variant->bitrate;
1551                                                 }
1552                                         }
1553                                 }
1554                                 break;
1555                 }
1556         }
1557
1558         if ($uriid != -1) {
1559                 foreach ($media AS $key => $value) {
1560                         $postarray['body'] = str_replace($key, '', $postarray['body']);
1561                 }
1562                 return;
1563         }
1564
1565         // Now we replace the media urls.
1566         foreach ($media AS $key => $value) {
1567                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1568         }
1569 }
1570
1571 /**
1572  * Undocumented function
1573  *
1574  * @param App $a
1575  * @param integer $uid User ID
1576  * @param object $post Incoming Twitter post
1577  * @param array $self
1578  * @param bool $create_user Should users be created?
1579  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1580  * @param bool $noquote
1581  * @param integer $uriid URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1582  * @return array item array
1583  */
1584 function twitter_createpost(App $a, $uid, $post, array $self, $create_user, $only_existing_contact, $noquote, int $uriid = 0)
1585 {
1586         $postarray = [];
1587         $postarray['network'] = Protocol::TWITTER;
1588         $postarray['uid'] = $uid;
1589         $postarray['wall'] = 0;
1590         $postarray['uri'] = "twitter::" . $post->id_str;
1591         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1592         $postarray['source'] = json_encode($post);
1593         $postarray['direction'] = Conversation::PULL;
1594
1595         if (empty($uriid)) {
1596                 $uriid = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1597         }
1598
1599         // Don't import our own comments
1600         if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1601                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1602                 return [];
1603         }
1604
1605         $contactid = 0;
1606
1607         if ($post->in_reply_to_status_id_str != "") {
1608                 $thr_parent = "twitter::" . $post->in_reply_to_status_id_str;
1609
1610                 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1611                 if (!DBA::isResult($item)) {
1612                         $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid]);
1613                 }
1614
1615                 if (DBA::isResult($item)) {
1616                         $postarray['thr-parent'] = $item['uri'];
1617                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1618                 } else {
1619                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1620                 }
1621
1622                 // Is it me?
1623                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1624
1625                 if ($post->user->id_str == $own_id) {
1626                         $r = q("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = %d LIMIT 1",
1627                                 intval($uid));
1628
1629                         if (DBA::isResult($r)) {
1630                                 $contactid = $r[0]["id"];
1631
1632                                 $postarray['owner-name']   = $r[0]["name"];
1633                                 $postarray['owner-link']   = $r[0]["url"];
1634                                 $postarray['owner-avatar'] = $r[0]["photo"];
1635                         } else {
1636                                 Logger::error('No self contact found', ['uid' => $uid]);
1637                                 return [];
1638                         }
1639                 }
1640                 // Don't create accounts of people who just comment something
1641                 $create_user = false;
1642         } else {
1643                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1644         }
1645
1646         if ($contactid == 0) {
1647                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1648
1649                 $postarray['owner-name'] = $post->user->name;
1650                 $postarray['owner-link'] = "https://twitter.com/" . $post->user->screen_name;
1651                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1652         }
1653
1654         if (($contactid == 0) && !$only_existing_contact) {
1655                 $contactid = $self['id'];
1656         } elseif ($contactid <= 0) {
1657                 Logger::info('Contact ID is zero or less than zero.');
1658                 return [];
1659         }
1660
1661         $postarray['contact-id'] = $contactid;
1662
1663         $postarray['verb'] = Activity::POST;
1664         $postarray['author-name'] = $postarray['owner-name'];
1665         $postarray['author-link'] = $postarray['owner-link'];
1666         $postarray['author-avatar'] = $postarray['owner-avatar'];
1667         $postarray['plink'] = "https://twitter.com/" . $post->user->screen_name . "/status/" . $post->id_str;
1668         $postarray['app'] = strip_tags($post->source);
1669
1670         if ($post->user->protected) {
1671                 $postarray['private'] = Item::PRIVATE;
1672                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1673         } else {
1674                 $postarray['private'] = Item::UNLISTED;
1675                 $postarray['allow_cid'] = '';
1676         }
1677
1678         if (!empty($post->full_text)) {
1679                 $postarray['body'] = $post->full_text;
1680         } else {
1681                 $postarray['body'] = $post->text;
1682         }
1683
1684         // When the post contains links then use the correct object type
1685         if (count($post->entities->urls) > 0) {
1686                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1687         }
1688
1689         // Search for media links
1690         twitter_media_entities($post, $postarray, $uriid);
1691
1692         $converted = twitter_expand_entities($postarray['body'], $post);
1693
1694         // When the post contains external links then images or videos are just "decorations".
1695         if (!empty($converted['urls'])) {
1696                 $postarray['post-type'] = Item::PT_NOTE;
1697         }
1698
1699         $postarray['body'] = $converted['body'];
1700         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1701         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1702
1703         if ($uriid > 0) {
1704                 twitter_store_tags($uriid, $converted['taglist']);
1705                 twitter_store_attachments($uriid, $post);
1706         }
1707
1708         if (!empty($post->place->name)) {
1709                 $postarray["location"] = $post->place->name;
1710         }
1711         if (!empty($post->place->full_name)) {
1712                 $postarray["location"] = $post->place->full_name;
1713         }
1714         if (!empty($post->geo->coordinates)) {
1715                 $postarray["coord"] = $post->geo->coordinates[0] . " " . $post->geo->coordinates[1];
1716         }
1717         if (!empty($post->coordinates->coordinates)) {
1718                 $postarray["coord"] = $post->coordinates->coordinates[1] . " " . $post->coordinates->coordinates[0];
1719         }
1720         if (!empty($post->retweeted_status)) {
1721                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1722
1723                 if (empty($retweet['body'])) {
1724                         return [];
1725                 }
1726
1727                 if (!$noquote) {
1728                         // Store the original tweet
1729                         Item::insert($retweet);
1730
1731                         // CHange the other post into a reshare activity
1732                         $postarray['verb'] = Activity::ANNOUNCE;
1733                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1734                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1735
1736                         $postarray['thr-parent'] = $retweet['uri'];
1737                 } else {
1738                         $retweet['source'] = $postarray['source'];
1739                         $retweet['direction'] = $postarray['direction'];
1740                         $retweet['private'] = $postarray['private'];
1741                         $retweet['allow_cid'] = $postarray['allow_cid'];
1742                         $retweet['contact-id'] = $postarray['contact-id'];
1743                         $retweet['owner-name'] = $postarray['owner-name'];
1744                         $retweet['owner-link'] = $postarray['owner-link'];
1745                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1746
1747                         $postarray = $retweet;
1748                 }
1749         }
1750
1751         if (!empty($post->quoted_status)) {
1752                 if ($noquote) {
1753                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1754                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1755                 } else {
1756                         $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
1757                         if (!empty($quoted['body'])) {
1758                                 Item::insert($quoted);
1759                                 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
1760                                 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriid, 'post' => $post]);
1761
1762                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1763                                                 $quoted['author-name'],
1764                                                 $quoted['author-link'],
1765                                                 $quoted['author-avatar'],
1766                                                 $quoted['plink'],
1767                                                 $quoted['created'],
1768                                                 $post['guid'] ?? ''
1769                                         );
1770
1771                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1772                         } else {
1773                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1774                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1775                         }
1776                 }
1777         }
1778
1779         return $postarray;
1780 }
1781
1782 /**
1783  * Store tags and mentions
1784  *
1785  * @param integer $uriid
1786  * @param array $taglist
1787  */
1788 function twitter_store_tags(int $uriid, array $taglist)
1789 {
1790         foreach ($taglist as $tag) {
1791                 Tag::storeByHash($uriid, $tag[0], $tag[1], $tag[2]);
1792         }
1793 }
1794
1795 function twitter_fetchparentposts(App $a, $uid, $post, TwitterOAuth $connection, array $self)
1796 {
1797         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
1798
1799         $posts = [];
1800
1801         while (!empty($post->in_reply_to_status_id_str)) {
1802                 try {
1803                         $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
1804                 } catch (TwitterOAuthException $e) {
1805                         Logger::warning('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
1806                         break;
1807                 }
1808
1809                 if (empty($post)) {
1810                         Logger::info("twitter_fetchparentposts: Can't fetch post");
1811                         break;
1812                 }
1813
1814                 if (empty($post->id_str)) {
1815                         Logger::info("twitter_fetchparentposts: This is not a post", ['post' => $post]);
1816                         break;
1817                 }
1818
1819                 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1820                         break;
1821                 }
1822
1823                 $posts[] = $post;
1824         }
1825
1826         Logger::log("twitter_fetchparentposts: Fetching " . count($posts) . " parents", Logger::DEBUG);
1827
1828         $posts = array_reverse($posts);
1829
1830         if (!empty($posts)) {
1831                 foreach ($posts as $post) {
1832                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1833
1834                         if (empty($postarray['body'])) {
1835                                 continue;
1836                         }
1837
1838                         $item = Item::insert($postarray);
1839
1840                         $postarray["id"] = $item;
1841
1842                         Logger::log('twitter_fetchparentpost: User ' . $self["nick"] . ' posted parent timeline item ' . $item);
1843                 }
1844         }
1845 }
1846
1847 function twitter_fetchhometimeline(App $a, $uid)
1848 {
1849         $ckey    = DI::config()->get('twitter', 'consumerkey');
1850         $csecret = DI::config()->get('twitter', 'consumersecret');
1851         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1852         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1853         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
1854         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
1855
1856         Logger::info('Fetching timeline', ['uid' => $uid]);
1857
1858         $application_name = DI::config()->get('twitter', 'application_name');
1859
1860         if ($application_name == "") {
1861                 $application_name = DI::baseUrl()->getHostname();
1862         }
1863
1864         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1865
1866         try {
1867                 $own_contact = twitter_fetch_own_contact($a, $uid);
1868         } catch (TwitterOAuthException $e) {
1869                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1870                 return;
1871         }
1872
1873         $r = q("SELECT * FROM `contact` WHERE `id` = %d AND `uid` = %d LIMIT 1",
1874                 intval($own_contact),
1875                 intval($uid));
1876
1877         if (DBA::isResult($r)) {
1878                 $own_id = $r[0]["nick"];
1879         } else {
1880                 Logger::warning('Own twitter contact not found', ['uid' => $uid]);
1881                 return;
1882         }
1883
1884         $self = User::getOwnerDataById($uid);
1885         if ($self === false) {
1886                 Logger::warning('Own contact not found', ['uid' => $uid]);
1887                 return;
1888         }
1889
1890         $parameters = ["exclude_replies" => false, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1891         //$parameters["count"] = 200;
1892         // Fetching timeline
1893         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
1894
1895         $first_time = ($lastid == "");
1896
1897         if ($lastid != "") {
1898                 $parameters["since_id"] = $lastid;
1899         }
1900
1901         try {
1902                 $items = $connection->get('statuses/home_timeline', $parameters);
1903         } catch (TwitterOAuthException $e) {
1904                 Logger::warning('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1905                 return;
1906         }
1907
1908         if (!is_array($items)) {
1909                 Logger::warning('home timeline is no array', ['items' => $items]);
1910                 return;
1911         }
1912
1913         if (empty($items)) {
1914                 Logger::notice('No new timeline content', ['uid' => $uid]);
1915                 return;
1916         }
1917
1918         $posts = array_reverse($items);
1919
1920         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
1921
1922         if (count($posts)) {
1923                 foreach ($posts as $post) {
1924                         if ($post->id_str > $lastid) {
1925                                 $lastid = $post->id_str;
1926                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1927                         }
1928
1929                         if ($first_time) {
1930                                 continue;
1931                         }
1932
1933                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
1934                                 Logger::info("Skip previously sent post");
1935                                 continue;
1936                         }
1937
1938                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == "") {
1939                                 Logger::info("Skip post that will be mirrored");
1940                                 continue;
1941                         }
1942
1943                         if ($post->in_reply_to_status_id_str != "") {
1944                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1945                         }
1946
1947                         Logger::log('Preparing post ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1948
1949                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
1950
1951                         if (empty($postarray['body']) || trim($postarray['body']) == "") {
1952                                 Logger::log('Empty body for post ' . $post->id_str . ' and user ' . $uid, Logger::DEBUG);
1953                                 continue;
1954                         }
1955
1956                         $notify = false;
1957
1958                         if (empty($postarray['thr-parent'])) {
1959                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
1960                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
1961                                         $notify = PRIORITY_MEDIUM;
1962                                 }
1963                         }
1964
1965                         $item = Item::insert($postarray, $notify);
1966                         $postarray["id"] = $item;
1967
1968                         Logger::log('User ' . $uid . ' posted home timeline item ' . $item);
1969                 }
1970         }
1971         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1972
1973         Logger::log('Last timeline ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1974
1975         // Fetching mentions
1976         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
1977
1978         $first_time = ($lastid == "");
1979
1980         if ($lastid != "") {
1981                 $parameters["since_id"] = $lastid;
1982         }
1983
1984         try {
1985                 $items = $connection->get('statuses/mentions_timeline', $parameters);
1986         } catch (TwitterOAuthException $e) {
1987                 Logger::warning('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
1988                 return;
1989         }
1990
1991         if (!is_array($items)) {
1992                 Logger::warning("mentions are no arrays", ['items' => $items]);
1993                 return;
1994         }
1995
1996         $posts = array_reverse($items);
1997
1998         Logger::log("Fetching mentions for user " . $uid . " " . sizeof($posts) . " items", Logger::DEBUG);
1999
2000         if (count($posts)) {
2001                 foreach ($posts as $post) {
2002                         if ($post->id_str > $lastid) {
2003                                 $lastid = $post->id_str;
2004                         }
2005
2006                         if ($first_time) {
2007                                 continue;
2008                         }
2009
2010                         if ($post->in_reply_to_status_id_str != "") {
2011                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2012                         }
2013
2014                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2015
2016                         if (empty($postarray['body'])) {
2017                                 continue;
2018                         }
2019
2020                         $item = Item::insert($postarray);
2021
2022                         Logger::log('User ' . $uid . ' posted mention timeline item ' . $item);
2023                 }
2024         }
2025
2026         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2027
2028         Logger::log('Last mentions ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
2029 }
2030
2031 function twitter_fetch_own_contact(App $a, $uid)
2032 {
2033         $ckey    = DI::config()->get('twitter', 'consumerkey');
2034         $csecret = DI::config()->get('twitter', 'consumersecret');
2035         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2036         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2037
2038         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2039
2040         $contact_id = 0;
2041
2042         if ($own_id == "") {
2043                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2044
2045                 // Fetching user data
2046                 // get() may throw TwitterOAuthException, but we will catch it later
2047                 $user = $connection->get('account/verify_credentials');
2048                 if (empty($user->id_str)) {
2049                         return false;
2050                 }
2051
2052                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2053
2054                 $contact_id = twitter_fetch_contact($uid, $user, true);
2055         } else {
2056                 $r = q("SELECT * FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
2057                         intval($uid),
2058                         DBA::escape("twitter::" . $own_id));
2059                 if (DBA::isResult($r)) {
2060                         $contact_id = $r[0]["id"];
2061                 } else {
2062                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2063                 }
2064         }
2065
2066         return $contact_id;
2067 }
2068
2069 function twitter_is_retweet(App $a, $uid, $body)
2070 {
2071         $body = trim($body);
2072
2073         // Skip if it isn't a pure repeated messages
2074         // Does it start with a share?
2075         if (strpos($body, "[share") > 0) {
2076                 return false;
2077         }
2078
2079         // Does it end with a share?
2080         if (strlen($body) > (strrpos($body, "[/share]") + 8)) {
2081                 return false;
2082         }
2083
2084         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2085         // Skip if there is no shared message in there
2086         if ($body == $attributes) {
2087                 return false;
2088         }
2089
2090         $link = "";
2091         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2092         if (!empty($matches[1])) {
2093                 $link = $matches[1];
2094         }
2095
2096         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2097         if (!empty($matches[1])) {
2098                 $link = $matches[1];
2099         }
2100
2101         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2102         if ($id == $link) {
2103                 return false;
2104         }
2105         return twitter_retweet($uid, $id);
2106 }
2107
2108 function twitter_retweet(int $uid, int $id, int $item_id = 0)
2109 {
2110         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2111
2112         $ckey    = DI::config()->get('twitter', 'consumerkey');
2113         $csecret = DI::config()->get('twitter', 'consumersecret');
2114         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2115         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2116
2117         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2118         $result = $connection->post('statuses/retweet/' . $id);
2119
2120         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2121
2122         if (!empty($item_id) && !empty($result->id_str)) {
2123                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2124                 Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $item_id]);
2125         }
2126
2127         return !isset($result->errors);
2128 }
2129
2130 function twitter_update_mentions($body)
2131 {
2132         $URLSearchString = "^\[\]";
2133         $return = preg_replace_callback(
2134                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2135                 function ($matches) {
2136                         if (strpos($matches[1], 'twitter.com')) {
2137                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2138                         } else {
2139                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2140                         }
2141
2142                         return $return;
2143                 },
2144                 $body
2145         );
2146
2147         return $return;
2148 }
2149
2150 function twitter_convert_share(array $attributes, array $author_contact, $content, $is_quote_share)
2151 {
2152         if (empty($author_contact)) {
2153                 return $content . "\n\n" . $attributes['link'];
2154         }
2155
2156         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2157                 $mention = '@' . $author_contact['nick'];
2158         } else {
2159                 $mention = $author_contact['addr'];
2160         }
2161
2162         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2163 }