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