]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
42986d162d1fd23a3d64e77ce956a5a59f24a43b
[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['uid'] = $uid;
1009         $datarray['extid'] = 'twitter::' . $post->id;
1010         $datarray['title'] = '';
1011
1012         if (!empty($post->retweeted_status)) {
1013                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1014                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1015
1016                 if (empty($item['body'])) {
1017                         return [];
1018                 }
1019
1020                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1021                         $item['author-name'],
1022                         $item['author-link'],
1023                         $item['author-avatar'],
1024                         $item['plink'],
1025                         $item['created']
1026                 );
1027
1028                 $datarray['body'] .= $item['body'] . '[/share]';
1029         } else {
1030                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1031
1032                 if (empty($item['body'])) {
1033                         return [];
1034                 }
1035
1036                 $datarray['body'] = $item['body'];
1037         }
1038
1039         $datarray['app'] = $item['app'];
1040         $datarray['verb'] = $item['verb'];
1041
1042         if (isset($item['location'])) {
1043                 $datarray['location'] = $item['location'];
1044         }
1045
1046         if (isset($item['coord'])) {
1047                 $datarray['coord'] = $item['coord'];
1048         }
1049
1050         return $datarray;
1051 }
1052
1053 function twitter_fetchtimeline(App $a, $uid)
1054 {
1055         $ckey    = DI::config()->get('twitter', 'consumerkey');
1056         $csecret = DI::config()->get('twitter', 'consumersecret');
1057         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1058         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1059         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1060
1061         $application_name = DI::config()->get('twitter', 'application_name');
1062
1063         if ($application_name == "") {
1064                 $application_name = DI::baseUrl()->getHostname();
1065         }
1066
1067         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1068
1069         // Ensure to have the own contact
1070         try {
1071                 twitter_fetch_own_contact($a, $uid);
1072         } catch (TwitterOAuthException $e) {
1073                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1074                 return;
1075         }
1076
1077         $parameters = ["exclude_replies" => true, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1078
1079         $first_time = ($lastid == "");
1080
1081         if ($lastid != "") {
1082                 $parameters["since_id"] = $lastid;
1083         }
1084
1085         try {
1086                 $items = $connection->get('statuses/user_timeline', $parameters);
1087         } catch (TwitterOAuthException $e) {
1088                 Logger::warning('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1089                 return;
1090         }
1091
1092         if (!is_array($items)) {
1093                 Logger::notice('No items', ['user' => $uid]);
1094                 return;
1095         }
1096
1097         $posts = array_reverse($items);
1098
1099         Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1100
1101         if (count($posts)) {
1102                 foreach ($posts as $post) {
1103                         if ($post->id_str > $lastid) {
1104                                 $lastid = $post->id_str;
1105                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1106                         }
1107
1108                         if ($first_time) {
1109                                 continue;
1110                         }
1111
1112                         if (!stristr($post->source, $application_name)) {
1113                                 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1114
1115                                 $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1116
1117                                 if (empty($mirrorpost['body'])) {
1118                                         continue;
1119                                 }
1120
1121                                 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1122
1123                                 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, PRIORITY_MEDIUM, true);
1124                         }
1125                 }
1126         }
1127         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1128         Logger::log('Last ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1129 }
1130
1131 function twitter_fix_avatar($avatar)
1132 {
1133         $new_avatar = str_replace("_normal.", ".", $avatar);
1134
1135         $info = Images::getInfoFromURLCached($new_avatar);
1136         if (!$info) {
1137                 $new_avatar = $avatar;
1138         }
1139
1140         return $new_avatar;
1141 }
1142
1143 function twitter_get_relation($uid, $target, $contact = [])
1144 {
1145         if (isset($contact['rel'])) {
1146                 $relation = $contact['rel'];
1147         } else {
1148                 $relation = 0;
1149         }
1150
1151         $ckey = DI::config()->get('twitter', 'consumerkey');
1152         $csecret = DI::config()->get('twitter', 'consumersecret');
1153         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1154         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1155         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1156
1157         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1158         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1159
1160         try {
1161                 $status = $connection->get('friendships/show', $parameters);
1162                 if ($connection->getLastHttpCode() !== 200) {
1163                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1164                 }
1165
1166                 $following = $status->relationship->source->following;
1167                 $followed = $status->relationship->source->followed_by;
1168
1169                 if ($following && !$followed) {
1170                         $relation = Contact::SHARING;
1171                 } elseif (!$following && $followed) {
1172                         $relation = Contact::FOLLOWER;
1173                 } elseif ($following && $followed) {
1174                         $relation = Contact::FRIEND;
1175                 } elseif (!$following && !$followed) {
1176                         $relation = 0;
1177                 }
1178
1179                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1180         } catch (Throwable $e) {
1181                 Logger::warning('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1182         }
1183
1184         return $relation;
1185 }
1186
1187 /**
1188  * @param $data
1189  * @return array
1190  */
1191 function twitter_user_to_contact($data)
1192 {
1193         if (empty($data->id_str)) {
1194                 return [];
1195         }
1196
1197         $baseurl = 'https://twitter.com';
1198         $url = $baseurl . '/' . $data->screen_name;
1199         $addr = $data->screen_name . '@twitter.com';
1200
1201         $fields = [
1202                 'url'      => $url,
1203                 'network'  => Protocol::TWITTER,
1204                 'alias'    => 'twitter::' . $data->id_str,
1205                 'baseurl'  => $baseurl,
1206                 'name'     => $data->name,
1207                 'nick'     => $data->screen_name,
1208                 'addr'     => $addr,
1209                 'location' => $data->location,
1210                 'about'    => $data->description,
1211                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1212         ];
1213
1214         return $fields;
1215 }
1216
1217 function twitter_fetch_contact($uid, $data, $create_user)
1218 {
1219         $fields = twitter_user_to_contact($data);
1220
1221         if (empty($fields)) {
1222                 return -1;
1223         }
1224
1225         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1226         $avatar = $fields['photo'];
1227         unset($fields['photo']);
1228
1229         // Update the public contact
1230         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => "twitter::" . $data->id_str]);
1231         if (DBA::isResult($pcontact)) {
1232                 $cid = $pcontact['id'];
1233         } else {
1234                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1235         }
1236
1237         if (!empty($cid)) {
1238                 DBA::update('contact', $fields, ['id' => $cid]);
1239                 Contact::updateAvatar($cid, $avatar);
1240         } else {
1241                 Logger::warning('No contact found', ['fields' => $fields]);
1242         }
1243
1244         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => "twitter::" . $data->id_str]);
1245         if (!DBA::isResult($contact) && empty($cid)) {
1246                 Logger::warning('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1247                 return 0;
1248         } elseif (!$create_user) {
1249                 return $cid;
1250         }
1251
1252         if (!DBA::isResult($contact)) {
1253                 $relation = twitter_get_relation($uid, $data->screen_name);
1254
1255                 // create contact record
1256                 $fields['uid'] = $uid;
1257                 $fields['created'] = DateTimeFormat::utcNow();
1258                 $fields['nurl'] = Strings::normaliseLink($fields['url']);
1259                 $fields['poll'] = 'twitter::' . $data->id_str;
1260                 $fields['rel'] = $relation;
1261                 $fields['priority'] = 1;
1262                 $fields['writable'] = true;
1263                 $fields['blocked'] = false;
1264                 $fields['readonly'] = false;
1265                 $fields['pending'] = false;
1266
1267                 if (!DBA::insert('contact', $fields)) {
1268                         return false;
1269                 }
1270
1271                 $contact_id = DBA::lastInsertId();
1272
1273                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1274
1275                 Contact::updateAvatar($contact_id, $avatar);
1276         } else {
1277                 if ($contact["readonly"] || $contact["blocked"]) {
1278                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact["nick"]]);
1279                         return -1;
1280                 }
1281
1282                 $contact_id = $contact['id'];
1283                 $update = false;
1284
1285                 // Update the contact relation once per day
1286                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1287                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1288                         $update = true;
1289                 }
1290
1291                 Contact::updateAvatar($contact['id'], $avatar);
1292
1293                 if ($contact['name'] != $data->name) {
1294                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1295                         $update = true;
1296                 }
1297
1298                 if ($contact['nick'] != $data->screen_name) {
1299                         $fields['uri-date'] = DateTimeFormat::utcNow();
1300                         $update = true;
1301                 }
1302
1303                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1304                         $update = true;
1305                 }
1306
1307                 if ($update) {
1308                         $fields['updated'] = DateTimeFormat::utcNow();
1309                         DBA::update('contact', $fields, ['id' => $contact['id']]);
1310                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1311                 }
1312         }
1313
1314         return $contact_id;
1315 }
1316
1317 /**
1318  * @param string $screen_name
1319  * @return stdClass|null
1320  * @throws Exception
1321  */
1322 function twitter_fetchuser($screen_name)
1323 {
1324         $ckey = DI::config()->get('twitter', 'consumerkey');
1325         $csecret = DI::config()->get('twitter', 'consumersecret');
1326
1327         try {
1328                 // Fetching user data
1329                 $connection = new TwitterOAuth($ckey, $csecret);
1330                 $parameters = ['screen_name' => $screen_name];
1331                 $user = $connection->get('users/show', $parameters);
1332         } catch (TwitterOAuthException $e) {
1333                 Logger::warning('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1334                 return null;
1335         }
1336
1337         if (!is_object($user)) {
1338                 return null;
1339         }
1340
1341         return $user;
1342 }
1343
1344 /**
1345  * Replaces Twitter entities with Friendica-friendly links.
1346  *
1347  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1348  *
1349  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1350  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1351  * index to be correct even after the last replacement.
1352  *
1353  * @param string   $body
1354  * @param stdClass $status
1355  * @param string   $picture
1356  * @return array
1357  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1358  */
1359 function twitter_expand_entities($body, stdClass $status, $picture)
1360 {
1361         $plain = $body;
1362
1363         $taglist = [];
1364
1365         $replacementList = [];
1366
1367         foreach ($status->entities->hashtags AS $hashtag) {
1368                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1369                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1370
1371                 $replacementList[$hashtag->indices[0]] = [
1372                         'replace' => $replace,
1373                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1374                 ];
1375         }
1376
1377         foreach ($status->entities->user_mentions AS $mention) {
1378                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1379                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1380
1381                 $replacementList[$mention->indices[0]] = [
1382                         'replace' => $replace,
1383                         'length' => $mention->indices[1] - $mention->indices[0],
1384                 ];
1385         }
1386
1387         // This URL if set will be used to add an attachment at the bottom of the post
1388         $attachmentUrl = '';
1389
1390         foreach ($status->entities->urls ?? [] as $url) {
1391                 $plain = str_replace($url->url, '', $plain);
1392
1393                 if ($url->url && $url->expanded_url && $url->display_url) {
1394
1395                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1396                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1397                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1398                         ) {
1399                                 $replacementList[$url->indices[0]] = [
1400                                         'replace' => '',
1401                                         'length' => $url->indices[1] - $url->indices[0],
1402                                 ];
1403                                 continue;
1404                         }
1405
1406                         $expanded_url = $url->expanded_url;
1407
1408                         $final_url = DI::httpRequest()->finalUrl($url->expanded_url);
1409
1410                         $oembed_data = OEmbed::fetchURL($final_url);
1411
1412                         if (empty($oembed_data) || empty($oembed_data->type)) {
1413                                 continue;
1414                         }
1415
1416                         // Quickfix: Workaround for URL with '[' and ']' in it
1417                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1418                                 $expanded_url = $url->url;
1419                         }
1420
1421                         if ($oembed_data->type == 'video') {
1422                                 $attachmentUrl = $expanded_url;
1423                                 $replace = '';
1424                         } elseif (($oembed_data->type == 'photo') && isset($oembed_data->url)) {
1425                                 $replace = '[url=' . $expanded_url . '][img]' . $oembed_data->url . '[/img][/url]';
1426                         } elseif ($oembed_data->type != 'link') {
1427                                 $replace = '[url=' . $expanded_url . ']' . $url->display_url . '[/url]';
1428                         } else {
1429                                 $img_str = DI::httpRequest()->fetch($final_url, 4);
1430
1431                                 $tempfile = tempnam(get_temppath(), 'cache');
1432                                 file_put_contents($tempfile, $img_str);
1433
1434                                 // See http://php.net/manual/en/function.exif-imagetype.php#79283
1435                                 if (filesize($tempfile) > 11) {
1436                                         $mime = image_type_to_mime_type(exif_imagetype($tempfile));
1437                                 } else {
1438                                         $mime = false;
1439                                 }
1440
1441                                 unlink($tempfile);
1442
1443                                 if (substr($mime, 0, 6) == 'image/') {
1444                                         $replace = '[img]' . $final_url . '[/img]';
1445                                 } else {
1446                                         $attachmentUrl = $expanded_url;
1447                                         $replace = '';
1448                                 }
1449                         }
1450
1451                         $replacementList[$url->indices[0]] = [
1452                                 'replace' => $replace,
1453                                 'length' => $url->indices[1] - $url->indices[0],
1454                         ];
1455                 }
1456         }
1457
1458         krsort($replacementList);
1459
1460         foreach ($replacementList as $startIndex => $parameters) {
1461                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1462         }
1463
1464         // Footer will be taken care of with a share block in the case of a quote
1465         if (empty($status->quoted_status)) {
1466                 $footer = '';
1467                 if ($attachmentUrl) {
1468                         $footer = "\n" . PageInfo::getFooterFromUrl($attachmentUrl, false, $picture);
1469                 }
1470
1471                 if (trim($footer)) {
1472                         $body .= $footer;
1473                 } elseif ($picture) {
1474                         $body .= "\n\n[img]" . $picture . "[/img]\n";
1475                 } else {
1476                         $body = PageInfo::searchAndAppendToBody($body);
1477                 }
1478         }
1479
1480         return ['body' => $body, 'plain' => trim($plain), 'taglist' => $taglist];
1481 }
1482
1483 /**
1484  * @brief Fetch media entities and add media links to the body
1485  *
1486  * @param object $post Twitter object with the post
1487  * @param array $postarray Array of the item that is about to be posted
1488  *
1489  * @return $picture string Image URL or empty string
1490  */
1491 function twitter_media_entities($post, array &$postarray)
1492 {
1493         // There are no media entities? So we quit.
1494         if (empty($post->extended_entities->media)) {
1495                 return '';
1496         }
1497
1498         // When the post links to an external page, we only take one picture.
1499         // We only do this when there is exactly one media.
1500         if ((count($post->entities->urls) > 0) && (count($post->extended_entities->media) == 1)) {
1501                 $medium = $post->extended_entities->media[0];
1502                 $picture = '';
1503                 foreach ($post->entities->urls as $link) {
1504                         // Let's make sure the external link url matches the media url
1505                         if ($medium->url == $link->url && isset($medium->media_url_https)) {
1506                                 $picture = $medium->media_url_https;
1507                                 $postarray['body'] = str_replace($medium->url, '', $postarray['body']);
1508                                 return $picture;
1509                         }
1510                 }
1511         }
1512
1513
1514
1515         // This is a pure media post, first search for all media urls
1516         $media = [];
1517         foreach ($post->extended_entities->media AS $medium) {
1518                 if (!isset($media[$medium->url])) {
1519                         $media[$medium->url] = '';
1520                 }
1521                 switch ($medium->type) {
1522                         case 'photo':
1523                                 if (!empty($medium->ext_alt_text)) {
1524                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1525                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1526                                 } else {
1527                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1528                                 }
1529
1530                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1531                                 break;
1532                         case 'video':
1533                         case 'animated_gif':
1534                                 if (!empty($medium->ext_alt_text)) {
1535                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1536                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1537                                 } else {
1538                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1539                                 }
1540
1541                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1542                                 if (is_array($medium->video_info->variants)) {
1543                                         $bitrate = 0;
1544                                         // We take the video with the highest bitrate
1545                                         foreach ($medium->video_info->variants AS $variant) {
1546                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1547                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1548                                                         $bitrate = $variant->bitrate;
1549                                                 }
1550                                         }
1551                                 }
1552                                 break;
1553                         // The following code will only be activated for test reasons
1554                         //default:
1555                         //      $postarray['body'] .= print_r($medium, true);
1556                 }
1557         }
1558
1559         // Now we replace the media urls.
1560         foreach ($media AS $key => $value) {
1561                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1562         }
1563
1564         return '';
1565 }
1566
1567 /**
1568  * Undocumented function
1569  *
1570  * @param App $a
1571  * @param integer $uid User ID
1572  * @param object $post Incoming Twitter post
1573  * @param array $self
1574  * @param bool $create_user Should users be created?
1575  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1576  * @param bool $noquote
1577  * @param integer $uriid URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1578  * @return array item array
1579  */
1580 function twitter_createpost(App $a, $uid, $post, array $self, $create_user, $only_existing_contact, $noquote, int $uriid = 0)
1581 {
1582         $postarray = [];
1583         $postarray['network'] = Protocol::TWITTER;
1584         $postarray['uid'] = $uid;
1585         $postarray['wall'] = 0;
1586         $postarray['uri'] = "twitter::" . $post->id_str;
1587         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1588         $postarray['source'] = json_encode($post);
1589
1590         if (empty($uriid)) {
1591                 $uriid = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1592         }
1593
1594         // Don't import our own comments
1595         if (Item::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1596                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1597                 return [];
1598         }
1599
1600         $contactid = 0;
1601
1602         if ($post->in_reply_to_status_id_str != "") {
1603                 $thr_parent = "twitter::" . $post->in_reply_to_status_id_str;
1604
1605                 $item = Item::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1606                 if (!DBA::isResult($item)) {
1607                         $item = Item::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid]);
1608                 }
1609
1610                 if (DBA::isResult($item)) {
1611                         $postarray['thr-parent'] = $item['uri'];
1612                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1613                 } else {
1614                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1615                 }
1616
1617                 // Is it me?
1618                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1619
1620                 if ($post->user->id_str == $own_id) {
1621                         $r = q("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = %d LIMIT 1",
1622                                 intval($uid));
1623
1624                         if (DBA::isResult($r)) {
1625                                 $contactid = $r[0]["id"];
1626
1627                                 $postarray['owner-name']   = $r[0]["name"];
1628                                 $postarray['owner-link']   = $r[0]["url"];
1629                                 $postarray['owner-avatar'] = $r[0]["photo"];
1630                         } else {
1631                                 Logger::error('No self contact found', ['uid' => $uid]);
1632                                 return [];
1633                         }
1634                 }
1635                 // Don't create accounts of people who just comment something
1636                 $create_user = false;
1637         } else {
1638                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1639         }
1640
1641         if ($contactid == 0) {
1642                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1643
1644                 $postarray['owner-name'] = $post->user->name;
1645                 $postarray['owner-link'] = "https://twitter.com/" . $post->user->screen_name;
1646                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1647         }
1648
1649         if (($contactid == 0) && !$only_existing_contact) {
1650                 $contactid = $self['id'];
1651         } elseif ($contactid <= 0) {
1652                 Logger::info('Contact ID is zero or less than zero.');
1653                 return [];
1654         }
1655
1656         $postarray['contact-id'] = $contactid;
1657
1658         $postarray['verb'] = Activity::POST;
1659         $postarray['author-name'] = $postarray['owner-name'];
1660         $postarray['author-link'] = $postarray['owner-link'];
1661         $postarray['author-avatar'] = $postarray['owner-avatar'];
1662         $postarray['plink'] = "https://twitter.com/" . $post->user->screen_name . "/status/" . $post->id_str;
1663         $postarray['app'] = strip_tags($post->source);
1664
1665         if ($post->user->protected) {
1666                 $postarray['private'] = 1;
1667                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1668         } else {
1669                 $postarray['private'] = 0;
1670                 $postarray['allow_cid'] = '';
1671         }
1672
1673         if (!empty($post->full_text)) {
1674                 $postarray['body'] = $post->full_text;
1675         } else {
1676                 $postarray['body'] = $post->text;
1677         }
1678
1679         // When the post contains links then use the correct object type
1680         if (count($post->entities->urls) > 0) {
1681                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1682         }
1683
1684         // Search for media links
1685         $picture = twitter_media_entities($post, $postarray);
1686
1687         $converted = twitter_expand_entities($postarray['body'], $post, $picture);
1688         $postarray['body'] = $converted['body'];
1689         $postarray['created'] = DateTimeFormat::utc($post->created_at);
1690         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
1691
1692         if ($uriid > 0) {
1693                 twitter_store_tags($uriid, $converted['taglist']);
1694         }
1695
1696         $statustext = $converted["plain"];
1697
1698         if (!empty($post->place->name)) {
1699                 $postarray["location"] = $post->place->name;
1700         }
1701         if (!empty($post->place->full_name)) {
1702                 $postarray["location"] = $post->place->full_name;
1703         }
1704         if (!empty($post->geo->coordinates)) {
1705                 $postarray["coord"] = $post->geo->coordinates[0] . " " . $post->geo->coordinates[1];
1706         }
1707         if (!empty($post->coordinates->coordinates)) {
1708                 $postarray["coord"] = $post->coordinates->coordinates[1] . " " . $post->coordinates->coordinates[0];
1709         }
1710         if (!empty($post->retweeted_status)) {
1711                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
1712
1713                 if (empty($retweet['body'])) {
1714                         return [];
1715                 }
1716
1717                 if (!$noquote) {
1718                         // Store the original tweet
1719                         Item::insert($retweet);
1720
1721                         // CHange the other post into a reshare activity
1722                         $postarray['verb'] = Activity::ANNOUNCE;
1723                         $postarray['gravity'] = GRAVITY_ACTIVITY;
1724                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1725
1726                         $postarray['thr-parent'] = $retweet['uri'];
1727                 } else {
1728                         $retweet['source'] = $postarray['source'];
1729                         $retweet['private'] = $postarray['private'];
1730                         $retweet['allow_cid'] = $postarray['allow_cid'];
1731                         $retweet['contact-id'] = $postarray['contact-id'];
1732                         $retweet['owner-name'] = $postarray['owner-name'];
1733                         $retweet['owner-link'] = $postarray['owner-link'];
1734                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
1735
1736                         $postarray = $retweet;
1737                 }
1738         }
1739
1740         if (!empty($post->quoted_status)) {
1741                 if ($noquote) {
1742                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
1743                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1744                 } else {
1745                         $quoted = twitter_createpost($a, $uid, $post->quoted_status, $self, false, false, true, $uriid);
1746                         if (!empty($quoted['body'])) {
1747                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
1748                                                 $quoted['author-name'],
1749                                                 $quoted['author-link'],
1750                                                 $quoted['author-avatar'],
1751                                                 $quoted['plink'],
1752                                                 $quoted['created']
1753                                         );
1754
1755                                 $postarray['body'] .= $quoted['body'] . '[/share]';
1756                         } else {
1757                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
1758                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
1759                         }
1760                 }
1761         }
1762
1763         return $postarray;
1764 }
1765
1766 /**
1767  * Store tags and mentions
1768  *
1769  * @param integer $uriid
1770  * @param array $taglist
1771  */
1772 function twitter_store_tags(int $uriid, array $taglist)
1773 {
1774         foreach ($taglist as $tag) {
1775                 Tag::storeByHash($uriid, $tag[0], $tag[1], $tag[2]);
1776         }
1777 }
1778
1779 function twitter_fetchparentposts(App $a, $uid, $post, TwitterOAuth $connection, array $self)
1780 {
1781         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
1782
1783         $posts = [];
1784
1785         while (!empty($post->in_reply_to_status_id_str)) {
1786                 $parameters = ["trim_user" => false, "tweet_mode" => "extended", "id" => $post->in_reply_to_status_id_str, "include_ext_alt_text" => true];
1787
1788                 try {
1789                         $post = $connection->get('statuses/show', $parameters);
1790                 } catch (TwitterOAuthException $e) {
1791                         Logger::warning('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
1792                         break;
1793                 }
1794
1795                 if (empty($post)) {
1796                         Logger::log("twitter_fetchparentposts: Can't fetch post " . $parameters['id'], Logger::DEBUG);
1797                         break;
1798                 }
1799
1800                 if (empty($post->id_str)) {
1801                         Logger::log("twitter_fetchparentposts: This is not a post " . json_encode($post), Logger::DEBUG);
1802                         break;
1803                 }
1804
1805                 if (Item::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
1806                         break;
1807                 }
1808
1809                 $posts[] = $post;
1810         }
1811
1812         Logger::log("twitter_fetchparentposts: Fetching " . count($posts) . " parents", Logger::DEBUG);
1813
1814         $posts = array_reverse($posts);
1815
1816         if (!empty($posts)) {
1817                 foreach ($posts as $post) {
1818                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
1819
1820                         if (empty($postarray['body'])) {
1821                                 continue;
1822                         }
1823
1824                         $item = Item::insert($postarray);
1825
1826                         $postarray["id"] = $item;
1827
1828                         Logger::log('twitter_fetchparentpost: User ' . $self["nick"] . ' posted parent timeline item ' . $item);
1829                 }
1830         }
1831 }
1832
1833 function twitter_fetchhometimeline(App $a, $uid)
1834 {
1835         $ckey    = DI::config()->get('twitter', 'consumerkey');
1836         $csecret = DI::config()->get('twitter', 'consumersecret');
1837         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1838         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1839         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
1840         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
1841
1842         Logger::info('Fetching timeline', ['uid' => $uid]);
1843
1844         $application_name = DI::config()->get('twitter', 'application_name');
1845
1846         if ($application_name == "") {
1847                 $application_name = DI::baseUrl()->getHostname();
1848         }
1849
1850         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1851
1852         try {
1853                 $own_contact = twitter_fetch_own_contact($a, $uid);
1854         } catch (TwitterOAuthException $e) {
1855                 Logger::warning('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1856                 return;
1857         }
1858
1859         $r = q("SELECT * FROM `contact` WHERE `id` = %d AND `uid` = %d LIMIT 1",
1860                 intval($own_contact),
1861                 intval($uid));
1862
1863         if (DBA::isResult($r)) {
1864                 $own_id = $r[0]["nick"];
1865         } else {
1866                 Logger::warning('Own twitter contact not found', ['uid' => $uid]);
1867                 return;
1868         }
1869
1870         $self = User::getOwnerDataById($uid);
1871         if ($self === false) {
1872                 Logger::warning('Own contact not found', ['uid' => $uid]);
1873                 return;
1874         }
1875
1876         $parameters = ["exclude_replies" => false, "trim_user" => false, "contributor_details" => true, "include_rts" => true, "tweet_mode" => "extended", "include_ext_alt_text" => true];
1877         //$parameters["count"] = 200;
1878         // Fetching timeline
1879         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
1880
1881         $first_time = ($lastid == "");
1882
1883         if ($lastid != "") {
1884                 $parameters["since_id"] = $lastid;
1885         }
1886
1887         try {
1888                 $items = $connection->get('statuses/home_timeline', $parameters);
1889         } catch (TwitterOAuthException $e) {
1890                 Logger::warning('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1891                 return;
1892         }
1893
1894         if (!is_array($items)) {
1895                 Logger::warning('home timeline is no array', ['items' => $items]);
1896                 return;
1897         }
1898
1899         if (empty($items)) {
1900                 Logger::notice('No new timeline content', ['uid' => $uid]);
1901                 return;
1902         }
1903
1904         $posts = array_reverse($items);
1905
1906         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
1907
1908         if (count($posts)) {
1909                 foreach ($posts as $post) {
1910                         if ($post->id_str > $lastid) {
1911                                 $lastid = $post->id_str;
1912                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1913                         }
1914
1915                         if ($first_time) {
1916                                 continue;
1917                         }
1918
1919                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
1920                                 Logger::info("Skip previously sent post");
1921                                 continue;
1922                         }
1923
1924                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == "") {
1925                                 Logger::info("Skip post that will be mirrored");
1926                                 continue;
1927                         }
1928
1929                         if ($post->in_reply_to_status_id_str != "") {
1930                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1931                         }
1932
1933                         Logger::log('Preparing post ' . $post->id_str . ' for user ' . $uid, Logger::DEBUG);
1934
1935                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
1936
1937                         if (empty($postarray['body']) || trim($postarray['body']) == "") {
1938                                 Logger::log('Empty body for post ' . $post->id_str . ' and user ' . $uid, Logger::DEBUG);
1939                                 continue;
1940                         }
1941
1942                         $notify = false;
1943
1944                         if (empty($postarray['thr-parent'])) {
1945                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
1946                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
1947                                         $notify = PRIORITY_MEDIUM;
1948                                 }
1949                         }
1950
1951                         $item = Item::insert($postarray, $notify);
1952                         $postarray["id"] = $item;
1953
1954                         Logger::log('User ' . $uid . ' posted home timeline item ' . $item);
1955                 }
1956         }
1957         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
1958
1959         Logger::log('Last timeline ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
1960
1961         // Fetching mentions
1962         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
1963
1964         $first_time = ($lastid == "");
1965
1966         if ($lastid != "") {
1967                 $parameters["since_id"] = $lastid;
1968         }
1969
1970         try {
1971                 $items = $connection->get('statuses/mentions_timeline', $parameters);
1972         } catch (TwitterOAuthException $e) {
1973                 Logger::warning('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
1974                 return;
1975         }
1976
1977         if (!is_array($items)) {
1978                 Logger::warning("mentions are no arrays", ['items' => $items]);
1979                 return;
1980         }
1981
1982         $posts = array_reverse($items);
1983
1984         Logger::log("Fetching mentions for user " . $uid . " " . sizeof($posts) . " items", Logger::DEBUG);
1985
1986         if (count($posts)) {
1987                 foreach ($posts as $post) {
1988                         if ($post->id_str > $lastid) {
1989                                 $lastid = $post->id_str;
1990                         }
1991
1992                         if ($first_time) {
1993                                 continue;
1994                         }
1995
1996                         if ($post->in_reply_to_status_id_str != "") {
1997                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
1998                         }
1999
2000                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2001
2002                         if (empty($postarray['body'])) {
2003                                 continue;
2004                         }
2005
2006                         $item = Item::insert($postarray);
2007
2008                         Logger::log('User ' . $uid . ' posted mention timeline item ' . $item);
2009                 }
2010         }
2011
2012         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2013
2014         Logger::log('Last mentions ID for user ' . $uid . ' is now ' . $lastid, Logger::DEBUG);
2015 }
2016
2017 function twitter_fetch_own_contact(App $a, $uid)
2018 {
2019         $ckey    = DI::config()->get('twitter', 'consumerkey');
2020         $csecret = DI::config()->get('twitter', 'consumersecret');
2021         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2022         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2023
2024         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2025
2026         $contact_id = 0;
2027
2028         if ($own_id == "") {
2029                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2030
2031                 // Fetching user data
2032                 // get() may throw TwitterOAuthException, but we will catch it later
2033                 $user = $connection->get('account/verify_credentials');
2034                 if (empty($user->id_str)) {
2035                         return false;
2036                 }
2037
2038                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2039
2040                 $contact_id = twitter_fetch_contact($uid, $user, true);
2041         } else {
2042                 $r = q("SELECT * FROM `contact` WHERE `uid` = %d AND `alias` = '%s' LIMIT 1",
2043                         intval($uid),
2044                         DBA::escape("twitter::" . $own_id));
2045                 if (DBA::isResult($r)) {
2046                         $contact_id = $r[0]["id"];
2047                 } else {
2048                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2049                 }
2050         }
2051
2052         return $contact_id;
2053 }
2054
2055 function twitter_is_retweet(App $a, $uid, $body)
2056 {
2057         $body = trim($body);
2058
2059         // Skip if it isn't a pure repeated messages
2060         // Does it start with a share?
2061         if (strpos($body, "[share") > 0) {
2062                 return false;
2063         }
2064
2065         // Does it end with a share?
2066         if (strlen($body) > (strrpos($body, "[/share]") + 8)) {
2067                 return false;
2068         }
2069
2070         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2071         // Skip if there is no shared message in there
2072         if ($body == $attributes) {
2073                 return false;
2074         }
2075
2076         $link = "";
2077         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2078         if (!empty($matches[1])) {
2079                 $link = $matches[1];
2080         }
2081
2082         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2083         if (!empty($matches[1])) {
2084                 $link = $matches[1];
2085         }
2086
2087         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2088         if ($id == $link) {
2089                 return false;
2090         }
2091         return twitter_retweet($uid, $id);
2092 }
2093
2094 function twitter_retweet(int $uid, int $id, int $item_id = 0)
2095 {
2096         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2097
2098         $ckey    = DI::config()->get('twitter', 'consumerkey');
2099         $csecret = DI::config()->get('twitter', 'consumersecret');
2100         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2101         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2102
2103         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2104         $result = $connection->post('statuses/retweet/' . $id);
2105
2106         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2107
2108         if (!empty($item_id) && !empty($result->id_str)) {
2109                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2110                 Item::update(['extid' => "twitter::" . $result->id_str], ['id' => $item_id]);
2111         }
2112
2113         return !isset($result->errors);
2114 }
2115
2116 function twitter_update_mentions($body)
2117 {
2118         $URLSearchString = "^\[\]";
2119         $return = preg_replace_callback(
2120                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2121                 function ($matches) {
2122                         if (strpos($matches[1], 'twitter.com')) {
2123                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2124                         } else {
2125                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2126                         }
2127
2128                         return $return;
2129                 },
2130                 $body
2131         );
2132
2133         return $return;
2134 }
2135
2136 function twitter_convert_share(array $attributes, array $author_contact, $content, $is_quote_share)
2137 {
2138         if (empty($author_contact)) {
2139                 return $content . "\n\n" . $attributes['link'];
2140         }
2141
2142         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2143                 $mention = '@' . $author_contact['nick'];
2144         } else {
2145                 $mention = $author_contact['addr'];
2146         }
2147
2148         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2149 }