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