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