]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
c55489a00836c9aeeb58e0f29d56e519aae6f9e5
[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\Group;
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);
530         }
531 }
532
533 function twitter_item_by_link(array &$hookData)
534 {
535         // Don't overwrite an existing result
536         if (isset($hookData['item_id'])) {
537                 return;
538         }
539
540         // Relevancy check
541         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $hookData['uri'], $matches)) {
542                 return;
543         }
544
545         // From now on, any early return should abort the whole chain since we've established it was a Twitter URL
546         $hookData['item_id'] = false;
547
548         // Node-level configuration check
549         if (empty(DI::config()->get('twitter', 'consumerkey')) || empty(DI::config()->get('twitter', 'consumersecret'))) {
550                 return;
551         }
552
553         // No anonymous import
554         if (!$hookData['uid']) {
555                 return;
556         }
557
558         if (
559                 empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthtoken'))
560                 || empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthsecret'))
561         ) {
562                 DI::sysmsg()->addNotice(DI::l10n()->t('Please connect a Twitter account in your Social Network settings to import Twitter posts.'));
563                 return;
564         }
565
566         $status = twitter_statuses_show($matches[1]);
567
568         if (empty($status->id_str)) {
569                 DI::sysmsg()->addNotice(DI::l10n()->t('Twitter post not found.'));
570                 return;
571         }
572
573         $item = twitter_createpost($hookData['uid'], $status, [], true, false, false);
574         if (!empty($item)) {
575                 $hookData['item_id'] = Item::insert($item);
576         }
577 }
578
579 function twitter_api_post(string $apiPath, string $pid, int $uid): ?object
580 {
581         if (empty($pid)) {
582                 return null;
583         }
584
585         return twitter_api_call($uid, $apiPath, ['id' => $pid]);
586 }
587
588 function twitter_api_call(int $uid, string $apiPath, array $parameters = []): ?object
589 {
590         $ckey = DI::config()->get('twitter', 'consumerkey');
591         $csecret = DI::config()->get('twitter', 'consumersecret');
592         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
593         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
594
595         // If the addon is not configured (general or for this user) quit here
596         if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
597                 return null;
598         }
599
600         try {
601                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
602                 $result = $connection->post($apiPath, $parameters);
603
604                 if ($connection->getLastHttpCode() != 200) {
605                         throw new Exception($result->errors[0]->message ?? json_encode($result), $connection->getLastHttpCode());
606                 }
607
608                 if (!empty($result->errors)) {
609                         throw new Exception($result->errors[0]->message, $result->errors[0]->code);
610                 }
611
612                 Logger::info('[twitter] API call successful', ['apiPath' => $apiPath, 'parameters' => $parameters]);
613                 Logger::debug('[twitter] API call result', ['apiPath' => $apiPath, 'parameters' => $parameters, 'result' => $result]);
614
615                 return $result;
616         } catch (TwitterOAuthException $twitterOAuthException) {
617                 Logger::notice('Unable to communicate with twitter', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $twitterOAuthException->getCode(), 'exception' => $twitterOAuthException]);
618                 return null;
619         } catch (Exception $e) {
620                 Logger::notice('[twitter] API call failed', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $e->getCode(), 'message' => $e->getMessage()]);
621                 return null;
622         }
623 }
624
625 function twitter_get_id(string $uri)
626 {
627         if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
628                 return 0;
629         }
630
631         $id = substr($uri, 9);
632         if (!is_numeric($id)) {
633                 return 0;
634         }
635
636         return (int)$id;
637 }
638
639 function twitter_post_hook(array &$b)
640 {
641         DI::logger()->debug('Invoke post hook', $b);
642
643         if ($b['deleted']) {
644                 twitter_delete_item($b);
645                 return;
646         }
647
648         // Post to Twitter
649         if (!DI::pConfig()->get($b['uid'], 'twitter', 'import')
650                 && ($b['private'] || ($b['created'] !== $b['edited']))) {
651                 return;
652         }
653
654         $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
655
656         $thr_parent = null;
657
658         if ($b['parent'] != $b['id']) {
659                 Logger::debug('Got comment', ['item' => $b]);
660
661                 // Looking if its a reply to a twitter post
662                 if (!twitter_get_id($b['parent-uri']) &&
663                         !twitter_get_id($b['extid']) &&
664                         !twitter_get_id($b['thr-parent'])) {
665                         Logger::info('No twitter post', ['parent' => $b['parent']]);
666                         return;
667                 }
668
669                 $condition = ['uri' => $b['thr-parent'], 'uid' => $b['uid']];
670                 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
671                 if (!DBA::isResult($thr_parent)) {
672                         Logger::notice('No parent found', ['thr-parent' => $b['thr-parent']]);
673                         return;
674                 }
675
676                 if ($thr_parent['author-network'] == Protocol::TWITTER) {
677                         $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
678                         $nicknameplain = '@' . $thr_parent['author-nick'];
679
680                         Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b['body']]);
681                         if ((strpos($b['body'], $nickname) === false) && (strpos($b['body'], $nicknameplain) === false)) {
682                                 $b['body'] = $nickname . ' ' . $b['body'];
683                         }
684                 }
685
686                 Logger::debug('Parent found', ['parent' => $thr_parent]);
687         } else {
688                 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
689                         return;
690                 }
691
692                 // Dont't post if the post doesn't belong to us.
693                 // This is a check for forum postings
694                 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
695                 if ($b['contact-id'] != $self['id']) {
696                         return;
697                 }
698         }
699
700         if ($b['verb'] == Activity::LIKE) {
701                 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
702
703                 twitter_api_post('favorites/create', twitter_get_id($b['thr-parent']), $b['uid']);
704
705                 return;
706         }
707
708         if ($b['verb'] == Activity::ANNOUNCE) {
709                 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
710                 twitter_retweet($b['uid'], twitter_get_id($b['thr-parent']));
711                 return;
712         }
713
714         if ($b['created'] !== $b['edited']) {
715                 return;
716         }
717
718         // if post comes from twitter don't send it back
719         if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
720                 return;
721         }
722
723         if ($b['app'] == 'Twitter') {
724                 return;
725         }
726
727         Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
728
729         DI::pConfig()->load($b['uid'], 'twitter');
730
731         $ckey    = DI::config()->get('twitter', 'consumerkey');
732         $csecret = DI::config()->get('twitter', 'consumersecret');
733         $otoken  = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
734         $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
735
736         if ($ckey && $csecret && $otoken && $osecret) {
737                 Logger::info('We have customer key and oauth stuff, going to send.');
738
739                 // If it's a repeated message from twitter then do a native retweet and exit
740                 if (twitter_is_retweet($b['uid'], $b['body'])) {
741                         return;
742                 }
743
744                 Codebird::setConsumerKey($ckey, $csecret);
745                 $cb = Codebird::getInstance();
746                 $cb->setToken($otoken, $osecret);
747
748                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
749
750                 // Set the timeout for upload to 30 seconds
751                 $connection->setTimeouts(10, 30);
752
753                 $max_char = 280;
754
755                 // Handling non-native reshares
756                 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
757                         $b['body'],
758                         function (array $attributes, array $author_contact, $content, $is_quote_share) {
759                                 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
760                         }
761                 );
762
763                 $b['body'] = twitter_update_mentions($b['body']);
764
765                 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
766                 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
767                 $msg = $msgarr['text'];
768
769                 if (($msg == '') && isset($msgarr['title'])) {
770                         $msg = Plaintext::shorten($msgarr['title'], $max_char - 50, $b['uid']);
771                 }
772
773                 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
774                 if (!empty($msgarr['url']) && (strpos($msg, $msgarr['url']) === false) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
775                         $msg .= "\n" . $msgarr['url'];
776                 }
777
778                 if (empty($msg)) {
779                         Logger::notice('Empty message', ['id' => $b['id']]);
780                         return;
781                 }
782
783                 // and now tweet it :-)
784                 $post = [];
785
786                 if (!empty($msgarr['images']) || !empty($msgarr['remote_images'])) {
787                         Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images'] ?? [], 'remote_images' => $msgarr['remote_images'] ?? []]);
788                         try {
789                                 $media_ids = [];
790                                 foreach ($msgarr['images'] ?? [] as $image) {
791                                         if (count($media_ids) == 4) {
792                                                 continue;
793                                         }
794                                         try {
795                                                 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
796                                         } catch (\Throwable $th) {
797                                                 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
798                                         }
799                                 }
800
801                                 foreach ($msgarr['remote_images'] ?? [] as $image) {
802                                         if (count($media_ids) == 4) {
803                                                 continue;
804                                         }
805                                         try {
806                                                 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
807                                         } catch (\Throwable $th) {
808                                                 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
809                                         }
810                                 }
811                                 $post['media_ids'] = implode(',', $media_ids);
812                                 if (empty($post['media_ids'])) {
813                                         unset($post['media_ids']);
814                                 }
815                         } catch (Exception $e) {
816                                 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
817                         }
818                 }
819
820                 if (!DI::pConfig()->get($b['uid'], 'twitter', 'thread') || empty($msgarr['parts']) || (count($msgarr['parts']) == 1)) {
821                         Logger::debug('Post single message', ['id' => $b['id']]);
822
823                         $post['status'] = $msg;
824
825                         if ($thr_parent) {
826                                 $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
827                         }
828
829                         $result = $connection->post('statuses/update', $post);
830                         Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
831
832                         if (!empty($result->source)) {
833                                 DI::keyValue()->set('twitter_application_name', strip_tags($result->source));
834                         }
835
836                         if (!empty($result->errors)) {
837                                 Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
838                                 Worker::defer();
839                         } elseif ($thr_parent) {
840                                 Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
841                                 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
842                         }
843                 } else {
844                         if ($thr_parent) {
845                                 $in_reply_to_status_id = twitter_get_id($thr_parent['uri']);
846                         } else {
847                                 $in_reply_to_status_id = 0;
848                         }
849
850                         Logger::debug('Post message thread', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
851                         foreach ($msgarr['parts'] as $key => $part) {
852                                 $post['status'] = $part;
853
854                                 if ($in_reply_to_status_id) {
855                                         $post['in_reply_to_status_id'] = $in_reply_to_status_id;
856                                 }
857
858                                 $result = $connection->post('statuses/update', $post);
859                                 Logger::debug('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $result]);
860
861                                 if (!empty($result->errors)) {
862                                         Logger::warning('Send to Twitter failed', ['part' => $key, 'id' => $b['id'], 'error' => $result->errors]);
863                                         Worker::defer();
864                                         break;
865                                 } elseif ($key == 0) {
866                                         Logger::debug('Updating extid', ['part' => $key, 'id' => $b['id'], 'extid' => $result->id_str]);
867                                         Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
868                                 }
869
870                                 if (!empty($result->source)) {
871                                         $application_name = strip_tags($result->source);
872                                 }
873
874                                 $in_reply_to_status_id = $result->id_str;
875                                 unset($post['media_ids']);
876                         }
877
878                         if (!empty($application_name)) {
879                                 DI::keyValue()->set('twitter_application_name', strip_tags($application_name));
880                         }
881                 }
882         }
883 }
884
885 function twitter_upload_image($connection, $cb, array $image, array $item)
886 {
887         if (!empty($image['id'])) {
888                 $photo = Photo::selectFirst([], ['id' => $image['id']]);
889         } else {
890                 $photo = Photo::createPhotoForExternalResource($image['url']);
891         }
892
893         $tempfile = tempnam(System::getTempPath(), 'cache');
894         file_put_contents($tempfile, Photo::getImageForPhoto($photo));
895
896         Logger::info('Uploading', ['id' => $item['id'], 'image' => $image]);
897         $media = $connection->upload('media/upload', ['media' => $tempfile]);
898
899         unlink($tempfile);
900
901         if (isset($media->media_id_string)) {
902                 $media_id = $media->media_id_string;
903
904                 if (!empty($image['description'])) {
905                         $data = ['media_id' => $media->media_id_string,
906                                 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
907                         $ret = $cb->media_metadata_create($data);
908                         Logger::info('Metadata create', ['id' => $item['id'], 'data' => $data, 'return' => $ret]);
909                 }
910         } else {
911                 Logger::error('Failed upload', ['id' => $item['id'], 'image' => $image['url'], 'return' => $media]);
912                 throw new Exception('Failed upload of ' . $image['url']);
913         }
914
915         return $media_id;
916 }
917
918 function twitter_delete_item(array $item)
919 {
920         if (!$item['deleted']) {
921                 return;
922         }
923
924         if ($item['parent'] != $item['id']) {
925                 Logger::debug('Deleting comment/announce', ['item' => $item]);
926
927                 // Looking if it's a reply to a twitter post
928                 if (!twitter_get_id($item['parent-uri']) &&
929                         !twitter_get_id($item['extid']) &&
930                         !twitter_get_id($item['thr-parent'])) {
931                         Logger::info('No twitter post', ['parent' => $item['parent']]);
932                         return;
933                 }
934
935                 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
936                 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
937                 if (!DBA::isResult($thr_parent)) {
938                         Logger::notice('No parent found', ['thr-parent' => $item['thr-parent']]);
939                         return;
940                 }
941
942                 Logger::debug('Parent found', ['parent' => $thr_parent]);
943         } else {
944                 if (!strstr($item['extid'], 'twitter')) {
945                         DI::logger()->info('Not a Twitter post', ['extid' => $item['extid']]);
946                         return;
947                 }
948
949                 // Don't delete if the post doesn't belong to us.
950                 // This is a check for forum postings
951                 $self = DBA::selectFirst('contact', ['id'], ['uid' => $item['uid'], 'self' => true]);
952                 if ($item['contact-id'] != $self['id']) {
953                         DI::logger()->info('Don\'t delete if the post doesn\'t belong to the user', ['contact-id' => $item['contact-id'], 'self' => $self['id']]);
954                         return;
955                 }
956         }
957
958         /**
959          * @TODO Remaining caveat: Comments posted on Twitter and imported in Friendica do not trigger any Notifier task,
960          *       possibly because they are private to the user and don't require any remote deletion notifications sent.
961          *       Comments posted on Friendica and mirrored on Twitter trigger the Notifier task and the Twitter counter-part
962          *       will be deleted accordingly.
963          */
964         if ($item['verb'] == Activity::POST) {
965                 Logger::info('Delete post/comment', ['uid' => $item['uid'], 'id' => twitter_get_id($item['extid'])]);
966                 twitter_api_post('statuses/destroy', twitter_get_id($item['extid']), $item['uid']);
967                 return;
968         }
969
970         if ($item['verb'] == Activity::LIKE) {
971                 Logger::info('Unlike', ['uid' => $item['uid'], 'id' => twitter_get_id($item['thr-parent'])]);
972                 twitter_api_post('favorites/destroy', twitter_get_id($item['thr-parent']), $item['uid']);
973                 return;
974         }
975
976         if ($item['verb'] == Activity::ANNOUNCE && !empty($thr_parent['uri'])) {
977                 Logger::info('Unretweet', ['uid' => $item['uid'], 'extid' => $thr_parent['uri'], 'id' => twitter_get_id($thr_parent['uri'])]);
978                 twitter_api_post('statuses/unretweet', twitter_get_id($thr_parent['uri']), $item['uid']);
979                 return;
980         }
981 }
982
983 function twitter_addon_admin_post()
984 {
985         DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
986         DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
987 }
988
989 function twitter_addon_admin(string &$o)
990 {
991         $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
992
993         $o = Renderer::replaceMacros($t, [
994                 '$submit' => DI::l10n()->t('Save Settings'),
995                 // name, label, value, help, [extra values]
996                 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
997                 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
998         ]);
999 }
1000
1001 function twitter_cron()
1002 {
1003         $last = DI::keyValue()->get('twitter_last_poll');
1004
1005         $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
1006         if (!$poll_interval) {
1007                 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
1008         }
1009
1010         if ($last) {
1011                 $next = $last + ($poll_interval * 60);
1012                 if ($next > time()) {
1013                         Logger::notice('twitter: poll intervall not reached');
1014                         return;
1015                 }
1016         }
1017         Logger::notice('twitter: cron_start');
1018
1019         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
1020         foreach ($pconfigs as $rr) {
1021                 Logger::notice('Fetching', ['user' => $rr['uid']]);
1022                 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
1023         }
1024
1025         $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
1026         if ($abandon_days < 1) {
1027                 $abandon_days = 0;
1028         }
1029
1030         $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
1031
1032         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1033         foreach ($pconfigs as $rr) {
1034                 if ($abandon_days != 0) {
1035                         if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
1036                                 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
1037                                 continue;
1038                         }
1039                 }
1040
1041                 Logger::notice('importing timeline', ['user' => $rr['uid']]);
1042                 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
1043                 /*
1044                         // To-Do
1045                         // check for new contacts once a day
1046                         $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
1047                         if($last_contact_check)
1048                         $next_contact_check = $last_contact_check + 86400;
1049                         else
1050                         $next_contact_check = 0;
1051
1052                         if($next_contact_check <= time()) {
1053                         pumpio_getallusers($rr["uid"]);
1054                         DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1055                         }
1056                         */
1057         }
1058
1059         Logger::notice('twitter: cron_end');
1060
1061         DI::keyValue()->set('twitter_last_poll', time());
1062 }
1063
1064 function twitter_expire()
1065 {
1066         $days = DI::config()->get('twitter', 'expire');
1067
1068         if ($days == 0) {
1069                 return;
1070         }
1071
1072         Logger::notice('Start deleting expired posts');
1073
1074         $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
1075         while ($row = Post::fetch($r)) {
1076                 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1077                 Item::markForDeletionById($row['id']);
1078         }
1079         DBA::close($r);
1080
1081         Logger::notice('End deleting expired posts');
1082
1083         Logger::notice('Start expiry');
1084
1085         $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1086         foreach ($pconfigs as $rr) {
1087                 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1088                 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1089         }
1090
1091         Logger::notice('End expiry');
1092 }
1093
1094 function twitter_prepare_body(array &$b)
1095 {
1096         if ($b['item']['network'] != Protocol::TWITTER) {
1097                 return;
1098         }
1099
1100         if ($b['preview']) {
1101                 $max_char = 280;
1102                 $item = $b['item'];
1103                 $item['plink'] = DI::baseUrl() . '/display/' . $item['guid'];
1104
1105                 $condition = ['uri' => $item['thr-parent'], 'uid' => DI::userSession()->getLocalUserId()];
1106                 $orig_post = Post::selectFirst(['author-link'], $condition);
1107                 if (DBA::isResult($orig_post)) {
1108                         $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1109                         $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1110                         $nicknameplain = '@' . $nicknameplain;
1111
1112                         if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1113                                 $item['body'] = $nickname . ' ' . $item['body'];
1114                         }
1115                 }
1116
1117                 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1118                 $msg = $msgarr['text'];
1119
1120                 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1121                         $msg .= ' ' . $msgarr['url'];
1122                 }
1123
1124                 if (isset($msgarr['image'])) {
1125                         $msg .= ' ' . $msgarr['image'];
1126                 }
1127
1128                 $b['html'] = nl2br(htmlspecialchars($msg));
1129         }
1130 }
1131
1132 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1133 {
1134         if ($twitterOAuth === null) {
1135                 $ckey = DI::config()->get('twitter', 'consumerkey');
1136                 $csecret = DI::config()->get('twitter', 'consumersecret');
1137
1138                 if (empty($ckey) || empty($csecret)) {
1139                         return new stdClass();
1140                 }
1141
1142                 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1143         }
1144
1145         $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1146
1147         return $twitterOAuth->get('statuses/show', $parameters);
1148 }
1149
1150 /**
1151  * Parse Twitter status URLs since Twitter removed OEmbed
1152  *
1153  * @param array $b Expected format:
1154  *                 [
1155  *                      'url' => [URL to parse],
1156  *                      'format' => 'json'|'',
1157  *                      'text' => Output parameter
1158  *                 ]
1159  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1160  */
1161 function twitter_parse_link(array &$b)
1162 {
1163         // Only handle Twitter status URLs
1164         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1165                 return;
1166         }
1167
1168         $status = twitter_statuses_show($matches[1]);
1169
1170         if (empty($status->id)) {
1171                 return;
1172         }
1173
1174         $item = twitter_createpost(0, $status, [], true, false, true);
1175         if (empty($item)) {
1176                 return;
1177         }
1178
1179         if ($b['format'] == 'json') {
1180                 $images = [];
1181                 foreach ($status->extended_entities->media ?? [] as $media) {
1182                         if (!empty($media->media_url_https)) {
1183                                 $images[] = [
1184                                         'src'    => $media->media_url_https,
1185                                         'width'  => $media->sizes->thumb->w,
1186                                         'height' => $media->sizes->thumb->h,
1187                                 ];
1188                         }
1189                 }
1190
1191                 $b['text'] = [
1192                         'data' => [
1193                                 'type' => 'link',
1194                                 'url' => $item['plink'],
1195                                 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1196                                 'text' => BBCode::toPlaintext($item['body'], false),
1197                                 'images' => $images,
1198                         ],
1199                         'contentType' => 'attachment',
1200                         'success' => true,
1201                 ];
1202         } else {
1203                 $b['text'] = BBCode::getShareOpeningTag(
1204                         $item['author-name'],
1205                         $item['author-link'],
1206                         $item['author-avatar'],
1207                         $item['plink'],
1208                         $item['created']
1209                 );
1210                 $b['text'] .= $item['body'] . '[/share]';
1211         }
1212 }
1213
1214
1215 /*********************
1216  *
1217  * General functions
1218  *
1219  *********************/
1220
1221
1222 /**
1223  * @brief Build the item array for the mirrored post
1224  *
1225  * @param integer $uid User id
1226  * @param object $post Twitter object with the post
1227  *
1228  * @return array item data to be posted
1229  */
1230 function twitter_do_mirrorpost(int $uid, $post)
1231 {
1232         $datarray['uid'] = $uid;
1233         $datarray['extid'] = 'twitter::' . $post->id;
1234         $datarray['title'] = '';
1235
1236         if (!empty($post->retweeted_status)) {
1237                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1238                 $item = twitter_createpost($uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1239
1240                 if (empty($item)) {
1241                         return [];
1242                 }
1243
1244                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1245                         $item['author-name'],
1246                         $item['author-link'],
1247                         $item['author-avatar'],
1248                         $item['plink'],
1249                         $item['created']
1250                 );
1251
1252                 $datarray['body'] .= $item['body'] . '[/share]';
1253         } else {
1254                 $item = twitter_createpost($uid, $post, ['id' => 0], false, false, false, -1);
1255
1256                 if (empty($item)) {
1257                         return [];
1258                 }
1259
1260                 $datarray['body'] = $item['body'];
1261         }
1262
1263         $datarray['app'] = $item['app'];
1264         $datarray['verb'] = $item['verb'];
1265
1266         if (isset($item['location'])) {
1267                 $datarray['location'] = $item['location'];
1268         }
1269
1270         if (isset($item['coord'])) {
1271                 $datarray['coord'] = $item['coord'];
1272         }
1273
1274         return $datarray;
1275 }
1276
1277 /**
1278  * Fetches the Twitter user's own posts
1279  *
1280  * @param int $uid
1281  * @return void
1282  * @throws Exception
1283  */
1284 function twitter_fetchtimeline(int $uid): void
1285 {
1286         $ckey    = DI::config()->get('twitter', 'consumerkey');
1287         $csecret = DI::config()->get('twitter', 'consumersecret');
1288         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1289         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1290         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1291
1292         $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
1293
1294         if ($application_name == '') {
1295                 $application_name = DI::baseUrl()->getHost();
1296         }
1297
1298         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1299
1300         // Ensure to have the own contact
1301         try {
1302                 twitter_fetch_own_contact($uid);
1303         } catch (TwitterOAuthException $e) {
1304                 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1305                 return;
1306         }
1307
1308         $parameters = [
1309                 'exclude_replies' => true,
1310                 'trim_user' => false,
1311                 'contributor_details' => true,
1312                 'include_rts' => true,
1313                 'tweet_mode' => 'extended',
1314                 'include_ext_alt_text' => true,
1315         ];
1316
1317         $first_time = ($lastid == '');
1318
1319         if ($lastid != '') {
1320                 $parameters['since_id'] = $lastid;
1321         }
1322
1323         try {
1324                 $items = $connection->get('statuses/user_timeline', $parameters);
1325         } catch (TwitterOAuthException $e) {
1326                 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1327                 return;
1328         }
1329
1330         if (!is_array($items)) {
1331                 Logger::notice('No items', ['user' => $uid]);
1332                 return;
1333         }
1334
1335         $posts = array_reverse($items);
1336
1337         Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1338
1339         if (count($posts)) {
1340                 foreach ($posts as $post) {
1341                         if ($post->id_str > $lastid) {
1342                                 $lastid = $post->id_str;
1343                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1344                         }
1345
1346                         if ($first_time) {
1347                                 Logger::notice('First time, continue');
1348                                 continue;
1349                         }
1350
1351                         if (stristr($post->source, $application_name)) {
1352                                 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1353                                 continue;
1354                         }
1355                         Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1356
1357                         $mirrorpost = twitter_do_mirrorpost($uid, $post);
1358
1359                         if (empty($mirrorpost['body'])) {
1360                                 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1361                                 continue;
1362                         }
1363
1364                         Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1365
1366                         Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1367                 }
1368         }
1369         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1370         Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1371 }
1372
1373 function twitter_fix_avatar($avatar)
1374 {
1375         $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1376
1377         $info = Images::getInfoFromURLCached($new_avatar);
1378         if (!$info) {
1379                 $new_avatar = $avatar;
1380         }
1381
1382         return $new_avatar;
1383 }
1384
1385 function twitter_get_relation($uid, $target, $contact = [])
1386 {
1387         if (isset($contact['rel'])) {
1388                 $relation = $contact['rel'];
1389         } else {
1390                 $relation = 0;
1391         }
1392
1393         $ckey = DI::config()->get('twitter', 'consumerkey');
1394         $csecret = DI::config()->get('twitter', 'consumersecret');
1395         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1396         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1397         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1398
1399         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1400         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1401
1402         try {
1403                 $status = $connection->get('friendships/show', $parameters);
1404                 if ($connection->getLastHttpCode() !== 200) {
1405                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1406                 }
1407
1408                 $following = $status->relationship->source->following;
1409                 $followed = $status->relationship->source->followed_by;
1410
1411                 if ($following && !$followed) {
1412                         $relation = Contact::SHARING;
1413                 } elseif (!$following && $followed) {
1414                         $relation = Contact::FOLLOWER;
1415                 } elseif ($following && $followed) {
1416                         $relation = Contact::FRIEND;
1417                 } elseif (!$following && !$followed) {
1418                         $relation = 0;
1419                 }
1420
1421                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1422         } catch (Throwable $e) {
1423                 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1424         }
1425
1426         return $relation;
1427 }
1428
1429 /**
1430  * @param $data
1431  * @return array
1432  */
1433 function twitter_user_to_contact($data)
1434 {
1435         if (empty($data->id_str)) {
1436                 return [];
1437         }
1438
1439         $baseurl = 'https://twitter.com';
1440         $url = $baseurl . '/' . $data->screen_name;
1441         $addr = $data->screen_name . '@twitter.com';
1442
1443         $fields = [
1444                 'url'      => $url,
1445                 'nurl'     => Strings::normaliseLink($url),
1446                 'uri-id'   => ItemURI::getIdByURI($url),
1447                 'network'  => Protocol::TWITTER,
1448                 'alias'    => 'twitter::' . $data->id_str,
1449                 'baseurl'  => $baseurl,
1450                 'name'     => $data->name,
1451                 'nick'     => $data->screen_name,
1452                 'addr'     => $addr,
1453                 'location' => $data->location,
1454                 'about'    => $data->description,
1455                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1456                 'header'   => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1457         ];
1458
1459         return $fields;
1460 }
1461
1462 function twitter_get_contact($data, int $uid = 0)
1463 {
1464         $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1465         if (DBA::isResult($contact)) {
1466                 return $contact['id'];
1467         } else {
1468                 return twitter_fetch_contact($uid, $data, false);
1469         }
1470 }
1471
1472 function twitter_fetch_contact($uid, $data, $create_user)
1473 {
1474         $fields = twitter_user_to_contact($data);
1475
1476         if (empty($fields)) {
1477                 return -1;
1478         }
1479
1480         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1481         $avatar = $fields['photo'];
1482         unset($fields['photo']);
1483
1484         // Update the public contact
1485         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1486         if (DBA::isResult($pcontact)) {
1487                 $cid = $pcontact['id'];
1488         } else {
1489                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1490         }
1491
1492         if (!empty($cid)) {
1493                 Contact::update($fields, ['id' => $cid]);
1494                 Contact::updateAvatar($cid, $avatar);
1495         } else {
1496                 Logger::notice('No contact found', ['fields' => $fields]);
1497         }
1498
1499         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1500         if (!DBA::isResult($contact) && empty($cid)) {
1501                 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1502                 return 0;
1503         } elseif (!$create_user) {
1504                 return $cid;
1505         }
1506
1507         if (!DBA::isResult($contact)) {
1508                 $relation = twitter_get_relation($uid, $data->screen_name);
1509
1510                 // create contact record
1511                 $fields['uid'] = $uid;
1512                 $fields['created'] = DateTimeFormat::utcNow();
1513                 $fields['poll'] = 'twitter::' . $data->id_str;
1514                 $fields['rel'] = $relation;
1515                 $fields['priority'] = 1;
1516                 $fields['writable'] = true;
1517                 $fields['blocked'] = false;
1518                 $fields['readonly'] = false;
1519                 $fields['pending'] = false;
1520
1521                 if (!Contact::insert($fields)) {
1522                         return false;
1523                 }
1524
1525                 $contact_id = DBA::lastInsertId();
1526
1527                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1528         } else {
1529                 if ($contact['readonly'] || $contact['blocked']) {
1530                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1531                         return -1;
1532                 }
1533
1534                 $contact_id = $contact['id'];
1535                 $update = false;
1536
1537                 // Update the contact relation once per day
1538                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1539                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1540                         $update = true;
1541                 }
1542
1543                 if ($contact['name'] != $data->name) {
1544                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1545                         $update = true;
1546                 }
1547
1548                 if ($contact['nick'] != $data->screen_name) {
1549                         $fields['uri-date'] = DateTimeFormat::utcNow();
1550                         $update = true;
1551                 }
1552
1553                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1554                         $update = true;
1555                 }
1556
1557                 if ($update) {
1558                         $fields['updated'] = DateTimeFormat::utcNow();
1559                         Contact::update($fields, ['id' => $contact['id']]);
1560                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1561                 }
1562         }
1563
1564         Contact::updateAvatar($contact_id, $avatar);
1565
1566         if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1567                 twitter_auto_follow($uid, $data);
1568         }
1569
1570         return $contact_id;
1571 }
1572
1573 /**
1574  * Follow a fediverse account that is proived in the name or the profile
1575  *
1576  * @param integer $uid
1577  * @param object $data
1578  */
1579 function twitter_auto_follow(int $uid, object $data)
1580 {
1581         $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1582
1583         // Search for user@domain.tld in the name
1584         if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1585                 if (twitter_add_contact($match[1], true, $uid)) {
1586                         return;
1587                 }
1588         }
1589
1590         // Search for @user@domain.tld in the description
1591         if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1592                 if (twitter_add_contact($match[1], true, $uid)) {
1593                         return;
1594                 }
1595         }
1596
1597         // Search for user@domain.tld in the description
1598         // We don't probe here, since this could be a mail address
1599         if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1600                 if (twitter_add_contact($match[1], false, $uid)) {
1601                         return;
1602                 }
1603         }
1604
1605         // Search for profile links in the description
1606         foreach ($data->entities->description->urls as $url) {
1607                 if (!empty($url->expanded_url)) {
1608                         // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1609                         twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1610                 }
1611         }
1612 }
1613
1614 /**
1615  * Check if the provided address is a fediverse account and adds it
1616  *
1617  * @param string $addr
1618  * @param boolean $probe
1619  * @param integer $uid
1620  * @return boolean
1621  */
1622 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1623 {
1624         $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1625         if (empty($contact)) {
1626                 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1627                 return false;
1628         }
1629
1630         if (!in_array($contact['network'], Protocol::FEDERATED)) {
1631                 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1632                 return false;
1633         }
1634
1635         if (Contact::isSharing($contact['id'], $uid)) {
1636                 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1637                 return true;
1638         }
1639
1640         Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1641         Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1642
1643         return true;
1644 }
1645
1646 /**
1647  * @param string $screen_name
1648  * @return stdClass|null
1649  * @throws Exception
1650  */
1651 function twitter_fetchuser($screen_name)
1652 {
1653         $ckey = DI::config()->get('twitter', 'consumerkey');
1654         $csecret = DI::config()->get('twitter', 'consumersecret');
1655
1656         try {
1657                 // Fetching user data
1658                 $connection = new TwitterOAuth($ckey, $csecret);
1659                 $parameters = ['screen_name' => $screen_name];
1660                 $user = $connection->get('users/show', $parameters);
1661         } catch (TwitterOAuthException $e) {
1662                 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1663                 return null;
1664         }
1665
1666         if (!is_object($user)) {
1667                 return null;
1668         }
1669
1670         return $user;
1671 }
1672
1673 /**
1674  * Replaces Twitter entities with Friendica-friendly links.
1675  *
1676  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1677  *
1678  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1679  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1680  * index to be correct even after the last replacement.
1681  *
1682  * @param string   $body
1683  * @param stdClass $status
1684  * @return array
1685  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1686  */
1687 function twitter_expand_entities($body, stdClass $status)
1688 {
1689         $plain = $body;
1690         $contains_urls = false;
1691
1692         $taglist = [];
1693
1694         $replacementList = [];
1695
1696         foreach ($status->entities->hashtags AS $hashtag) {
1697                 $replace = '#[url=' . DI::baseUrl() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1698                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1699
1700                 $replacementList[$hashtag->indices[0]] = [
1701                         'replace' => $replace,
1702                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1703                 ];
1704         }
1705
1706         foreach ($status->entities->user_mentions AS $mention) {
1707                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1708                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1709
1710                 $replacementList[$mention->indices[0]] = [
1711                         'replace' => $replace,
1712                         'length' => $mention->indices[1] - $mention->indices[0],
1713                 ];
1714         }
1715
1716         foreach ($status->entities->urls ?? [] as $url) {
1717                 $plain = str_replace($url->url, '', $plain);
1718
1719                 if ($url->url && $url->expanded_url && $url->display_url) {
1720                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1721                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1722                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1723                         ) {
1724                                 $replacementList[$url->indices[0]] = [
1725                                         'replace' => '',
1726                                         'length' => $url->indices[1] - $url->indices[0],
1727                                 ];
1728                                 continue;
1729                         }
1730
1731                         $contains_urls = true;
1732
1733                         $expanded_url = $url->expanded_url;
1734
1735                         // Quickfix: Workaround for URL with '[' and ']' in it
1736                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1737                                 $expanded_url = $url->url;
1738                         }
1739
1740                         $replacementList[$url->indices[0]] = [
1741                                 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1742                                 'length' => $url->indices[1] - $url->indices[0],
1743                         ];
1744                 }
1745         }
1746
1747         krsort($replacementList);
1748
1749         foreach ($replacementList as $startIndex => $parameters) {
1750                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1751         }
1752
1753         $body = trim($body);
1754
1755         return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1756 }
1757
1758 /**
1759  * Store entity attachments
1760  *
1761  * @param integer $uriId
1762  * @param object $post Twitter object with the post
1763  */
1764 function twitter_store_attachments(int $uriId, $post)
1765 {
1766         if (!empty($post->extended_entities->media)) {
1767                 foreach ($post->extended_entities->media AS $medium) {
1768                         switch ($medium->type) {
1769                                 case 'photo':
1770                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1771
1772                                         $attachment['url'] = $medium->media_url_https . '?name=large';
1773                                         $attachment['width'] = $medium->sizes->large->w;
1774                                         $attachment['height'] = $medium->sizes->large->h;
1775
1776                                         if ($medium->sizes->small->w != $attachment['width']) {
1777                                                 $attachment['preview'] = $medium->media_url_https . '?name=small';
1778                                                 $attachment['preview-width'] = $medium->sizes->small->w;
1779                                                 $attachment['preview-height'] = $medium->sizes->small->h;
1780                                         }
1781
1782                                         $attachment['name'] = $medium->display_url ?? null;
1783                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1784                                         Logger::debug('Photo attachment', ['attachment' => $attachment]);
1785                                         Post\Media::insert($attachment);
1786                                         break;
1787                                 case 'video':
1788                                 case 'animated_gif':
1789                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1790                                         if (is_array($medium->video_info->variants)) {
1791                                                 $bitrate = 0;
1792                                                 // We take the video with the highest bitrate
1793                                                 foreach ($medium->video_info->variants AS $variant) {
1794                                                         if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1795                                                                 $attachment['url'] = $variant->url;
1796                                                                 $bitrate = $variant->bitrate;
1797                                                         }
1798                                                 }
1799                                         }
1800
1801                                         $attachment['name'] = $medium->display_url ?? null;
1802                                         $attachment['preview'] = $medium->media_url_https . ':small';
1803                                         $attachment['preview-width'] = $medium->sizes->small->w;
1804                                         $attachment['preview-height'] = $medium->sizes->small->h;
1805                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1806                                         Logger::debug('Video attachment', ['attachment' => $attachment]);
1807                                         Post\Media::insert($attachment);
1808                                         break;
1809                                 default:
1810                                         Logger::notice('Unknown media type', ['medium' => $medium]);
1811                         }
1812                 }
1813         }
1814
1815         if (!empty($post->entities->urls)) {
1816                 foreach ($post->entities->urls as $url) {
1817                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1818                         Logger::debug('Attached link', ['attachment' => $attachment]);
1819                         Post\Media::insert($attachment);
1820                 }
1821         }
1822 }
1823
1824 /**
1825  * @brief Fetch media entities and add media links to the body
1826  *
1827  * @param object  $post      Twitter object with the post
1828  * @param array   $postarray Array of the item that is about to be posted
1829  * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1830  */
1831 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1832 {
1833         // There are no media entities? So we quit.
1834         if (empty($post->extended_entities->media)) {
1835                 return;
1836         }
1837
1838         // This is a pure media post, first search for all media urls
1839         $media = [];
1840         foreach ($post->extended_entities->media AS $medium) {
1841                 if (!isset($media[$medium->url])) {
1842                         $media[$medium->url] = '';
1843                 }
1844                 switch ($medium->type) {
1845                         case 'photo':
1846                                 if (!empty($medium->ext_alt_text)) {
1847                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1848                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1849                                 } else {
1850                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1851                                 }
1852
1853                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1854                                 $postarray['post-type'] = Item::PT_IMAGE;
1855                                 break;
1856                         case 'video':
1857                                 // Currently deactivated, since this causes the video to be display before the content
1858                                 // We have to figure out a better way for declaring the post type and the display style.
1859                                 //$postarray['post-type'] = Item::PT_VIDEO;
1860                         case 'animated_gif':
1861                                 if (!empty($medium->ext_alt_text)) {
1862                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1863                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1864                                 } else {
1865                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1866                                 }
1867
1868                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1869                                 if (is_array($medium->video_info->variants)) {
1870                                         $bitrate = 0;
1871                                         // We take the video with the highest bitrate
1872                                         foreach ($medium->video_info->variants AS $variant) {
1873                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1874                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1875                                                         $bitrate = $variant->bitrate;
1876                                                 }
1877                                         }
1878                                 }
1879                                 break;
1880                 }
1881         }
1882
1883         if ($uriId != -1) {
1884                 foreach ($media AS $key => $value) {
1885                         $postarray['body'] = str_replace($key, '', $postarray['body']);
1886                 }
1887                 return;
1888         }
1889
1890         // Now we replace the media urls.
1891         foreach ($media AS $key => $value) {
1892                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1893         }
1894 }
1895
1896 /**
1897  * Undocumented function
1898  *
1899  * @param integer $uid User ID
1900  * @param object $post Incoming Twitter post
1901  * @param array $self
1902  * @param bool $create_user Should users be created?
1903  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1904  * @param bool $noquote
1905  * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1906  * @return array item array
1907  */
1908 function twitter_createpost(int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1909 {
1910         $postarray = [];
1911         $postarray['network'] = Protocol::TWITTER;
1912         $postarray['uid'] = $uid;
1913         $postarray['wall'] = 0;
1914         $postarray['uri'] = 'twitter::' . $post->id_str;
1915         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1916         $postarray['source'] = json_encode($post);
1917         $postarray['direction'] = Conversation::PULL;
1918
1919         if (empty($uriId)) {
1920                 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1921         }
1922
1923         // Don't import our own comments
1924         if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1925                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1926                 return [];
1927         }
1928
1929         $contactid = 0;
1930
1931         if ($post->in_reply_to_status_id_str != '') {
1932                 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1933
1934                 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1935                 if (!DBA::isResult($item)) {
1936                         $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1937                 }
1938
1939                 if (DBA::isResult($item)) {
1940                         $postarray['thr-parent'] = $item['uri'];
1941                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1942                 } else {
1943                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1944                 }
1945
1946                 // Is it me?
1947                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1948
1949                 if ($post->user->id_str == $own_id) {
1950                         $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1951                         if (DBA::isResult($self)) {
1952                                 $contactid = $self['id'];
1953
1954                                 $postarray['owner-id']     = Contact::getIdForURL($self['url']);
1955                                 $postarray['owner-name']   = $self['name'];
1956                                 $postarray['owner-link']   = $self['url'];
1957                                 $postarray['owner-avatar'] = $self['photo'];
1958                         } else {
1959                                 Logger::error('No self contact found', ['uid' => $uid]);
1960                                 return [];
1961                         }
1962                 }
1963                 // Don't create accounts of people who just comment something
1964                 $create_user = false;
1965         } else {
1966                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1967         }
1968
1969         if ($contactid == 0) {
1970                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1971
1972                 $postarray['owner-id']     = twitter_get_contact($post->user);
1973                 $postarray['owner-name']   = $post->user->name;
1974                 $postarray['owner-link']   = 'https://twitter.com/' . $post->user->screen_name;
1975                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1976         }
1977
1978         if (($contactid == 0) && !$only_existing_contact) {
1979                 $contactid = $self['id'];
1980         } elseif ($contactid <= 0) {
1981                 Logger::info('Contact ID is zero or less than zero.');
1982                 return [];
1983         }
1984
1985         $postarray['contact-id']    = $contactid;
1986         $postarray['verb']          = Activity::POST;
1987         $postarray['author-id']     = $postarray['owner-id'];
1988         $postarray['author-name']   = $postarray['owner-name'];
1989         $postarray['author-link']   = $postarray['owner-link'];
1990         $postarray['author-avatar'] = $postarray['owner-avatar'];
1991         $postarray['plink']         = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1992         $postarray['app']           = strip_tags($post->source);
1993
1994         if ($post->user->protected) {
1995                 $postarray['private']   = Item::PRIVATE;
1996                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1997         } else {
1998                 $postarray['private']   = Item::UNLISTED;
1999                 $postarray['allow_cid'] = '';
2000         }
2001
2002         if (!empty($post->full_text)) {
2003                 $postarray['body'] = $post->full_text;
2004         } else {
2005                 $postarray['body'] = $post->text;
2006         }
2007
2008         // When the post contains links then use the correct object type
2009         if (count($post->entities->urls) > 0) {
2010                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2011         }
2012
2013         // Search for media links
2014         twitter_media_entities($post, $postarray, $uriId);
2015
2016         $converted = twitter_expand_entities($postarray['body'], $post);
2017
2018         // When the post contains external links then images or videos are just "decorations".
2019         if (!empty($converted['urls'])) {
2020                 $postarray['post-type'] = Item::PT_NOTE;
2021         }
2022
2023         $postarray['body'] = $converted['body'];
2024         $postarray['created'] = DateTimeFormat::utc($post->created_at);
2025         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2026
2027         if ($uriId > 0) {
2028                 twitter_store_tags($uriId, $converted['taglist']);
2029                 twitter_store_attachments($uriId, $post);
2030         }
2031
2032         if (!empty($post->place->name)) {
2033                 $postarray['location'] = $post->place->name;
2034         }
2035         if (!empty($post->place->full_name)) {
2036                 $postarray['location'] = $post->place->full_name;
2037         }
2038         if (!empty($post->geo->coordinates)) {
2039                 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2040         }
2041         if (!empty($post->coordinates->coordinates)) {
2042                 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2043         }
2044         if (!empty($post->retweeted_status)) {
2045                 $retweet = twitter_createpost($uid, $post->retweeted_status, $self, false, false, $noquote);
2046
2047                 if (empty($retweet)) {
2048                         return [];
2049                 }
2050
2051                 if (!$noquote) {
2052                         // Store the original tweet
2053                         Item::insert($retweet);
2054
2055                         // CHange the other post into a reshare activity
2056                         $postarray['verb'] = Activity::ANNOUNCE;
2057                         $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2058                         $postarray['object-type'] = Activity\ObjectType::NOTE;
2059
2060                         $postarray['thr-parent'] = $retweet['uri'];
2061                 } else {
2062                         $retweet['source']       = $postarray['source'];
2063                         $retweet['direction']    = $postarray['direction'];
2064                         $retweet['private']      = $postarray['private'];
2065                         $retweet['allow_cid']    = $postarray['allow_cid'];
2066                         $retweet['contact-id']   = $postarray['contact-id'];
2067                         $retweet['owner-id']     = $postarray['owner-id'];
2068                         $retweet['owner-name']   = $postarray['owner-name'];
2069                         $retweet['owner-link']   = $postarray['owner-link'];
2070                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
2071
2072                         $postarray = $retweet;
2073                 }
2074         }
2075
2076         if (!empty($post->quoted_status)) {
2077                 if ($noquote) {
2078                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2079                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2080                 } else {
2081                         $quoted = twitter_createpost(0, $post->quoted_status, $self, false, false, true);
2082                         if (!empty($quoted)) {
2083                                 Item::insert($quoted);
2084                                 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2085                                 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2086
2087                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2088                                                 $quoted['author-name'],
2089                                                 $quoted['author-link'],
2090                                                 $quoted['author-avatar'],
2091                                                 $quoted['plink'],
2092                                                 $quoted['created'],
2093                                                 $post['guid'] ?? ''
2094                                         );
2095
2096                                 $postarray['body'] .= $quoted['body'] . '[/share]';
2097                         } else {
2098                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2099                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2100                         }
2101                 }
2102         }
2103
2104         return $postarray;
2105 }
2106
2107 /**
2108  * Store tags and mentions
2109  *
2110  * @param integer $uriId
2111  * @param array $taglist
2112  * @return void
2113  */
2114 function twitter_store_tags(int $uriId, array $taglist)
2115 {
2116         foreach ($taglist as $tag) {
2117                 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2118         }
2119 }
2120
2121 function twitter_fetchparentposts(int $uid, $post, TwitterOAuth $connection, array $self)
2122 {
2123         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2124
2125         $posts = [];
2126
2127         while (!empty($post->in_reply_to_status_id_str)) {
2128                 try {
2129                         $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2130                 } catch (TwitterOAuthException $e) {
2131                         Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2132                         break;
2133                 }
2134
2135                 if (empty($post)) {
2136                         Logger::info("twitter_fetchparentposts: Can't fetch post");
2137                         break;
2138                 }
2139
2140                 if (empty($post->id_str)) {
2141                         Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2142                         break;
2143                 }
2144
2145                 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2146                         break;
2147                 }
2148
2149                 $posts[] = $post;
2150         }
2151
2152         Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2153
2154         $posts = array_reverse($posts);
2155
2156         if (!empty($posts)) {
2157                 foreach ($posts as $post) {
2158                         $postarray = twitter_createpost($uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2159
2160                         if (empty($postarray)) {
2161                                 continue;
2162                         }
2163
2164                         $item = Item::insert($postarray);
2165
2166                         $postarray['id'] = $item;
2167
2168                         Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2169                 }
2170         }
2171 }
2172
2173 /**
2174  * Fetches the posts received by the Twitter user
2175  *
2176  * @param int $uid
2177  * @return void
2178  * @throws Exception
2179  */
2180 function twitter_fetchhometimeline(int $uid): void
2181 {
2182         $ckey    = DI::config()->get('twitter', 'consumerkey');
2183         $csecret = DI::config()->get('twitter', 'consumersecret');
2184         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2185         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2186         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2187         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2188
2189         Logger::info('Fetching timeline', ['uid' => $uid]);
2190
2191         $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
2192
2193         if ($application_name == '') {
2194                 $application_name = DI::baseUrl()->getHost();
2195         }
2196
2197         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2198
2199         try {
2200                 $own_contact = twitter_fetch_own_contact($uid);
2201         } catch (TwitterOAuthException $e) {
2202                 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2203                 return;
2204         }
2205
2206         $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2207         if (DBA::isResult($contact)) {
2208                 $own_id = $contact['nick'];
2209         } else {
2210                 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2211                 return;
2212         }
2213
2214         $self = User::getOwnerDataById($uid);
2215         if ($self === false) {
2216                 Logger::warning('Own contact not found', ['uid' => $uid]);
2217                 return;
2218         }
2219
2220         $parameters = [
2221                 'exclude_replies' => false,
2222                 'trim_user' => false,
2223                 'contributor_details' => true,
2224                 'include_rts' => true,
2225                 'tweet_mode' => 'extended',
2226                 'include_ext_alt_text' => true,
2227                 //'count' => 200,
2228         ];
2229
2230         // Fetching timeline
2231         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2232
2233         $first_time = ($lastid == '');
2234
2235         if ($lastid != '') {
2236                 $parameters['since_id'] = $lastid;
2237         }
2238
2239         try {
2240                 $items = $connection->get('statuses/home_timeline', $parameters);
2241         } catch (TwitterOAuthException $e) {
2242                 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2243                 return;
2244         }
2245
2246         if (!is_array($items)) {
2247                 Logger::notice('home timeline is no array', ['items' => $items]);
2248                 return;
2249         }
2250
2251         if (empty($items)) {
2252                 Logger::info('No new timeline content', ['uid' => $uid]);
2253                 return;
2254         }
2255
2256         $posts = array_reverse($items);
2257
2258         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2259
2260         if (count($posts)) {
2261                 foreach ($posts as $post) {
2262                         if ($post->id_str > $lastid) {
2263                                 $lastid = $post->id_str;
2264                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2265                         }
2266
2267                         if ($first_time) {
2268                                 continue;
2269                         }
2270
2271                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2272                                 Logger::info('Skip previously sent post');
2273                                 continue;
2274                         }
2275
2276                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2277                                 Logger::info('Skip post that will be mirrored');
2278                                 continue;
2279                         }
2280
2281                         if ($post->in_reply_to_status_id_str != '') {
2282                                 twitter_fetchparentposts($uid, $post, $connection, $self);
2283                         }
2284
2285                         Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2286
2287                         $postarray = twitter_createpost($uid, $post, $self, $create_user, true, false);
2288
2289                         if (empty($postarray)) {
2290                                 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2291                                 continue;
2292                         }
2293
2294                         $notify = false;
2295
2296                         if (empty($postarray['thr-parent'])) {
2297                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2298                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2299                                         $notify = Worker::PRIORITY_MEDIUM;
2300                                 }
2301                         }
2302
2303                         $postarray['wall'] = (bool)$notify;
2304
2305                         $item = Item::insert($postarray, $notify);
2306                         $postarray['id'] = $item;
2307
2308                         Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2309                 }
2310         }
2311         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2312
2313         Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2314
2315         // Fetching mentions
2316         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2317
2318         $first_time = ($lastid == '');
2319
2320         if ($lastid != '') {
2321                 $parameters['since_id'] = $lastid;
2322         }
2323
2324         try {
2325                 $items = $connection->get('statuses/mentions_timeline', $parameters);
2326         } catch (TwitterOAuthException $e) {
2327                 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2328                 return;
2329         }
2330
2331         if (!is_array($items)) {
2332                 Logger::notice('mentions are no arrays', ['items' => $items]);
2333                 return;
2334         }
2335
2336         $posts = array_reverse($items);
2337
2338         Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2339
2340         if (count($posts)) {
2341                 foreach ($posts as $post) {
2342                         if ($post->id_str > $lastid) {
2343                                 $lastid = $post->id_str;
2344                         }
2345
2346                         if ($first_time) {
2347                                 continue;
2348                         }
2349
2350                         if ($post->in_reply_to_status_id_str != '') {
2351                                 twitter_fetchparentposts($uid, $post, $connection, $self);
2352                         }
2353
2354                         $postarray = twitter_createpost($uid, $post, $self, false, !$create_user, false);
2355
2356                         if (empty($postarray)) {
2357                                 continue;
2358                         }
2359
2360                         $item = Item::insert($postarray);
2361
2362                         Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2363                 }
2364         }
2365
2366         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2367
2368         Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2369 }
2370
2371 function twitter_fetch_own_contact(int $uid)
2372 {
2373         $ckey    = DI::config()->get('twitter', 'consumerkey');
2374         $csecret = DI::config()->get('twitter', 'consumersecret');
2375         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2376         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2377
2378         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2379
2380         $contact_id = 0;
2381
2382         if ($own_id == '') {
2383                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2384
2385                 // Fetching user data
2386                 // get() may throw TwitterOAuthException, but we will catch it later
2387                 $user = $connection->get('account/verify_credentials');
2388                 if (empty($user->id_str)) {
2389                         return false;
2390                 }
2391
2392                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2393
2394                 $contact_id = twitter_fetch_contact($uid, $user, true);
2395         } else {
2396                 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2397                 if (DBA::isResult($contact)) {
2398                         $contact_id = $contact['id'];
2399                 } else {
2400                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2401                 }
2402         }
2403
2404         return $contact_id;
2405 }
2406
2407 function twitter_is_retweet(int $uid, string $body): bool
2408 {
2409         $body = trim($body);
2410
2411         // Skip if it isn't a pure repeated messages
2412         // Does it start with a share?
2413         if (strpos($body, '[share') > 0) {
2414                 return false;
2415         }
2416
2417         // Does it end with a share?
2418         if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2419                 return false;
2420         }
2421
2422         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2423         // Skip if there is no shared message in there
2424         if ($body == $attributes) {
2425                 return false;
2426         }
2427
2428         $link = '';
2429         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2430         if (!empty($matches[1])) {
2431                 $link = $matches[1];
2432         }
2433
2434         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2435         if (!empty($matches[1])) {
2436                 $link = $matches[1];
2437         }
2438
2439         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2440         if ($id == $link) {
2441                 return false;
2442         }
2443         return twitter_retweet($uid, $id);
2444 }
2445
2446 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2447 {
2448         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2449
2450         $result = twitter_api_post('statuses/retweet', $id, $uid);
2451
2452         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2453
2454         if (!empty($item_id) && !empty($result->id_str)) {
2455                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2456                 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2457         }
2458
2459         return !isset($result->errors);
2460 }
2461
2462 function twitter_update_mentions(string $body): string
2463 {
2464         $URLSearchString = '^\[\]';
2465         $return = preg_replace_callback(
2466                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2467                 function ($matches) {
2468                         if (strpos($matches[1], 'twitter.com')) {
2469                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2470                         } else {
2471                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2472                         }
2473
2474                         return $return;
2475                 },
2476                 $body
2477         );
2478
2479         return $return;
2480 }
2481
2482 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2483 {
2484         if (empty($author_contact)) {
2485                 return $content . "\n\n" . $attributes['link'];
2486         }
2487
2488         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2489                 $mention = '@' . $author_contact['nick'];
2490         } else {
2491                 $mention = $author_contact['addr'];
2492         }
2493
2494         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2495 }