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