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