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