]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
Changes:
[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         $consumerkey    = trim($_POST['consumerkey'] ?? '');
907         $consumersecret = trim($_POST['consumersecret'] ?? '');
908         DI::config()->set('twitter', 'consumerkey', $consumerkey);
909         DI::config()->set('twitter', 'consumersecret', $consumersecret);
910 }
911
912 function twitter_addon_admin(App $a, &$o)
913 {
914         $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
915
916         $o = Renderer::replaceMacros($t, [
917                 '$submit' => DI::l10n()->t('Save Settings'),
918                 // name, label, value, help, [extra values]
919                 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
920                 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
921         ]);
922 }
923
924 function twitter_cron(App $a)
925 {
926         $last = DI::config()->get('twitter', 'last_poll');
927
928         $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
929         if (!$poll_interval) {
930                 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
931         }
932
933         if ($last) {
934                 $next = $last + ($poll_interval * 60);
935                 if ($next > time()) {
936                         Logger::notice('twitter: poll intervall not reached');
937                         return;
938                 }
939         }
940         Logger::notice('twitter: cron_start');
941
942         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
943         foreach ($pconfigs as $rr) {
944                 Logger::notice('Fetching', ['user' => $rr['uid']]);
945                 Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
946         }
947
948         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
949         if ($abandon_days < 1) {
950                 $abandon_days = 0;
951         }
952
953         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
954
955         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
956         foreach ($pconfigs as $rr) {
957                 if ($abandon_days != 0) {
958                         if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
959                                 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
960                                 continue;
961                         }
962                 }
963
964                 Logger::notice('importing timeline', ['user' => $rr['uid']]);
965                 Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
966                 /*
967                         // To-Do
968                         // check for new contacts once a day
969                         $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
970                         if($last_contact_check)
971                         $next_contact_check = $last_contact_check + 86400;
972                         else
973                         $next_contact_check = 0;
974
975                         if($next_contact_check <= time()) {
976                         pumpio_getallusers($a, $rr["uid"]);
977                         DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
978                         }
979                         */
980         }
981
982         Logger::notice('twitter: cron_end');
983
984         DI::config()->set('twitter', 'last_poll', time());
985 }
986
987 function twitter_expire(App $a)
988 {
989         $days = DI::config()->get('twitter', 'expire');
990
991         if ($days == 0) {
992                 return;
993         }
994
995         Logger::notice('Start deleting expired posts');
996
997         $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
998         while ($row = Post::fetch($r)) {
999                 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1000                 Item::markForDeletionById($row['id']);
1001         }
1002         DBA::close($r);
1003
1004         Logger::notice('End deleting expired posts');
1005
1006         Logger::notice('Start expiry');
1007
1008         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1009         foreach ($pconfigs as $rr) {
1010                 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1011                 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1012         }
1013
1014         Logger::notice('End expiry');
1015 }
1016
1017 function twitter_prepare_body(App $a, array &$b)
1018 {
1019         if ($b['item']['network'] != Protocol::TWITTER) {
1020                 return;
1021         }
1022
1023         if ($b['preview']) {
1024                 $max_char = 280;
1025                 $item = $b['item'];
1026                 $item['plink'] = DI::baseUrl()->get() . '/display/' . $item['guid'];
1027
1028                 $condition = ['uri' => $item['thr-parent'], 'uid' => local_user()];
1029                 $orig_post = Post::selectFirst(['author-link'], $condition);
1030                 if (DBA::isResult($orig_post)) {
1031                         $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1032                         $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1033                         $nicknameplain = '@' . $nicknameplain;
1034
1035                         if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1036                                 $item['body'] = $nickname . ' ' . $item['body'];
1037                         }
1038                 }
1039
1040                 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1041                 $msg = $msgarr['text'];
1042
1043                 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1044                         $msg .= ' ' . $msgarr['url'];
1045                 }
1046
1047                 if (isset($msgarr['image'])) {
1048                         $msg .= ' ' . $msgarr['image'];
1049                 }
1050
1051                 $b['html'] = nl2br(htmlspecialchars($msg));
1052         }
1053 }
1054
1055 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1056 {
1057         if ($twitterOAuth === null) {
1058                 $ckey = DI::config()->get('twitter', 'consumerkey');
1059                 $csecret = DI::config()->get('twitter', 'consumersecret');
1060
1061                 if (empty($ckey) || empty($csecret)) {
1062                         return new stdClass();
1063                 }
1064
1065                 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1066         }
1067
1068         $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1069
1070         return $twitterOAuth->get('statuses/show', $parameters);
1071 }
1072
1073 /**
1074  * Parse Twitter status URLs since Twitter removed OEmbed
1075  *
1076  * @param App   $a
1077  * @param array $b Expected format:
1078  *                 [
1079  *                      'url' => [URL to parse],
1080  *                      'format' => 'json'|'',
1081  *                      'text' => Output parameter
1082  *                 ]
1083  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1084  */
1085 function twitter_parse_link(App $a, array &$b)
1086 {
1087         // Only handle Twitter status URLs
1088         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1089                 return;
1090         }
1091
1092         $status = twitter_statuses_show($matches[1]);
1093
1094         if (empty($status->id)) {
1095                 return;
1096         }
1097
1098         $item = twitter_createpost($a, 0, $status, [], true, false, true);
1099
1100         if ($b['format'] == 'json') {
1101                 $images = [];
1102                 foreach ($status->extended_entities->media ?? [] as $media) {
1103                         if (!empty($media->media_url_https)) {
1104                                 $images[] = [
1105                                         'src'    => $media->media_url_https,
1106                                         'width'  => $media->sizes->thumb->w,
1107                                         'height' => $media->sizes->thumb->h,
1108                                 ];
1109                         }
1110                 }
1111
1112                 $b['text'] = [
1113                         'data' => [
1114                                 'type' => 'link',
1115                                 'url' => $item['plink'],
1116                                 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1117                                 'text' => BBCode::toPlaintext($item['body'], false),
1118                                 'images' => $images,
1119                         ],
1120                         'contentType' => 'attachment',
1121                         'success' => true,
1122                 ];
1123         } else {
1124                 $b['text'] = BBCode::getShareOpeningTag(
1125                         $item['author-name'],
1126                         $item['author-link'],
1127                         $item['author-avatar'],
1128                         $item['plink'],
1129                         $item['created']
1130                 );
1131                 $b['text'] .= $item['body'] . '[/share]';
1132         }
1133 }
1134
1135
1136 /*********************
1137  *
1138  * General functions
1139  *
1140  *********************/
1141
1142
1143 /**
1144  * @brief Build the item array for the mirrored post
1145  *
1146  * @param App $a Application class
1147  * @param integer $uid User id
1148  * @param object $post Twitter object with the post
1149  *
1150  * @return array item data to be posted
1151  */
1152 function twitter_do_mirrorpost(App $a, int $uid, $post)
1153 {
1154         $datarray['uid'] = $uid;
1155         $datarray['extid'] = 'twitter::' . $post->id;
1156         $datarray['title'] = '';
1157
1158         if (!empty($post->retweeted_status)) {
1159                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1160                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1161
1162                 if (empty($item['body'])) {
1163                         return [];
1164                 }
1165
1166                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1167                         $item['author-name'],
1168                         $item['author-link'],
1169                         $item['author-avatar'],
1170                         $item['plink'],
1171                         $item['created']
1172                 );
1173
1174                 $datarray['body'] .= $item['body'] . '[/share]';
1175         } else {
1176                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1177
1178                 if (empty($item['body'])) {
1179                         return [];
1180                 }
1181
1182                 $datarray['body'] = $item['body'];
1183         }
1184
1185         $datarray['app'] = $item['app'];
1186         $datarray['verb'] = $item['verb'];
1187
1188         if (isset($item['location'])) {
1189                 $datarray['location'] = $item['location'];
1190         }
1191
1192         if (isset($item['coord'])) {
1193                 $datarray['coord'] = $item['coord'];
1194         }
1195
1196         return $datarray;
1197 }
1198
1199 function twitter_fetchtimeline(App $a, int $uid)
1200 {
1201         $ckey    = DI::config()->get('twitter', 'consumerkey');
1202         $csecret = DI::config()->get('twitter', 'consumersecret');
1203         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1204         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1205         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1206
1207         $application_name = DI::config()->get('twitter', 'application_name');
1208
1209         if ($application_name == '') {
1210                 $application_name = DI::baseUrl()->getHostname();
1211         }
1212
1213         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1214
1215         // Ensure to have the own contact
1216         try {
1217                 twitter_fetch_own_contact($a, $uid);
1218         } catch (TwitterOAuthException $e) {
1219                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1220                 return;
1221         }
1222
1223         $parameters = [
1224                 'exclude_replies' => true,
1225                 'trim_user' => false,
1226                 'contributor_details' => true,
1227                 'include_rts' => true,
1228                 'tweet_mode' => 'extended',
1229                 'include_ext_alt_text' => true,
1230         ];
1231
1232         $first_time = ($lastid == '');
1233
1234         if ($lastid != '') {
1235                 $parameters['since_id'] = $lastid;
1236         }
1237
1238         try {
1239                 $items = $connection->get('statuses/user_timeline', $parameters);
1240         } catch (TwitterOAuthException $e) {
1241                 Logger::warning('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1242                 return;
1243         }
1244
1245         if (!is_array($items)) {
1246                 Logger::notice('No items', ['user' => $uid]);
1247                 return;
1248         }
1249
1250         $posts = array_reverse($items);
1251
1252         Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1253
1254         if (count($posts)) {
1255                 foreach ($posts as $post) {
1256                         if ($post->id_str > $lastid) {
1257                                 $lastid = $post->id_str;
1258                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1259                         }
1260
1261                         if ($first_time) {
1262                                 continue;
1263                         }
1264
1265                         if (!stristr($post->source, $application_name)) {
1266                                 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1267
1268                                 $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1269
1270                                 if (empty($mirrorpost['body'])) {
1271                                         continue;
1272                                 }
1273
1274                                 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1275
1276                                 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, PRIORITY_MEDIUM, Post\Delayed::UNPREPARED);
1277                         }
1278                 }
1279         }
1280         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1281         Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1282 }
1283
1284 function twitter_fix_avatar($avatar)
1285 {
1286         $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1287
1288         $info = Images::getInfoFromURLCached($new_avatar);
1289         if (!$info) {
1290                 $new_avatar = $avatar;
1291         }
1292
1293         return $new_avatar;
1294 }
1295
1296 function twitter_get_relation($uid, $target, $contact = [])
1297 {
1298         if (isset($contact['rel'])) {
1299                 $relation = $contact['rel'];
1300         } else {
1301                 $relation = 0;
1302         }
1303
1304         $ckey = DI::config()->get('twitter', 'consumerkey');
1305         $csecret = DI::config()->get('twitter', 'consumersecret');
1306         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1307         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1308         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1309
1310         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1311         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1312
1313         try {
1314                 $status = $connection->get('friendships/show', $parameters);
1315                 if ($connection->getLastHttpCode() !== 200) {
1316                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1317                 }
1318
1319                 $following = $status->relationship->source->following;
1320                 $followed = $status->relationship->source->followed_by;
1321
1322                 if ($following && !$followed) {
1323                         $relation = Contact::SHARING;
1324                 } elseif (!$following && $followed) {
1325                         $relation = Contact::FOLLOWER;
1326                 } elseif ($following && $followed) {
1327                         $relation = Contact::FRIEND;
1328                 } elseif (!$following && !$followed) {
1329                         $relation = 0;
1330                 }
1331
1332                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1333         } catch (Throwable $e) {
1334                 Logger::warning('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1335         }
1336
1337         return $relation;
1338 }
1339
1340 /**
1341  * @param $data
1342  * @return array
1343  */
1344 function twitter_user_to_contact($data)
1345 {
1346         if (empty($data->id_str)) {
1347                 return [];
1348         }
1349
1350         $baseurl = 'https://twitter.com';
1351         $url = $baseurl . '/' . $data->screen_name;
1352         $addr = $data->screen_name . '@twitter.com';
1353
1354         $fields = [
1355                 'url'      => $url,
1356                 'nurl'     => Strings::normaliseLink($url),
1357                 'uri-id'   => ItemURI::getIdByURI($url),
1358                 'network'  => Protocol::TWITTER,
1359                 'alias'    => 'twitter::' . $data->id_str,
1360                 'baseurl'  => $baseurl,
1361                 'name'     => $data->name,
1362                 'nick'     => $data->screen_name,
1363                 'addr'     => $addr,
1364                 'location' => $data->location,
1365                 'about'    => $data->description,
1366                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1367                 'header'   => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1368         ];
1369
1370         return $fields;
1371 }
1372
1373 function twitter_get_contact($data, int $uid = 0)
1374 {
1375         $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1376         if (DBA::isResult($contact)) {
1377                 return $contact['id'];
1378         } else {
1379                 return twitter_fetch_contact($uid, $data, false);
1380         }
1381 }
1382
1383 function twitter_fetch_contact($uid, $data, $create_user)
1384 {
1385         $fields = twitter_user_to_contact($data);
1386
1387         if (empty($fields)) {
1388                 return -1;
1389         }
1390
1391         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1392         $avatar = $fields['photo'];
1393         unset($fields['photo']);
1394
1395         // Update the public contact
1396         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1397         if (DBA::isResult($pcontact)) {
1398                 $cid = $pcontact['id'];
1399         } else {
1400                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1401         }
1402
1403         if (!empty($cid)) {
1404                 Contact::update($fields, ['id' => $cid]);
1405                 Contact::updateAvatar($cid, $avatar);
1406         } else {
1407                 Logger::warning('No contact found', ['fields' => $fields]);
1408         }
1409
1410         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1411         if (!DBA::isResult($contact) && empty($cid)) {
1412                 Logger::warning('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1413                 return 0;
1414         } elseif (!$create_user) {
1415                 return $cid;
1416         }
1417
1418         if (!DBA::isResult($contact)) {
1419                 $relation = twitter_get_relation($uid, $data->screen_name);
1420
1421                 // create contact record
1422                 $fields['uid'] = $uid;
1423                 $fields['created'] = DateTimeFormat::utcNow();
1424                 $fields['poll'] = 'twitter::' . $data->id_str;
1425                 $fields['rel'] = $relation;
1426                 $fields['priority'] = 1;
1427                 $fields['writable'] = true;
1428                 $fields['blocked'] = false;
1429                 $fields['readonly'] = false;
1430                 $fields['pending'] = false;
1431
1432                 if (!Contact::insert($fields)) {
1433                         return false;
1434                 }
1435
1436                 $contact_id = DBA::lastInsertId();
1437
1438                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1439         } else {
1440                 if ($contact['readonly'] || $contact['blocked']) {
1441                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1442                         return -1;
1443                 }
1444
1445                 $contact_id = $contact['id'];
1446                 $update = false;
1447
1448                 // Update the contact relation once per day
1449                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1450                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1451                         $update = true;
1452                 }
1453
1454                 if ($contact['name'] != $data->name) {
1455                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1456                         $update = true;
1457                 }
1458
1459                 if ($contact['nick'] != $data->screen_name) {
1460                         $fields['uri-date'] = DateTimeFormat::utcNow();
1461                         $update = true;
1462                 }
1463
1464                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1465                         $update = true;
1466                 }
1467
1468                 if ($update) {
1469                         $fields['updated'] = DateTimeFormat::utcNow();
1470                         Contact::update($fields, ['id' => $contact['id']]);
1471                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1472                 }
1473         }
1474
1475         Contact::updateAvatar($contact_id, $avatar);
1476
1477         return $contact_id;
1478 }
1479
1480 /**
1481  * @param string $screen_name
1482  * @return stdClass|null
1483  * @throws Exception
1484  */
1485 function twitter_fetchuser($screen_name)
1486 {
1487         $ckey = DI::config()->get('twitter', 'consumerkey');
1488         $csecret = DI::config()->get('twitter', 'consumersecret');
1489
1490         try {
1491                 // Fetching user data
1492                 $connection = new TwitterOAuth($ckey, $csecret);
1493                 $parameters = ['screen_name' => $screen_name];
1494                 $user = $connection->get('users/show', $parameters);
1495         } catch (TwitterOAuthException $e) {
1496                 Logger::warning('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1497                 return null;
1498         }
1499
1500         if (!is_object($user)) {
1501                 return null;
1502         }
1503
1504         return $user;
1505 }
1506
1507 /**
1508  * Replaces Twitter entities with Friendica-friendly links.
1509  *
1510  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1511  *
1512  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1513  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1514  * index to be correct even after the last replacement.
1515  *
1516  * @param string   $body
1517  * @param stdClass $status
1518  * @return array
1519  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1520  */
1521 function twitter_expand_entities($body, stdClass $status)
1522 {
1523         $plain = $body;
1524         $contains_urls = false;
1525
1526         $taglist = [];
1527
1528         $replacementList = [];
1529
1530         foreach ($status->entities->hashtags AS $hashtag) {
1531                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1532                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1533
1534                 $replacementList[$hashtag->indices[0]] = [
1535                         'replace' => $replace,
1536                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1537                 ];
1538         }
1539
1540         foreach ($status->entities->user_mentions AS $mention) {
1541                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1542                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1543
1544                 $replacementList[$mention->indices[0]] = [
1545                         'replace' => $replace,
1546                         'length' => $mention->indices[1] - $mention->indices[0],
1547                 ];
1548         }
1549
1550         foreach ($status->entities->urls ?? [] as $url) {
1551                 $plain = str_replace($url->url, '', $plain);
1552
1553                 if ($url->url && $url->expanded_url && $url->display_url) {
1554                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1555                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1556                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1557                         ) {
1558                                 $replacementList[$url->indices[0]] = [
1559                                         'replace' => '',
1560                                         'length' => $url->indices[1] - $url->indices[0],
1561                                 ];
1562                                 continue;
1563                         }
1564
1565                         $contains_urls = true;
1566
1567                         $expanded_url = $url->expanded_url;
1568
1569                         // Quickfix: Workaround for URL with '[' and ']' in it
1570                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1571                                 $expanded_url = $url->url;
1572                         }
1573
1574                         $replacementList[$url->indices[0]] = [
1575                                 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1576                                 'length' => $url->indices[1] - $url->indices[0],
1577                         ];
1578                 }
1579         }
1580
1581         krsort($replacementList);
1582
1583         foreach ($replacementList as $startIndex => $parameters) {
1584                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1585         }
1586
1587         $body = trim($body);
1588
1589         return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1590 }
1591
1592 /**
1593  * Store entity attachments
1594  *
1595  * @param integer $uriId
1596  * @param object $post Twitter object with the post
1597  */
1598 function twitter_store_attachments(int $uriId, $post)
1599 {
1600         if (!empty($post->extended_entities->media)) {
1601                 foreach ($post->extended_entities->media AS $medium) {
1602                         switch ($medium->type) {
1603                                 case 'photo':
1604                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1605
1606                                         $attachment['url'] = $medium->media_url_https . '?name=large';
1607                                         $attachment['width'] = $medium->sizes->large->w;
1608                                         $attachment['height'] = $medium->sizes->large->h;
1609
1610                                         if ($medium->sizes->small->w != $attachment['width']) {
1611                                                 $attachment['preview'] = $medium->media_url_https . '?name=small';
1612                                                 $attachment['preview-width'] = $medium->sizes->small->w;
1613                                                 $attachment['preview-height'] = $medium->sizes->small->h;
1614                                         }
1615
1616                                         $attachment['name'] = $medium->display_url ?? null;
1617                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1618                                         Logger::debug('Photo attachment', ['attachment' => $attachment]);
1619                                         Post\Media::insert($attachment);
1620                                         break;
1621                                 case 'video':
1622                                 case 'animated_gif':
1623                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1624                                         if (is_array($medium->video_info->variants)) {
1625                                                 $bitrate = 0;
1626                                                 // We take the video with the highest bitrate
1627                                                 foreach ($medium->video_info->variants AS $variant) {
1628                                                         if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1629                                                                 $attachment['url'] = $variant->url;
1630                                                                 $bitrate = $variant->bitrate;
1631                                                         }
1632                                                 }
1633                                         }
1634
1635                                         $attachment['name'] = $medium->display_url ?? null;
1636                                         $attachment['preview'] = $medium->media_url_https . ':small';
1637                                         $attachment['preview-width'] = $medium->sizes->small->w;
1638                                         $attachment['preview-height'] = $medium->sizes->small->h;
1639                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1640                                         Logger::debug('Video attachment', ['attachment' => $attachment]);
1641                                         Post\Media::insert($attachment);
1642                                         break;
1643                                 default:
1644                                         Logger::notice('Unknown media type', ['medium' => $medium]);
1645                         }
1646                 }
1647         }
1648
1649         if (!empty($post->entities->urls)) {
1650                 foreach ($post->entities->urls as $url) {
1651                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1652                         Logger::debug('Attached link', ['attachment' => $attachment]);
1653                         Post\Media::insert($attachment);
1654                 }
1655         }
1656 }
1657
1658 /**
1659  * @brief Fetch media entities and add media links to the body
1660  *
1661  * @param object  $post      Twitter object with the post
1662  * @param array   $postarray Array of the item that is about to be posted
1663  * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1664  */
1665 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1666 {
1667         // There are no media entities? So we quit.
1668         if (empty($post->extended_entities->media)) {
1669                 return;
1670         }
1671
1672         // This is a pure media post, first search for all media urls
1673         $media = [];
1674         foreach ($post->extended_entities->media AS $medium) {
1675                 if (!isset($media[$medium->url])) {
1676                         $media[$medium->url] = '';
1677                 }
1678                 switch ($medium->type) {
1679                         case 'photo':
1680                                 if (!empty($medium->ext_alt_text)) {
1681                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1682                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1683                                 } else {
1684                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1685                                 }
1686
1687                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1688                                 $postarray['post-type'] = Item::PT_IMAGE;
1689                                 break;
1690                         case 'video':
1691                                 // Currently deactivated, since this causes the video to be display before the content
1692                                 // We have to figure out a better way for declaring the post type and the display style.
1693                                 //$postarray['post-type'] = Item::PT_VIDEO;
1694                         case 'animated_gif':
1695                                 if (!empty($medium->ext_alt_text)) {
1696                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1697                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1698                                 } else {
1699                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1700                                 }
1701
1702                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1703                                 if (is_array($medium->video_info->variants)) {
1704                                         $bitrate = 0;
1705                                         // We take the video with the highest bitrate
1706                                         foreach ($medium->video_info->variants AS $variant) {
1707                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1708                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1709                                                         $bitrate = $variant->bitrate;
1710                                                 }
1711                                         }
1712                                 }
1713                                 break;
1714                 }
1715         }
1716
1717         if ($uriId != -1) {
1718                 foreach ($media AS $key => $value) {
1719                         $postarray['body'] = str_replace($key, '', $postarray['body']);
1720                 }
1721                 return;
1722         }
1723
1724         // Now we replace the media urls.
1725         foreach ($media AS $key => $value) {
1726                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1727         }
1728 }
1729
1730 /**
1731  * Undocumented function
1732  *
1733  * @param App $a
1734  * @param integer $uid User ID
1735  * @param object $post Incoming Twitter post
1736  * @param array $self
1737  * @param bool $create_user Should users be created?
1738  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1739  * @param bool $noquote
1740  * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1741  * @return array item array
1742  */
1743 function twitter_createpost(App $a, int $uid, $post, array $self, $create_user, bool $only_existing_contact, $noquote, int $uriId = 0): array
1744 {
1745         $postarray = [];
1746         $postarray['network'] = Protocol::TWITTER;
1747         $postarray['uid'] = $uid;
1748         $postarray['wall'] = 0;
1749         $postarray['uri'] = 'twitter::' . $post->id_str;
1750         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1751         $postarray['source'] = json_encode($post);
1752         $postarray['direction'] = Conversation::PULL;
1753
1754         if (empty($uriId)) {
1755                 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1756         }
1757
1758         // Don't import our own comments
1759         if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1760                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1761                 return [];
1762         }
1763
1764         $contactid = 0;
1765
1766         if ($post->in_reply_to_status_id_str != '') {
1767                 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1768
1769                 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1770                 if (!DBA::isResult($item)) {
1771                         $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => GRAVITY_COMMENT]);
1772                 }
1773
1774                 if (DBA::isResult($item)) {
1775                         $postarray['thr-parent'] = $item['uri'];
1776                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1777                 } else {
1778                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1779                 }
1780
1781                 // Is it me?
1782                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1783
1784                 if ($post->user->id_str == $own_id) {
1785                         $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1786                         if (DBA::isResult($self)) {
1787                                 $contactid = $self['id'];
1788
1789                                 $postarray['owner-name']   = $self['name'];
1790                                 $postarray['owner-link']   = $self['url'];
1791                                 $postarray['owner-avatar'] = $self['photo'];
1792                         } else {
1793                                 Logger::error('No self contact found', ['uid' => $uid]);
1794                                 return [];
1795                         }
1796                 }
1797                 // Don't create accounts of people who just comment something
1798                 $create_user = false;
1799         } else {
1800                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1801         }
1802
1803         if ($contactid == 0) {
1804                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1805
1806                 $postarray['owner-id']     = twitter_get_contact($post->user);
1807                 $postarray['owner-name']   = $post->user->name;
1808                 $postarray['owner-link']   = 'https://twitter.com/' . $post->user->screen_name;
1809                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1810         }
1811
1812         if (($contactid == 0) && !$only_existing_contact) {
1813                 $contactid = $self['id'];
1814         } elseif ($contactid <= 0) {
1815                 Logger::info('Contact ID is zero or less than zero.');
1816                 return [];
1817         }
1818
1819         $postarray['contact-id']    = $contactid;
1820         $postarray['verb']          = Activity::POST;
1821         $postarray['author-id']     = $postarray['owner-id'];
1822         $postarray['author-name']   = $postarray['owner-name'];
1823         $postarray['author-link']   = $postarray['owner-link'];
1824         $postarray['author-avatar'] = $postarray['owner-avatar'];
1825         $postarray['plink']         = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1826         $postarray['app']           = strip_tags($post->source);
1827
1828         if ($post->user->protected) {
1829                 $postarray['private']   = Item::PRIVATE;
1830                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1831         } else {
1832                 $postarray['private']   = Item::UNLISTED;
1833                 $postarray['allow_cid'] = '';
1834         }
1835
1836         if (!empty($post->full_text)) {
1837                 $postarray['body'] = $post->full_text;
1838         } else {
1839                 $postarray['body'] = $post->text;
1840         }
1841
1842         // When the post contains links then use the correct object type
1843         if (count($post->entities->urls) > 0) {
1844                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1845         }
1846
1847         // Search for media links
1848         twitter_media_entities($post, $postarray, $uriId);
1849
1850         $converted = twitter_expand_entities($postarray['body'], $post);
1851
1852         // When the post contains external links then images or videos are just "decorations".
1853         if (!empty($converted['urls'])) {
1854                 $postarray['post-type'] = Item::PT_NOTE;
1855         }
1856
1857         $postarray['body'] = $converted['body'];
1858         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1859         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1860
1861         if ($uriId > 0) {
1862                 twitter_store_tags($uriId, $converted['taglist']);
1863                 twitter_store_attachments($uriId, $post);
1864         }
1865
1866         if (!empty($post->place->name)) {
1867                 $postarray['location'] = $post->place->name;
1868         }
1869         if (!empty($post->place->full_name)) {
1870                 $postarray['location'] = $post->place->full_name;
1871         }
1872         if (!empty($post->geo->coordinates)) {
1873                 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
1874         }
1875         if (!empty($post->coordinates->coordinates)) {
1876                 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
1877         }
1878         if (!empty($post->retweeted_status)) {
1879                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1880
1881                 if (empty($retweet['body'])) {
1882                         return [];
1883                 }
1884
1885                 if (!$noquote) {
1886                         // Store the original tweet
1887                         Item::insert($retweet);
1888
1889                         // CHange the other post into a reshare activity
1890                         $postarray['verb'] = Activity::ANNOUNCE;
1891                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1892                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1893
1894                         $postarray['thr-parent'] = $retweet['uri'];
1895                 } else {
1896                         $retweet['source']       = $postarray['source'];
1897                         $retweet['direction']    = $postarray['direction'];
1898                         $retweet['private']      = $postarray['private'];
1899                         $retweet['allow_cid']    = $postarray['allow_cid'];
1900                         $retweet['contact-id']   = $postarray['contact-id'];
1901                         $retweet['owner-id']     = $postarray['owner-id'];
1902                         $retweet['owner-name']   = $postarray['owner-name'];
1903                         $retweet['owner-link']   = $postarray['owner-link'];
1904                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1905
1906                         $postarray = $retweet;
1907                 }
1908         }
1909
1910         if (!empty($post->quoted_status)) {
1911                 if ($noquote) {
1912                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1913                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1914                 } else {
1915                         $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
1916                         if (!empty($quoted['body'])) {
1917                                 Item::insert($quoted);
1918                                 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
1919                                 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
1920
1921                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1922                                                 $quoted['author-name'],
1923                                                 $quoted['author-link'],
1924                                                 $quoted['author-avatar'],
1925                                                 $quoted['plink'],
1926                                                 $quoted['created'],
1927                                                 $post['guid'] ?? ''
1928                                         );
1929
1930                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1931                         } else {
1932                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1933                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
1934                         }
1935                 }
1936         }
1937
1938         return $postarray;
1939 }
1940
1941 /**
1942  * Store tags and mentions
1943  *
1944  * @param integer $uriId
1945  * @param array $taglist
1946  * @return void
1947  */
1948 function twitter_store_tags(int $uriId, array $taglist)
1949 {
1950         foreach ($taglist as $tag) {
1951                 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
1952         }
1953 }
1954
1955 function twitter_fetchparentposts(App $a, int $uid, $post, TwitterOAuth $connection, array $self)
1956 {
1957         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
1958
1959         $posts = [];
1960
1961         while (!empty($post->in_reply_to_status_id_str)) {
1962                 try {
1963                         $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
1964                 } catch (TwitterOAuthException $e) {
1965                         Logger::warning('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
1966                         break;
1967                 }
1968
1969                 if (empty($post)) {
1970                         Logger::info("twitter_fetchparentposts: Can't fetch post");
1971                         break;
1972                 }
1973
1974                 if (empty($post->id_str)) {
1975                         Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
1976                         break;
1977                 }
1978
1979                 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1980                         break;
1981                 }
1982
1983                 $posts[] = $post;
1984         }
1985
1986         Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
1987
1988         $posts = array_reverse($posts);
1989
1990         if (!empty($posts)) {
1991                 foreach ($posts as $post) {
1992                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1993
1994                         if (empty($postarray['body'])) {
1995                                 continue;
1996                         }
1997
1998                         $item = Item::insert($postarray);
1999
2000                         $postarray['id'] = $item;
2001
2002                         Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2003                 }
2004         }
2005 }
2006
2007 function twitter_fetchhometimeline(App $a, int $uid)
2008 {
2009         $ckey    = DI::config()->get('twitter', 'consumerkey');
2010         $csecret = DI::config()->get('twitter', 'consumersecret');
2011         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2012         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2013         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2014         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2015
2016         Logger::info('Fetching timeline', ['uid' => $uid]);
2017
2018         $application_name = DI::config()->get('twitter', 'application_name');
2019
2020         if ($application_name == '') {
2021                 $application_name = DI::baseUrl()->getHostname();
2022         }
2023
2024         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2025
2026         try {
2027                 $own_contact = twitter_fetch_own_contact($a, $uid);
2028         } catch (TwitterOAuthException $e) {
2029                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2030                 return;
2031         }
2032
2033         $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2034         if (DBA::isResult($contact)) {
2035                 $own_id = $contact['nick'];
2036         } else {
2037                 Logger::warning('Own twitter contact not found', ['uid' => $uid]);
2038                 return;
2039         }
2040
2041         $self = User::getOwnerDataById($uid);
2042         if ($self === false) {
2043                 Logger::warning('Own contact not found', ['uid' => $uid]);
2044                 return;
2045         }
2046
2047         $parameters = [
2048                 'exclude_replies' => false,
2049                 'trim_user' => false,
2050                 'contributor_details' => true,
2051                 'include_rts' => true,
2052                 'tweet_mode' => 'extended',
2053                 'include_ext_alt_text' => true,
2054                 //'count' => 200,
2055         ];
2056
2057         // Fetching timeline
2058         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2059
2060         $first_time = ($lastid == '');
2061
2062         if ($lastid != '') {
2063                 $parameters['since_id'] = $lastid;
2064         }
2065
2066         try {
2067                 $items = $connection->get('statuses/home_timeline', $parameters);
2068         } catch (TwitterOAuthException $e) {
2069                 Logger::warning('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2070                 return;
2071         }
2072
2073         if (!is_array($items)) {
2074                 Logger::warning('home timeline is no array', ['items' => $items]);
2075                 return;
2076         }
2077
2078         if (empty($items)) {
2079                 Logger::notice('No new timeline content', ['uid' => $uid]);
2080                 return;
2081         }
2082
2083         $posts = array_reverse($items);
2084
2085         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2086
2087         if (count($posts)) {
2088                 foreach ($posts as $post) {
2089                         if ($post->id_str > $lastid) {
2090                                 $lastid = $post->id_str;
2091                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2092                         }
2093
2094                         if ($first_time) {
2095                                 continue;
2096                         }
2097
2098                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2099                                 Logger::info('Skip previously sent post');
2100                                 continue;
2101                         }
2102
2103                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2104                                 Logger::info('Skip post that will be mirrored');
2105                                 continue;
2106                         }
2107
2108                         if ($post->in_reply_to_status_id_str != '') {
2109                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2110                         }
2111
2112                         Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2113
2114                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
2115
2116                         if (empty($postarray['body']) || trim($postarray['body']) == '') {
2117                                 Logger::info('Empty body for post ' . $post->id_str . ' and user ' . $uid);
2118                                 continue;
2119                         }
2120
2121                         $notify = false;
2122
2123                         if (empty($postarray['thr-parent'])) {
2124                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2125                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2126                                         $notify = PRIORITY_MEDIUM;
2127                                 }
2128                         }
2129
2130                         $item = Item::insert($postarray, $notify);
2131                         $postarray['id'] = $item;
2132
2133                         Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2134                 }
2135         }
2136         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2137
2138         Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2139
2140         // Fetching mentions
2141         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2142
2143         $first_time = ($lastid == '');
2144
2145         if ($lastid != '') {
2146                 $parameters['since_id'] = $lastid;
2147         }
2148
2149         try {
2150                 $items = $connection->get('statuses/mentions_timeline', $parameters);
2151         } catch (TwitterOAuthException $e) {
2152                 Logger::warning('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2153                 return;
2154         }
2155
2156         if (!is_array($items)) {
2157                 Logger::warning('mentions are no arrays', ['items' => $items]);
2158                 return;
2159         }
2160
2161         $posts = array_reverse($items);
2162
2163         Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2164
2165         if (count($posts)) {
2166                 foreach ($posts as $post) {
2167                         if ($post->id_str > $lastid) {
2168                                 $lastid = $post->id_str;
2169                         }
2170
2171                         if ($first_time) {
2172                                 continue;
2173                         }
2174
2175                         if ($post->in_reply_to_status_id_str != '') {
2176                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2177                         }
2178
2179                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2180
2181                         if (empty($postarray['body'])) {
2182                                 continue;
2183                         }
2184
2185                         $item = Item::insert($postarray);
2186
2187                         Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2188                 }
2189         }
2190
2191         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2192
2193         Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2194 }
2195
2196 function twitter_fetch_own_contact(App $a, int $uid)
2197 {
2198         $ckey    = DI::config()->get('twitter', 'consumerkey');
2199         $csecret = DI::config()->get('twitter', 'consumersecret');
2200         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2201         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2202
2203         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2204
2205         $contact_id = 0;
2206
2207         if ($own_id == '') {
2208                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2209
2210                 // Fetching user data
2211                 // get() may throw TwitterOAuthException, but we will catch it later
2212                 $user = $connection->get('account/verify_credentials');
2213                 if (empty($user->id_str)) {
2214                         return false;
2215                 }
2216
2217                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2218
2219                 $contact_id = twitter_fetch_contact($uid, $user, true);
2220         } else {
2221                 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2222                 if (DBA::isResult($contact)) {
2223                         $contact_id = $contact['id'];
2224                 } else {
2225                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2226                 }
2227         }
2228
2229         return $contact_id;
2230 }
2231
2232 function twitter_is_retweet(App $a, int $uid, string $body): bool
2233 {
2234         $body = trim($body);
2235
2236         // Skip if it isn't a pure repeated messages
2237         // Does it start with a share?
2238         if (strpos($body, '[share') > 0) {
2239                 return false;
2240         }
2241
2242         // Does it end with a share?
2243         if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2244                 return false;
2245         }
2246
2247         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2248         // Skip if there is no shared message in there
2249         if ($body == $attributes) {
2250                 return false;
2251         }
2252
2253         $link = '';
2254         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2255         if (!empty($matches[1])) {
2256                 $link = $matches[1];
2257         }
2258
2259         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2260         if (!empty($matches[1])) {
2261                 $link = $matches[1];
2262         }
2263
2264         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2265         if ($id == $link) {
2266                 return false;
2267         }
2268         return twitter_retweet($uid, $id);
2269 }
2270
2271 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2272 {
2273         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2274
2275         $result = twitter_api_post('statuses/retweet', $id, $uid);
2276
2277         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2278
2279         if (!empty($item_id) && !empty($result->id_str)) {
2280                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2281                 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2282         }
2283
2284         return !isset($result->errors);
2285 }
2286
2287 function twitter_update_mentions(string $body): string
2288 {
2289         $URLSearchString = '^\[\]';
2290         $return = preg_replace_callback(
2291                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2292                 function ($matches) {
2293                         if (strpos($matches[1], 'twitter.com')) {
2294                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2295                         } else {
2296                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2297                         }
2298
2299                         return $return;
2300                 },
2301                 $body
2302         );
2303
2304         return $return;
2305 }
2306
2307 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2308 {
2309         if (empty($author_contact)) {
2310                 return $content . "\n\n" . $attributes['link'];
2311         }
2312
2313         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2314                 $mention = '@' . $author_contact['nick'];
2315         } else {
2316                 $mention = $author_contact['addr'];
2317         }
2318
2319         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2320 }