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