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