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