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