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