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