5167e022375cc8a1e77fa5476d2dbb2530cb6607
[friendica-addons.git/.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('=(.*)@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::log("twitter_action '" . $action . "' ID: " . $pid . " data: " . print_r($post, true), Logger::DATA);
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                         break;
504                 case "unlike":
505                         $result = $connection->post('favorites/destroy', $post);
506                         break;
507                 default:
508                         Logger::log('Unhandled action ' . $action, Logger::DEBUG);
509                         $result = [];
510         }
511         Logger::log("twitter_action '" . $action . "' send, result: " . print_r($result, true), Logger::DEBUG);
512 }
513
514 function twitter_post_hook(App $a, array &$b)
515 {
516         // Post to Twitter
517         if (!DI::pConfig()->get($b["uid"], 'twitter', 'import')
518                 && ($b['deleted'] || $b['private'] || ($b['created'] !== $b['edited']))) {
519                 return;
520         }
521
522         if ($b['parent'] != $b['id']) {
523                 Logger::log("twitter_post_hook: parameter " . print_r($b, true), Logger::DATA);
524
525                 // Looking if its a reply to a twitter post
526                 if ((substr($b["parent-uri"], 0, 9) != "twitter::")
527                         && (substr($b["extid"], 0, 9) != "twitter::")
528                         && (substr($b["thr-parent"], 0, 9) != "twitter::"))
529                 {
530                         Logger::log("twitter_post_hook: no twitter post " . $b["parent"]);
531                         return;
532                 }
533
534                 $condition = ['uri' => $b["thr-parent"], 'uid' => $b["uid"]];
535                 $orig_post = Item::selectFirst([], $condition);
536                 if (!DBA::isResult($orig_post)) {
537                         Logger::log("twitter_post_hook: no parent found " . $b["thr-parent"]);
538                         return;
539                 } else {
540                         $iscomment = true;
541                 }
542
543
544                 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post["author-link"]);
545                 $nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
546                 $nicknameplain = "@" . $nicknameplain;
547
548                 Logger::log("twitter_post_hook: comparing " . $nickname . " and " . $nicknameplain . " with " . $b["body"], Logger::DEBUG);
549                 if ((strpos($b["body"], $nickname) === false) && (strpos($b["body"], $nicknameplain) === false)) {
550                         $b["body"] = $nickname . " " . $b["body"];
551                 }
552
553                 Logger::log("twitter_post_hook: parent found " . print_r($orig_post, true), Logger::DATA);
554         } else {
555                 $iscomment = false;
556
557                 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
558                         return;
559                 }
560
561                 // Dont't post if the post doesn't belong to us.
562                 // This is a check for forum postings
563                 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
564                 if ($b['contact-id'] != $self['id']) {
565                         return;
566                 }
567         }
568
569         if (($b['verb'] == Activity::POST) && $b['deleted']) {
570                 twitter_action($a, $b["uid"], substr($orig_post["uri"], 9), "delete");
571         }
572
573         if ($b['verb'] == Activity::LIKE) {
574                 Logger::log("twitter_post_hook: parameter 2 " . substr($b["thr-parent"], 9), Logger::DEBUG);
575                 if ($b['deleted']) {
576                         twitter_action($a, $b["uid"], substr($b["thr-parent"], 9), "unlike");
577                 } else {
578                         twitter_action($a, $b["uid"], substr($b["thr-parent"], 9), "like");
579                 }
580
581                 return;
582         }
583
584         if ($b['deleted'] || ($b['created'] !== $b['edited'])) {
585                 return;
586         }
587
588         // if post comes from twitter don't send it back
589         if ($b['extid'] == Protocol::TWITTER) {
590                 return;
591         }
592
593         if ($b['app'] == "Twitter") {
594                 return;
595         }
596
597         Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
598
599         DI::pConfig()->load($b['uid'], 'twitter');
600
601         $ckey    = DI::config()->get('twitter', 'consumerkey');
602         $csecret = DI::config()->get('twitter', 'consumersecret');
603         $otoken  = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
604         $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
605
606         if ($ckey && $csecret && $otoken && $osecret) {
607                 Logger::log('twitter: we have customer key and oauth stuff, going to send.', Logger::DEBUG);
608
609                 // If it's a repeated message from twitter then do a native retweet and exit
610                 if (twitter_is_retweet($a, $b['uid'], $b['body'])) {
611                         return;
612                 }
613
614                 Codebird::setConsumerKey($ckey, $csecret);
615                 $cb = Codebird::getInstance();
616                 $cb->setToken($otoken, $osecret);
617
618                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
619
620                 // Set the timeout for upload to 30 seconds
621                 $connection->setTimeouts(10, 30);
622
623                 $max_char = 280;
624
625                 // Handling non-native reshares
626                 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
627                         $b['body'],
628                         function (array $attributes, array $author_contact, $content, $is_quote_share) {
629                                 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
630                         }
631                 );
632
633                 $b['body'] = twitter_update_mentions($b['body']);
634
635                 $msgarr = ItemContent::getPlaintextPost($b, $max_char, true, BBCode::TWITTER);
636                 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
637                 $msg = $msgarr["text"];
638
639                 if (($msg == "") && isset($msgarr["title"])) {
640                         $msg = Plaintext::shorten($msgarr["title"], $max_char - 50);
641                 }
642
643                 if (!empty($msgarr['url']) && ($msgarr['url'] == $b['plink']) && !empty($msgarr['images']) && (count($msgarr['images']) <= 4)) {
644                         $url_added = false;
645                 } elseif (isset($msgarr["url"]) && ($msgarr["type"] != "photo")) {
646                         $msg .= "\n" . $msgarr["url"];
647                         $url_added = true;
648                 } else {
649                         $url_added = false;
650                 }
651
652                 if (empty($msg)) {
653                         Logger::info('Empty message', ['id' => $b['id']]);
654                         return;
655                 }
656
657                 // and now tweet it :-)
658                 $post = [];
659
660                 if (!empty($msgarr['images'])) {
661                         Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images']]);
662                         try {
663                                 $media_ids = [];
664                                 foreach ($msgarr['images'] as $image) {
665                                         if (count($media_ids) == 4) {
666                                                 continue;
667                                         }
668
669                                         $img_str = Network::fetchUrl($image['url']);
670
671                                         $tempfile = tempnam(get_temppath(), 'cache');
672                                         file_put_contents($tempfile, $img_str);
673
674                                         Logger::info('Uploading', ['id' => $b['id'], 'image' => $image['url']]);
675                                         $media = $connection->upload('media/upload', ['media' => $tempfile]);
676
677                                         unlink($tempfile);
678
679                                         if (isset($media->media_id_string)) {
680                                                 $media_ids[] = $media->media_id_string;
681
682                                                 if (!empty($image['description'])) {
683                                                         $data = ['media_id' => $media->media_id_string,
684                                                                 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
685                                                         $ret = $cb->media_metadata_create($data);
686                                                         Logger::info('Metadata create', ['id' => $b['id'], 'data' => $data, 'return' => json_encode($ret)]);
687                                                 }
688                                         } else {
689                                                 throw new Exception('Failed upload', ['id' => $b['id'], 'image' => $image['url']]);
690                                         }
691                                 }
692                                 $post['media_ids'] = implode(',', $media_ids);
693                                 if (empty($post['media_ids'])) {
694                                         unset($post['media_ids']);
695                                 }
696                         } catch (Exception $e) {
697                                 Logger::info('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
698                         }
699                 }
700
701                 $post['status'] = $msg;
702
703                 if ($iscomment) {
704                         $post["in_reply_to_status_id"] = substr($orig_post["uri"], 9);
705                 }
706
707                 $url = 'statuses/update';
708                 $result = $connection->post($url, $post);
709                 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
710
711                 if (!empty($result->source)) {
712                         DI::config()->set("twitter", "application_name", strip_tags($result->source));
713                 }
714
715                 if (!empty($result->errors)) {
716                         Logger::info('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
717                         Worker::defer();
718                 } elseif ($iscomment) {
719                         Logger::info('Update extid', ['id' => $b['id'], 'extid' => $result->id_str]);
720                         Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $b['id']]);
721                 }
722         }
723 }
724
725 function twitter_addon_admin_post(App $a)
726 {
727         $consumerkey    = !empty($_POST['consumerkey'])    ? Strings::escapeTags(trim($_POST['consumerkey']))    : '';
728         $consumersecret = !empty($_POST['consumersecret']) ? Strings::escapeTags(trim($_POST['consumersecret'])) : '';
729         DI::config()->set('twitter', 'consumerkey', $consumerkey);
730         DI::config()->set('twitter', 'consumersecret', $consumersecret);
731         info(DI::l10n()->t('Settings updated.') . EOL);
732 }
733
734 function twitter_addon_admin(App $a, &$o)
735 {
736         $t = Renderer::getMarkupTemplate("admin.tpl", "addon/twitter/");
737
738         $o = Renderer::replaceMacros($t, [
739                 '$submit' => DI::l10n()->t('Save Settings'),
740                 // name, label, value, help, [extra values]
741                 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
742                 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
743         ]);
744 }
745
746 function twitter_cron(App $a)
747 {
748         $last = DI::config()->get('twitter', 'last_poll');
749
750         $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
751         if (!$poll_interval) {
752                 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
753         }
754
755         if ($last) {
756                 $next = $last + ($poll_interval * 60);
757                 if ($next > time()) {
758                         Logger::notice('twitter: poll intervall not reached');
759                         return;
760                 }
761         }
762         Logger::notice('twitter: cron_start');
763
764         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'mirror_posts' AND `v` = '1'");
765         if (DBA::isResult($r)) {
766                 foreach ($r as $rr) {
767                         Logger::notice('Fetching', ['user' => $rr['uid']]);
768                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 1, (int) $rr['uid']);
769                 }
770         }
771
772         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
773         if ($abandon_days < 1) {
774                 $abandon_days = 0;
775         }
776
777         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
778
779         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1'");
780         if (DBA::isResult($r)) {
781                 foreach ($r as $rr) {
782                         if ($abandon_days != 0) {
783                                 $user = q("SELECT `login_date` FROM `user` WHERE uid=%d AND `login_date` >= '%s'", $rr['uid'], $abandon_limit);
784                                 if (!DBA::isResult($user)) {
785                                         Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
786                                         continue;
787                                 }
788                         }
789
790                         Logger::notice('importing timeline', ['user' => $rr['uid']]);
791                         Worker::add(['priority' => PRIORITY_MEDIUM, 'force_priority' => true], "addon/twitter/twitter_sync.php", 2, (int) $rr['uid']);
792                         /*
793                           // To-Do
794                           // check for new contacts once a day
795                           $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
796                           if($last_contact_check)
797                           $next_contact_check = $last_contact_check + 86400;
798                           else
799                           $next_contact_check = 0;
800
801                           if($next_contact_check <= time()) {
802                           pumpio_getallusers($a, $rr["uid"]);
803                           DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
804                           }
805                          */
806                 }
807         }
808
809         Logger::notice('twitter: cron_end');
810
811         DI::config()->set('twitter', 'last_poll', time());
812 }
813
814 function twitter_expire(App $a)
815 {
816         $days = DI::config()->get('twitter', 'expire');
817
818         if ($days == 0) {
819                 return;
820         }
821
822         $r = Item::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
823         while ($row = DBA::fetch($r)) {
824                 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
825                 DBA::delete('item', ['id' => $row['id']]);
826         }
827         DBA::close($r);
828
829         Logger::notice('twitter_expire: expire_start');
830
831         $r = q("SELECT * FROM `pconfig` WHERE `cat` = 'twitter' AND `k` = 'import' AND `v` = '1' ORDER BY RAND()");
832         if (DBA::isResult($r)) {
833                 foreach ($r as $rr) {
834                         Logger::notice('twitter_expire', ['user' => $rr['uid']]);
835                         Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
836                 }
837         }
838
839         Logger::notice('twitter_expire: expire_end');
840 }
841
842 function twitter_prepare_body(App $a, array &$b)
843 {
844         if ($b["item"]["network"] != Protocol::TWITTER) {
845                 return;
846         }
847
848         if ($b["preview"]) {
849                 $max_char = 280;
850                 $item = $b["item"];
851                 $item["plink"] = DI::baseUrl()->get() . "/display/" . $item["guid"];
852
853                 $condition = ['uri' => $item["thr-parent"], 'uid' => local_user()];
854                 $orig_post = Item::selectFirst(['author-link'], $condition);
855                 if (DBA::isResult($orig_post)) {
856                         $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post["author-link"]);
857                         $nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
858                         $nicknameplain = "@" . $nicknameplain;
859
860                         if ((strpos($item["body"], $nickname) === false) && (strpos($item["body"], $nicknameplain) === false)) {
861                                 $item["body"] = $nickname . " " . $item["body"];
862                         }
863                 }
864
865                 $msgarr = ItemContent::getPlaintextPost($item, $max_char, true, BBCode::TWITTER);
866                 $msg = $msgarr["text"];
867
868                 if (isset($msgarr["url"]) && ($msgarr["type"] != "photo")) {
869                         $msg .= " " . $msgarr["url"];
870                 }
871
872                 if (isset($msgarr["image"])) {
873                         $msg .= " " . $msgarr["image"];
874                 }
875
876                 $b['html'] = nl2br(htmlspecialchars($msg));
877         }
878 }
879
880 /**
881  * @brief Build the item array for the mirrored post
882  *
883  * @param App $a Application class
884  * @param integer $uid User id
885  * @param object $post Twitter object with the post
886  *
887  * @return array item data to be posted
888  */
889 function twitter_do_mirrorpost(App $a, $uid, $post)
890 {
891         $datarray['api_source'] = true;
892         $datarray['profile_uid'] = $uid;
893         $datarray['extid'] = Protocol::TWITTER;
894         $datarray['message_id'] = Item::newURI($uid, Protocol::TWITTER . ':' . $post->id);
895         $datarray['protocol'] = Conversation::PARCEL_TWITTER;
896         $datarray['source'] = json_encode($post);
897         $datarray['title'] = '';
898
899         if (!empty($post->retweeted_status)) {
900                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
901                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
902
903                 if (empty($item['body'])) {
904                         return [];
905                 }
906
907                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
908                         $item['author-name'],
909                         $item['author-link'],
910                         $item['author-avatar'],
911                         '',
912                         $item['created'],
913                         $item['plink']
914                 );
915
916                 $datarray['body'] .= $item['body'] . '[/share]';
917         } else {
918                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
919
920                 if (empty($item['body'])) {
921                         return [];
922                 }
923
924                 $datarray['body'] = $item['body'];
925         }
926
927         $datarray['source'] = $item['app'];
928         $datarray['verb'] = $item['verb'];
929
930         if (isset($item['location'])) {
931                 $datarray['location'] = $item['location'];
932         }
933
934         if (isset($item['coord'])) {
935                 $datarray['coord'] = $item['coord'];
936         }
937
938         return $datarray;
939 }
940
941 function twitter_fetchtimeline(App $a, $uid)
942 {
943         $ckey    = DI::config()->get('twitter', 'consumerkey');
944         $csecret = DI::config()->get('twitter', 'consumersecret');
945         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
946         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
947         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
948
949         $application_name = DI::config()->get('twitter', 'application_name');
950
951         if ($application_name == "") {
952                 $application_name = DI::baseUrl()->getHostname();
953         }
954
955         $has_picture = false;
956
957         require_once 'mod/item.php';
958         require_once 'mod/share.php';
959
960         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
961
962         $parameters = ["exclude_replies" => true, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
963
964         $first_time = ($lastid == "");
965
966         if ($lastid != "") {
967                 $parameters["since_id"] = $lastid;
968         }
969
970         try {
971                 $items = $connection->get('statuses/user_timeline', $parameters);
972         } catch (TwitterOAuthException $e) {
973                 Logger::notice('Error fetching timeline', ['user' => $uid, 'message' => $e->getMessage()]);
974                 return;
975         }
976
977         if (!is_array($items)) {
978                 Logger::notice('No items', ['user' => $uid]);
979                 return;
980         }
981
982         $posts = array_reverse($items);
983
984         Logger::log('Starting from ID ' . $lastid . ' for user ' . $uid, Logger::DEBUG);
985
986         if (count($posts)) {
987                 foreach ($posts as $post) {
988                         if ($post->id_str > $lastid) {
989                                 $lastid = $post->id_str;
990                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
991                         }
992
993                         if ($first_time) {
994                                 continue;
995                         }
996
997                         if (!stristr($post->source, $application_name)) {
998                                 $_SESSION["authenticated"] = true;
999                                 $_SESSION["uid"] = $uid;
1000
1001                                 Logger::log('Preparing Twitter ID ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1002
1003                                 $_REQUEST = twitter_do_mirrorpost($a, $uid, $post);
1004
1005                                 if (empty($_REQUEST['body'])) {
1006                                         continue;
1007                                 }
1008
1009                                 Logger::log('Posting Twitter ID ' . $post->id_str . ' for user ' . $uid);
1010
1011                                 item_post($a);
1012                         }
1013                 }
1014         }
1015         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1016         Logger::log('Last ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1017 }
1018
1019 function twitter_fix_avatar($avatar)
1020 {
1021         $new_avatar = str_replace("_normal.", ".", $avatar);
1022
1023         $info = Images::getInfoFromURLCached($new_avatar);
1024         if (!$info) {
1025                 $new_avatar = $avatar;
1026         }
1027
1028         return $new_avatar;
1029 }
1030
1031 function twitter_get_relation($uid, $target, $contact = [])
1032 {
1033         if (isset($contact['rel'])) {
1034                 $relation = $contact['rel'];
1035         } else {
1036                 $relation = 0;
1037         }
1038
1039         $ckey = DI::config()->get('twitter', 'consumerkey');
1040         $csecret = DI::config()->get('twitter', 'consumersecret');
1041         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1042         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1043         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1044
1045         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1046         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1047
1048         try {
1049                 $status = $connection->get('friendships/show', $parameters);
1050         } catch (TwitterOAuthException $e) {
1051                 Logger::info('Error fetching friendship status', ['user' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1052                 return $relation;
1053         }
1054
1055         $following = $status->relationship->source->following;
1056         $followed = $status->relationship->source->followed_by;
1057
1058         if ($following && !$followed) {
1059                 $relation = Contact::SHARING;
1060         } elseif (!$following && $followed) {
1061                 $relation = Contact::FOLLOWER;
1062         } elseif ($following && $followed) {
1063                 $relation = Contact::FRIEND;
1064         } elseif (!$following && !$followed) {
1065                 $relation = 0;
1066         }
1067
1068         Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1069
1070         return $relation;
1071 }
1072
1073 function twitter_user_to_contact($data)
1074 {
1075         if (empty($data->id_str)) {
1076                 return -1;
1077         }
1078
1079         $baseurl = 'https://twitter.com';
1080         $url = $baseurl . '/' . $data->screen_name;
1081         $addr = $data->screen_name . '@twitter.com';
1082
1083         $fields = [
1084                 'url'      => $url,
1085                 'network'  => Protocol::TWITTER,
1086                 'alias'    => 'twitter::' . $data->id_str,
1087                 'baseurl'  => $baseurl,
1088                 'name'     => $data->name,
1089                 'nick'     => $data->screen_name,
1090                 'addr'     => $addr,
1091                 'location' => $data->location,
1092                 'about'    => $data->description,
1093                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1094         ];
1095
1096         return $fields;
1097 }
1098
1099 function twitter_fetch_contact($uid, $data, $create_user)
1100 {
1101         $fields = twitter_user_to_contact($data);
1102
1103         if (empty($fields)) {
1104                 return -1;
1105         }
1106
1107         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1108         $avatar = $fields['photo'];
1109         unset($fields['photo']);
1110
1111         // Update the public contact
1112         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => "twitter::" . $data->id_str]);
1113         if (DBA::isResult($pcontact)) {
1114                 $cid = $pcontact['id'];
1115         } else {
1116                 $cid = Contact::getIdForURL($fields['url'], 0, true, $fields);
1117         }
1118
1119         if (!empty($cid)) {
1120                 DBA::update('contact', $fields, ['id' => $cid]);
1121                 Contact::updateAvatar($avatar, 0, $cid);
1122         }
1123
1124         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => "twitter::" . $data->id_str]);
1125         if (!DBA::isResult($contact) && !$create_user) {
1126                 Logger::info('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1127                 return 0;
1128         }
1129
1130         if (!DBA::isResult($contact)) {
1131                 $relation = twitter_get_relation($uid, $data->screen_name);
1132
1133                 // create contact record
1134                 $fields['uid'] = $uid;
1135                 $fields['created'] = DateTimeFormat::utcNow();
1136                 $fields['nurl'] = Strings::normaliseLink($fields['url']);
1137                 $fields['poll'] = 'twitter::' . $data->id_str;
1138                 $fields['rel'] = $relation;
1139                 $fields['priority'] = 1;
1140                 $fields['writable'] = true;
1141                 $fields['blocked'] = false;
1142                 $fields['readonly'] = false;
1143                 $fields['pending'] = false;
1144
1145                 if (!DBA::insert('contact', $fields)) {
1146                         return false;
1147                 }
1148
1149                 $contact_id = DBA::lastInsertId();
1150
1151                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1152
1153                 Contact::updateAvatar($avatar, $uid, $contact_id);
1154         } else {
1155                 if ($contact["readonly"] || $contact["blocked"]) {
1156                         Logger::log("twitter_fetch_contact: Contact '" . $contact["nick"] . "' is blocked or readonly.", Logger::DEBUG);
1157                         return -1;
1158                 }
1159
1160                 $contact_id = $contact['id'];
1161                 $update = false;
1162
1163                 // Update the contact relation once per day
1164                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1165                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1166                         $update = true;
1167                 }
1168
1169                 Contact::updateAvatar($avatar, $uid, $contact['id']);
1170
1171                 if ($contact['name'] != $data->name) {
1172                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1173                         $update = true;
1174                 }
1175
1176                 if ($contact['nick'] != $data->screen_name) {
1177                         $fields['uri-date'] = DateTimeFormat::utcNow();
1178                         $update = true;
1179                 }
1180
1181                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1182                         $update = true;
1183                 }
1184
1185                 if ($update) {
1186                         $fields['updated'] = DateTimeFormat::utcNow();
1187                         DBA::update('contact', $fields, ['id' => $contact['id']]);
1188                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1189                 }
1190         }
1191
1192         return $contact_id;
1193 }
1194
1195 /**
1196  * @param string $screen_name
1197  * @return stdClass|null
1198  * @throws Exception
1199  */
1200 function twitter_fetchuser($screen_name)
1201 {
1202         $ckey = DI::config()->get('twitter', 'consumerkey');
1203         $csecret = DI::config()->get('twitter', 'consumersecret');
1204
1205         try {
1206                 // Fetching user data
1207                 $connection = new TwitterOAuth($ckey, $csecret);
1208                 $parameters = ['screen_name' => $screen_name];
1209                 $user = $connection->get('users/show', $parameters);
1210         } catch (TwitterOAuthException $e) {
1211                 Logger::log('twitter_fetchuser: Error fetching user ' . $screen_name . ': ' . $e->getMessage());
1212                 return null;
1213         }
1214
1215         if (!is_object($user)) {
1216                 return null;
1217         }
1218
1219         return $user;
1220 }
1221
1222 /**
1223  * Replaces Twitter entities with Friendica-friendly links.
1224  *
1225  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1226  *
1227  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1228  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1229  * index to be correct even after the last replacement.
1230  *
1231  * @param string   $body
1232  * @param stdClass $status
1233  * @param string   $picture
1234  * @return array
1235  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1236  */
1237 function twitter_expand_entities($body, stdClass $status, $picture)
1238 {
1239         $plain = $body;
1240
1241         $taglist = [];
1242
1243         $replacementList = [];
1244
1245         foreach ($status->entities->hashtags AS $hashtag) {
1246                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1247                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1248
1249                 $replacementList[$hashtag->indices[0]] = [
1250                         'replace' => $replace,
1251                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1252                 ];
1253         }
1254
1255         foreach ($status->entities->user_mentions AS $mention) {
1256                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1257                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1258
1259                 $replacementList[$mention->indices[0]] = [
1260                         'replace' => $replace,
1261                         'length' => $mention->indices[1] - $mention->indices[0],
1262                 ];
1263         }
1264
1265         // This URL if set will be used to add an attachment at the bottom of the post
1266         $attachmentUrl = '';
1267
1268         foreach ($status->entities->urls ?? [] as $url) {
1269                 $plain = str_replace($url->url, '', $plain);
1270
1271                 if ($url->url && $url->expanded_url && $url->display_url) {
1272
1273                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1274                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1275                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1276                         ) {
1277                                 $replacementList[$url->indices[0]] = [
1278                                         'replace' => '',
1279                                         'length' => $url->indices[1] - $url->indices[0],
1280                                 ];
1281                                 continue;
1282                         }
1283
1284                         $expanded_url = $url->expanded_url;
1285
1286                         $final_url = Network::finalUrl($url->expanded_url);
1287
1288                         $oembed_data = OEmbed::fetchURL($final_url);
1289
1290                         if (empty($oembed_data) || empty($oembed_data->type)) {
1291                                 continue;
1292                         }
1293
1294                         // Quickfix: Workaround for URL with '[' and ']' in it
1295                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1296                                 $expanded_url = $url->url;
1297                         }
1298
1299                         if ($oembed_data->type == 'video') {
1300                                 $attachmentUrl = $expanded_url;
1301                                 $replace = '';
1302                         } elseif (($oembed_data->type == 'photo') && isset($oembed_data->url)) {
1303                                 $replace = '[url=' . $expanded_url . '][img]' . $oembed_data->url . '[/img][/url]';
1304                         } elseif ($oembed_data->type != 'link') {
1305                                 $replace = '[url=' . $expanded_url . ']' . $url->display_url . '[/url]';
1306                         } else {
1307                                 $img_str = Network::fetchUrl($final_url, true, 4);
1308
1309                                 $tempfile = tempnam(get_temppath(), 'cache');
1310                                 file_put_contents($tempfile, $img_str);
1311
1312                                 // See http://php.net/manual/en/function.exif-imagetype.php#79283
1313                                 if (filesize($tempfile) > 11) {
1314                                         $mime = image_type_to_mime_type(exif_imagetype($tempfile));
1315                                 } else {
1316                                         $mime = false;
1317                                 }
1318
1319                                 unlink($tempfile);
1320
1321                                 if (substr($mime, 0, 6) == 'image/') {
1322                                         $replace = '[img]' . $final_url . '[/img]';
1323                                 } else {
1324                                         $attachmentUrl = $expanded_url;
1325                                         $replace = '';
1326                                 }
1327                         }
1328
1329                         $replacementList[$url->indices[0]] = [
1330                                 'replace' => $replace,
1331                                 'length' => $url->indices[1] - $url->indices[0],
1332                         ];
1333                 }
1334         }
1335
1336         krsort($replacementList);
1337
1338         foreach ($replacementList as $startIndex => $parameters) {
1339                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1340         }
1341
1342         // Footer will be taken care of with a share block in the case of a quote
1343         if (empty($status->quoted_status)) {
1344                 $footer = '';
1345                 if ($attachmentUrl) {
1346                         $footer = add_page_info($attachmentUrl, false, $picture);
1347                 }
1348
1349                 if (trim($footer)) {
1350                         $body .= $footer;
1351                 } elseif ($picture) {
1352                         $body .= "\n\n[img]" . $picture . "[/img]\n";
1353                 } else {
1354                         $body = add_page_info_to_body($body);
1355                 }
1356         }
1357
1358         return ['body' => $body, 'plain' => $plain, 'taglist' => $taglist];
1359 }
1360
1361 /**
1362  * @brief Fetch media entities and add media links to the body
1363  *
1364  * @param object $post Twitter object with the post
1365  * @param array $postarray Array of the item that is about to be posted
1366  *
1367  * @return $picture string Image URL or empty string
1368  */
1369 function twitter_media_entities($post, array &$postarray)
1370 {
1371         // There are no media entities? So we quit.
1372         if (empty($post->extended_entities->media)) {
1373                 return '';
1374         }
1375
1376         // When the post links to an external page, we only take one picture.
1377         // We only do this when there is exactly one media.
1378         if ((count($post->entities->urls) > 0) && (count($post->extended_entities->media) == 1)) {
1379                 $medium = $post->extended_entities->media[0];
1380                 $picture = '';
1381                 foreach ($post->entities->urls as $link) {
1382                         // Let's make sure the external link url matches the media url
1383                         if ($medium->url == $link->url && isset($medium->media_url_https)) {
1384                                 $picture = $medium->media_url_https;
1385                                 $postarray['body'] = str_replace($medium->url, '', $postarray['body']);
1386                                 return $picture;
1387                         }
1388                 }
1389         }
1390
1391         // This is a pure media post, first search for all media urls
1392         $media = [];
1393         foreach ($post->extended_entities->media AS $medium) {
1394                 if (!isset($media[$medium->url])) {
1395                         $media[$medium->url] = '';
1396                 }
1397                 switch ($medium->type) {
1398                         case 'photo':
1399                                 if (!empty($medium->ext_alt_text)) {
1400                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1401                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1402                                 } else {
1403                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1404                                 }
1405
1406                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1407                                 break;
1408                         case 'video':
1409                         case 'animated_gif':
1410                                 if (!empty($medium->ext_alt_text)) {
1411                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1412                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1413                                 } else {
1414                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1415                                 }
1416
1417                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1418                                 if (is_array($medium->video_info->variants)) {
1419                                         $bitrate = 0;
1420                                         // We take the video with the highest bitrate
1421                                         foreach ($medium->video_info->variants AS $variant) {
1422                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1423                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1424                                                         $bitrate = $variant->bitrate;
1425                                                 }
1426                                         }
1427                                 }
1428                                 break;
1429                         // The following code will only be activated for test reasons
1430                         //default:
1431                         //      $postarray['body'] .= print_r($medium, true);
1432                 }
1433         }
1434
1435         // Now we replace the media urls.
1436         foreach ($media AS $key => $value) {
1437                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1438         }
1439
1440         return '';
1441 }
1442
1443 /**
1444  * Undocumented function
1445  *
1446  * @param App $a
1447  * @param integer $uid User ID
1448  * @param object $post Incoming Twitter post
1449  * @param array $self
1450  * @param bool $create_user Should users be created?
1451  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1452  * @param bool $noquote
1453  * @param integer $uriid URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1454  * @return array item array
1455  */
1456 function twitter_createpost(App $a, $uid, $post, array $self, $create_user, $only_existing_contact, $noquote, int $uriid = 0)
1457 {
1458         $postarray = [];
1459         $postarray['network'] = Protocol::TWITTER;
1460         $postarray['uid'] = $uid;
1461         $postarray['wall'] = 0;
1462         $postarray['uri'] = "twitter::" . $post->id_str;
1463         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1464         $postarray['source'] = json_encode($post);
1465
1466         if (empty($uriid)) {
1467                 $uriid = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1468         }
1469
1470         // Don't import our own comments
1471         if (Item::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1472                 Logger::log("Item with extid " . $postarray['uri'] . " found.", Logger::DEBUG);
1473                 return [];
1474         }
1475
1476         $contactid = 0;
1477
1478         if ($post->in_reply_to_status_id_str != "") {
1479                 $parent = "twitter::" . $post->in_reply_to_status_id_str;
1480
1481                 $fields = ['uri', 'parent-uri', 'parent'];
1482                 $parent_item = Item::selectFirst($fields, ['uri' => $parent, 'uid' => $uid]);
1483                 if (!DBA::isResult($parent_item)) {
1484                         $parent_item = Item::selectFirst($fields, ['extid' => $parent, 'uid' => $uid]);
1485                 }
1486
1487                 if (DBA::isResult($parent_item)) {
1488                         $postarray['thr-parent'] = $parent_item['uri'];
1489                         $postarray['parent-uri'] = $parent_item['parent-uri'];
1490                         $postarray['parent'] = $parent_item['parent'];
1491                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1492                 } else {
1493                         $postarray['thr-parent'] = $postarray['uri'];
1494                         $postarray['parent-uri'] = $postarray['uri'];
1495                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1496                 }
1497
1498                 // Is it me?
1499                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1500
1501                 if ($post->user->id_str == $own_id) {
1502                         $r = q("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = %d LIMIT 1",
1503                                 intval($uid));
1504
1505                         if (DBA::isResult($r)) {
1506                                 $contactid = $r[0]["id"];
1507
1508                                 $postarray['owner-name']   = $r[0]["name"];
1509                                 $postarray['owner-link']   = $r[0]["url"];
1510                                 $postarray['owner-avatar'] = $r[0]["photo"];
1511                         } else {
1512                                 Logger::log("No self contact for user " . $uid, Logger::DEBUG);
1513                                 return [];
1514                         }
1515                 }
1516                 // Don't create accounts of people who just comment something
1517                 $create_user = false;
1518         } else {
1519                 $postarray['parent-uri'] = $postarray['uri'];
1520                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1521         }
1522
1523         if ($contactid == 0) {
1524                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1525
1526                 $postarray['owner-name'] = $post->user->name;
1527                 $postarray['owner-link'] = "https://twitter.com/" . $post->user->screen_name;
1528                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1529         }
1530
1531         if (($contactid == 0) && !$only_existing_contact) {
1532                 $contactid = $self['id'];
1533         } elseif ($contactid <= 0) {
1534                 Logger::log("Contact ID is zero or less than zero.", Logger::DEBUG);
1535                 return [];
1536         }
1537
1538         $postarray['contact-id'] = $contactid;
1539
1540         $postarray['verb'] = Activity::POST;
1541         $postarray['author-name'] = $postarray['owner-name'];
1542         $postarray['author-link'] = $postarray['owner-link'];
1543         $postarray['author-avatar'] = $postarray['owner-avatar'];
1544         $postarray['plink'] = "https://twitter.com/" . $post->user->screen_name . "/status/" . $post->id_str;
1545         $postarray['app'] = strip_tags($post->source);
1546
1547         if ($post->user->protected) {
1548                 $postarray['private'] = 1;
1549                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1550         } else {
1551                 $postarray['private'] = 0;
1552                 $postarray['allow_cid'] = '';
1553         }
1554
1555         if (!empty($post->full_text)) {
1556                 $postarray['body'] = $post->full_text;
1557         } else {
1558                 $postarray['body'] = $post->text;
1559         }
1560
1561         // When the post contains links then use the correct object type
1562         if (count($post->entities->urls) > 0) {
1563                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1564         }
1565
1566         // Search for media links
1567         $picture = twitter_media_entities($post, $postarray);
1568
1569         $converted = twitter_expand_entities($postarray['body'], $post, $picture);
1570         $postarray['body'] = $converted['body'];
1571         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1572         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1573
1574         if ($uriid > 0) {
1575                 twitter_store_tags($uriid, $converted['taglist']);
1576         }
1577
1578         $statustext = $converted["plain"];
1579
1580         if (!empty($post->place->name)) {
1581                 $postarray["location"] = $post->place->name;
1582         }
1583         if (!empty($post->place->full_name)) {
1584                 $postarray["location"] = $post->place->full_name;
1585         }
1586         if (!empty($post->geo->coordinates)) {
1587                 $postarray["coord"] = $post->geo->coordinates[0] . " " . $post->geo->coordinates[1];
1588         }
1589         if (!empty($post->coordinates->coordinates)) {
1590                 $postarray["coord"] = $post->coordinates->coordinates[1] . " " . $post->coordinates->coordinates[0];
1591         }
1592         if (!empty($post->retweeted_status)) {
1593                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1594
1595                 if (empty($retweet['body'])) {
1596                         return [];
1597                 }
1598
1599                 if (!$noquote) {
1600                         // Store the original tweet
1601                         Item::insert($retweet);
1602
1603                         // CHange the other post into a reshare activity
1604                         $postarray['verb'] = Activity::ANNOUNCE;
1605                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1606                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1607
1608                         $postarray['thr-parent'] = $retweet['uri'];
1609                         $postarray['parent-uri'] = $retweet['uri'];
1610                 } else {
1611                         $retweet['source'] = $postarray['source'];
1612                         $retweet['private'] = $postarray['private'];
1613                         $retweet['allow_cid'] = $postarray['allow_cid'];
1614                         $retweet['contact-id'] = $postarray['contact-id'];
1615                         $retweet['owner-name'] = $postarray['owner-name'];
1616                         $retweet['owner-link'] = $postarray['owner-link'];
1617                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1618
1619                         $postarray = $retweet;
1620                 }
1621         }
1622
1623         if (!empty($post->quoted_status)) {
1624                 if ($noquote) {
1625                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1626                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1627                 } else {
1628                         $quoted = twitter_createpost($a, $uid, $post->quoted_status, $self, false, false, true, $uriid);
1629                         if (!empty($quoted['body'])) {
1630                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1631                                                 $quoted['author-name'],
1632                                                 $quoted['author-link'],
1633                                                 $quoted['author-avatar'],
1634                                                 "",
1635                                                 $quoted['created'],
1636                                                 $quoted['plink']
1637                                         );
1638
1639                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1640                         } else {
1641                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1642                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1643                         }
1644                 }
1645         }
1646
1647         return $postarray;
1648 }
1649
1650 /**
1651  * Store tags and mentions
1652  *
1653  * @param integer $uriid
1654  * @param array $taglist
1655  */
1656 function twitter_store_tags(int $uriid, array $taglist)
1657 {
1658         foreach ($taglist as $tag) {
1659                 Tag::storeByHash($uriid, $tag[0], $tag[1], $tag[2]);
1660         }
1661 }
1662
1663 function twitter_fetchparentposts(App $a, $uid, $post, TwitterOAuth $connection, array $self)
1664 {
1665         Logger::log("twitter_fetchparentposts: Fetching for user " . $uid . " and post " . $post->id_str, Logger::DEBUG);
1666
1667         $posts = [];
1668
1669         while (!empty($post->in_reply_to_status_id_str)) {
1670                 $parameters = ["trim_user" => false, "tweet_mode" => "extended", "id" => $post->in_reply_to_status_id_str, "include_ext_alt_text" => true];
1671
1672                 try {
1673                         $post = $connection->get('statuses/show', $parameters);
1674                 } catch (TwitterOAuthException $e) {
1675                         Logger::log('twitter_fetchparentposts: Error fetching for user ' . $uid . ' and post ' . $post->id_str . ': ' . $e->getMessage());
1676                         break;
1677                 }
1678
1679                 if (empty($post)) {
1680                         Logger::log("twitter_fetchparentposts: Can't fetch post " . $parameters['id'], Logger::DEBUG);
1681                         break;
1682                 }
1683
1684                 if (empty($post->id_str)) {
1685                         Logger::log("twitter_fetchparentposts: This is not a post " . json_encode($post), Logger::DEBUG);
1686                         break;
1687                 }
1688
1689                 if (Item::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1690                         break;
1691                 }
1692
1693                 $posts[] = $post;
1694         }
1695
1696         Logger::log("twitter_fetchparentposts: Fetching " . count($posts) . " parents", Logger::DEBUG);
1697
1698         $posts = array_reverse($posts);
1699
1700         if (!empty($posts)) {
1701                 foreach ($posts as $post) {
1702                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1703
1704                         if (empty($postarray['body'])) {
1705                                 continue;
1706                         }
1707
1708                         $item = Item::insert($postarray);
1709
1710                         $postarray["id"] = $item;
1711
1712                         Logger::log('twitter_fetchparentpost: User ' . $self["nick"] . ' posted parent timeline item ' . $item);
1713                 }
1714         }
1715 }
1716
1717 function twitter_fetchhometimeline(App $a, $uid)
1718 {
1719         $ckey    = DI::config()->get('twitter', 'consumerkey');
1720         $csecret = DI::config()->get('twitter', 'consumersecret');
1721         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1722         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1723         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
1724         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
1725
1726         Logger::log("Fetching timeline for user " . $uid, Logger::DEBUG);
1727
1728         $application_name = DI::config()->get('twitter', 'application_name');
1729
1730         if ($application_name == "") {
1731                 $application_name = DI::baseUrl()->getHostname();
1732         }
1733
1734         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1735
1736         try {
1737                 $own_contact = twitter_fetch_own_contact($a, $uid);
1738         } catch (TwitterOAuthException $e) {
1739                 Logger::log('Error fetching own contact for user ' . $uid . ': ' . $e->getMessage());
1740                 return;
1741         }
1742
1743         $r = q("SELECT * FROM `contact` WHERE `id` = %d AND `uid` = %d LIMIT 1",
1744                 intval($own_contact),
1745                 intval($uid));
1746
1747         if (DBA::isResult($r)) {
1748                 $own_id = $r[0]["nick"];
1749         } else {
1750                 Logger::log("Own twitter contact not found for user " . $uid);
1751                 return;
1752         }
1753
1754         $self = User::getOwnerDataById($uid);
1755         if ($self === false) {
1756                 Logger::log("Own contact not found for user " . $uid);
1757                 return;
1758         }
1759
1760         $parameters = ["exclude_replies" => false, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1761         //$parameters["count"] = 200;
1762         // Fetching timeline
1763         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
1764
1765         $first_time = ($lastid == "");
1766
1767         if ($lastid != "") {
1768                 $parameters["since_id"] = $lastid;
1769         }
1770
1771         try {
1772                 $items = $connection->get('statuses/home_timeline', $parameters);
1773         } catch (TwitterOAuthException $e) {
1774                 Logger::log('Error fetching home timeline for user ' . $uid . ': ' . $e->getMessage());
1775                 return;
1776         }
1777
1778         if (!is_array($items)) {
1779                 Logger::log('No array while fetching home timeline for user ' . $uid . ': ' . print_r($items, true));
1780                 return;
1781         }
1782
1783         if (empty($items)) {
1784                 Logger::log('No new timeline content for user ' . $uid, Logger::INFO);
1785                 return;
1786         }
1787
1788         $posts = array_reverse($items);
1789
1790         Logger::log('Fetching timeline from ID ' . $lastid . ' for user ' . $uid . ' ' . sizeof($posts) . ' items', Logger::DEBUG);
1791
1792         if (count($posts)) {
1793                 foreach ($posts as $post) {
1794                         if ($post->id_str > $lastid) {
1795                                 $lastid = $post->id_str;
1796                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1797                         }
1798
1799                         if ($first_time) {
1800                                 continue;
1801                         }
1802
1803                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
1804                                 Logger::log("Skip previously sent post", Logger::DEBUG);
1805                                 continue;
1806                         }
1807
1808                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == "") {
1809                                 Logger::log("Skip post that will be mirrored", Logger::DEBUG);
1810                                 continue;
1811                         }
1812
1813                         if ($post->in_reply_to_status_id_str != "") {
1814                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1815                         }
1816
1817                         Logger::log('Preparing post ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1818
1819                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
1820
1821                         if (empty($postarray['body']) || trim($postarray['body']) == "") {
1822                                 Logger::log('Empty body for post ' . $post->id_str . ' and user ' . $uid, Logger::DEBUG);
1823                                 continue;
1824                         }
1825
1826                         $notify = false;
1827
1828                         if (($postarray['uri'] == $postarray['parent-uri']) && ($postarray['author-link'] == $postarray['owner-link'])) {
1829                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
1830                                 if (DBA::isResult($contact)) {
1831                                         $notify = Item::isRemoteSelf($contact, $postarray);
1832                                 }
1833                         }
1834
1835                         $item = Item::insert($postarray, $notify);
1836                         $postarray["id"] = $item;
1837
1838                         Logger::log('User ' . $uid . ' posted home timeline item ' . $item);
1839                 }
1840         }
1841         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1842
1843         Logger::log('Last timeline ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1844
1845         // Fetching mentions
1846         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
1847
1848         $first_time = ($lastid == "");
1849
1850         if ($lastid != "") {
1851                 $parameters["since_id"] = $lastid;
1852         }
1853
1854         try {
1855                 $items = $connection->get('statuses/mentions_timeline', $parameters);
1856         } catch (TwitterOAuthException $e) {
1857                 Logger::log('Error fetching mentions: ' . $e->getMessage());
1858                 return;
1859         }
1860
1861         if (!is_array($items)) {
1862                 Logger::log("Error fetching mentions: " . print_r($items, true), Logger::DEBUG);
1863                 return;
1864         }
1865
1866         $posts = array_reverse($items);
1867
1868         Logger::log("Fetching mentions for user " . $uid . " " . sizeof($posts) . " items", Logger::DEBUG);
1869
1870         if (count($posts)) {
1871                 foreach ($posts as $post) {
1872                         if ($post->id_str > $lastid) {
1873                                 $lastid = $post->id_str;
1874                         }
1875
1876                         if ($first_time) {
1877                                 continue;
1878                         }
1879
1880                         if ($post->in_reply_to_status_id_str != "") {
1881                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1882                         }
1883
1884                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
1885
1886                         if (empty($postarray['body'])) {
1887                                 continue;
1888                         }
1889
1890                         $item = Item::insert($postarray);
1891
1892                         Logger::log('User ' . $uid . ' posted mention timeline item ' . $item);
1893                 }
1894         }
1895
1896         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
1897
1898         Logger::log('Last mentions ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1899 }
1900
1901 function twitter_fetch_own_contact(App $a, $uid)
1902 {
1903         $ckey    = DI::config()->get('twitter', 'consumerkey');
1904         $csecret = DI::config()->get('twitter', 'consumersecret');
1905         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1906         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1907
1908         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1909
1910         $contact_id = 0;
1911
1912         if ($own_id == "") {
1913                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1914
1915                 // Fetching user data
1916                 // get() may throw TwitterOAuthException, but we will catch it later
1917                 $user = $connection->get('account/verify_credentials');
1918                 if (empty($user) || empty($user->id_str)) {
1919                         return false;
1920                 }
1921
1922                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
1923
1924                 $contact_id = twitter_fetch_contact($uid, $user, true);
1925         } else {
1926                 $r = q("SELECT * FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
1927                         intval($uid),
1928                         DBA::escape("twitter::" . $own_id));
1929                 if (DBA::isResult($r)) {
1930                         $contact_id = $r[0]["id"];
1931                 } else {
1932                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
1933                 }
1934         }
1935
1936         return $contact_id;
1937 }
1938
1939 function twitter_is_retweet(App $a, $uid, $body)
1940 {
1941         $body = trim($body);
1942
1943         // Skip if it isn't a pure repeated messages
1944         // Does it start with a share?
1945         if (strpos($body, "[share") > 0) {
1946                 return false;
1947         }
1948
1949         // Does it end with a share?
1950         if (strlen($body) > (strrpos($body, "[/share]") + 8)) {
1951                 return false;
1952         }
1953
1954         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
1955         // Skip if there is no shared message in there
1956         if ($body == $attributes) {
1957                 return false;
1958         }
1959
1960         $link = "";
1961         preg_match("/link='(.*?)'/ism", $attributes, $matches);
1962         if (!empty($matches[1])) {
1963                 $link = $matches[1];
1964         }
1965
1966         preg_match('/link="(.*?)"/ism', $attributes, $matches);
1967         if (!empty($matches[1])) {
1968                 $link = $matches[1];
1969         }
1970
1971         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
1972         if ($id == $link) {
1973                 return false;
1974         }
1975
1976         Logger::log('twitter_is_retweet: Retweeting id ' . $id . ' for user ' . $uid, Logger::DEBUG);
1977
1978         $ckey    = DI::config()->get('twitter', 'consumerkey');
1979         $csecret = DI::config()->get('twitter', 'consumersecret');
1980         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1981         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1982
1983         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1984         $result = $connection->post('statuses/retweet/' . $id);
1985
1986         Logger::log('twitter_is_retweet: result ' . print_r($result, true), Logger::DEBUG);
1987
1988         return !isset($result->errors);
1989 }
1990
1991 function twitter_update_mentions($body)
1992 {
1993         $URLSearchString = "^\[\]";
1994         $return = preg_replace_callback(
1995                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
1996                 function ($matches) {
1997                         if (strpos($matches[1], 'twitter.com')) {
1998                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
1999                         } else {
2000                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2001                         }
2002
2003                         return $return;
2004                 },
2005                 $body
2006         );
2007
2008         return $return;
2009 }
2010
2011 function twitter_convert_share(array $attributes, array $author_contact, $content, $is_quote_share)
2012 {
2013         if (empty($author_contact)) {
2014                 return $content . "\n\n" . $attributes['link'];
2015         }
2016
2017         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2018                 $mention = '@' . $author_contact['nick'];
2019         } else {
2020                 $mention = $author_contact['addr'];
2021         }
2022
2023         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2024 }