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