]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
Merge branch '2022.12-rc' of https://github.com/friendica/friendica-addons into 2022...
[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\ConfigFileLoader;
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(App $a, ConfigFileLoader $loader)
128 {
129         $a->getConfigCache()->load($loader->loadAddonConfig('twitter'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
130 }
131
132 function twitter_check_item_notification(App $a, 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(App $a, array &$data)
143 {
144         if ($data['protocol'] == Protocol::TWITTER) {
145                 $data['result'] = true;
146         }
147 }
148
149 function twitter_follow(App $a, 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 = $a->getLoggedInUserId();
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(App $a, 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(App $a, 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(App $a, 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(App $a, 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(App $a)
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(App $a, 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(App $a, 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(App $a, 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(App $a, 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(App $a, 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($a, $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(App $a, 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($a, $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(App $a)
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(App $a, 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(App $a)
1001 {
1002         $last = DI::config()->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($a, $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::config()->set('twitter', 'last_poll', time());
1061 }
1062
1063 function twitter_expire(App $a)
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(App $a, 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 App   $a
1153  * @param array $b Expected format:
1154  *                 [
1155  *                      'url' => [URL to parse],
1156  *                      'format' => 'json'|'',
1157  *                      'text' => Output parameter
1158  *                 ]
1159  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1160  */
1161 function twitter_parse_link(App $a, array &$b)
1162 {
1163         // Only handle Twitter status URLs
1164         if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1165                 return;
1166         }
1167
1168         $status = twitter_statuses_show($matches[1]);
1169
1170         if (empty($status->id)) {
1171                 return;
1172         }
1173
1174         $item = twitter_createpost($a, 0, $status, [], true, false, true);
1175         if (empty($item)) {
1176                 return;
1177         }
1178
1179         if ($b['format'] == 'json') {
1180                 $images = [];
1181                 foreach ($status->extended_entities->media ?? [] as $media) {
1182                         if (!empty($media->media_url_https)) {
1183                                 $images[] = [
1184                                         'src'    => $media->media_url_https,
1185                                         'width'  => $media->sizes->thumb->w,
1186                                         'height' => $media->sizes->thumb->h,
1187                                 ];
1188                         }
1189                 }
1190
1191                 $b['text'] = [
1192                         'data' => [
1193                                 'type' => 'link',
1194                                 'url' => $item['plink'],
1195                                 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1196                                 'text' => BBCode::toPlaintext($item['body'], false),
1197                                 'images' => $images,
1198                         ],
1199                         'contentType' => 'attachment',
1200                         'success' => true,
1201                 ];
1202         } else {
1203                 $b['text'] = BBCode::getShareOpeningTag(
1204                         $item['author-name'],
1205                         $item['author-link'],
1206                         $item['author-avatar'],
1207                         $item['plink'],
1208                         $item['created']
1209                 );
1210                 $b['text'] .= $item['body'] . '[/share]';
1211         }
1212 }
1213
1214
1215 /*********************
1216  *
1217  * General functions
1218  *
1219  *********************/
1220
1221
1222 /**
1223  * @brief Build the item array for the mirrored post
1224  *
1225  * @param App $a Application class
1226  * @param integer $uid User id
1227  * @param object $post Twitter object with the post
1228  *
1229  * @return array item data to be posted
1230  */
1231 function twitter_do_mirrorpost(App $a, int $uid, $post)
1232 {
1233         $datarray['uid'] = $uid;
1234         $datarray['extid'] = 'twitter::' . $post->id;
1235         $datarray['title'] = '';
1236
1237         if (!empty($post->retweeted_status)) {
1238                 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1239                 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1240
1241                 if (empty($item)) {
1242                         return [];
1243                 }
1244
1245                 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1246                         $item['author-name'],
1247                         $item['author-link'],
1248                         $item['author-avatar'],
1249                         $item['plink'],
1250                         $item['created']
1251                 );
1252
1253                 $datarray['body'] .= $item['body'] . '[/share]';
1254         } else {
1255                 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1256
1257                 if (empty($item)) {
1258                         return [];
1259                 }
1260
1261                 $datarray['body'] = $item['body'];
1262         }
1263
1264         $datarray['app'] = $item['app'];
1265         $datarray['verb'] = $item['verb'];
1266
1267         if (isset($item['location'])) {
1268                 $datarray['location'] = $item['location'];
1269         }
1270
1271         if (isset($item['coord'])) {
1272                 $datarray['coord'] = $item['coord'];
1273         }
1274
1275         return $datarray;
1276 }
1277
1278 /**
1279  * Fetches the Twitter user's own posts
1280  *
1281  * @param App $a
1282  * @param int $uid
1283  * @return void
1284  * @throws Exception
1285  */
1286 function twitter_fetchtimeline(App $a, int $uid): void
1287 {
1288         $ckey    = DI::config()->get('twitter', 'consumerkey');
1289         $csecret = DI::config()->get('twitter', 'consumersecret');
1290         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1291         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1292         $lastid  = DI::pConfig()->get($uid, 'twitter', 'lastid');
1293
1294         $application_name = DI::config()->get('twitter', 'application_name');
1295
1296         if ($application_name == '') {
1297                 $application_name = DI::baseUrl()->getHostname();
1298         }
1299
1300         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1301
1302         // Ensure to have the own contact
1303         try {
1304                 twitter_fetch_own_contact($a, $uid);
1305         } catch (TwitterOAuthException $e) {
1306                 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1307                 return;
1308         }
1309
1310         $parameters = [
1311                 'exclude_replies' => true,
1312                 'trim_user' => false,
1313                 'contributor_details' => true,
1314                 'include_rts' => true,
1315                 'tweet_mode' => 'extended',
1316                 'include_ext_alt_text' => true,
1317         ];
1318
1319         $first_time = ($lastid == '');
1320
1321         if ($lastid != '') {
1322                 $parameters['since_id'] = $lastid;
1323         }
1324
1325         try {
1326                 $items = $connection->get('statuses/user_timeline', $parameters);
1327         } catch (TwitterOAuthException $e) {
1328                 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1329                 return;
1330         }
1331
1332         if (!is_array($items)) {
1333                 Logger::notice('No items', ['user' => $uid]);
1334                 return;
1335         }
1336
1337         $posts = array_reverse($items);
1338
1339         Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1340
1341         if (count($posts)) {
1342                 foreach ($posts as $post) {
1343                         if ($post->id_str > $lastid) {
1344                                 $lastid = $post->id_str;
1345                                 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1346                         }
1347
1348                         if ($first_time) {
1349                                 Logger::notice('First time, continue');
1350                                 continue;
1351                         }
1352
1353                         if (stristr($post->source, $application_name)) {
1354                                 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1355                                 continue;
1356                         }
1357                         Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1358
1359                         $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1360
1361                         if (empty($mirrorpost['body'])) {
1362                                 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1363                                 continue;
1364                         }
1365
1366                         Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1367
1368                         Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1369                 }
1370         }
1371         DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1372         Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1373 }
1374
1375 function twitter_fix_avatar($avatar)
1376 {
1377         $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1378
1379         $info = Images::getInfoFromURLCached($new_avatar);
1380         if (!$info) {
1381                 $new_avatar = $avatar;
1382         }
1383
1384         return $new_avatar;
1385 }
1386
1387 function twitter_get_relation($uid, $target, $contact = [])
1388 {
1389         if (isset($contact['rel'])) {
1390                 $relation = $contact['rel'];
1391         } else {
1392                 $relation = 0;
1393         }
1394
1395         $ckey = DI::config()->get('twitter', 'consumerkey');
1396         $csecret = DI::config()->get('twitter', 'consumersecret');
1397         $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1398         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1399         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1400
1401         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1402         $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1403
1404         try {
1405                 $status = $connection->get('friendships/show', $parameters);
1406                 if ($connection->getLastHttpCode() !== 200) {
1407                         throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1408                 }
1409
1410                 $following = $status->relationship->source->following;
1411                 $followed = $status->relationship->source->followed_by;
1412
1413                 if ($following && !$followed) {
1414                         $relation = Contact::SHARING;
1415                 } elseif (!$following && $followed) {
1416                         $relation = Contact::FOLLOWER;
1417                 } elseif ($following && $followed) {
1418                         $relation = Contact::FRIEND;
1419                 } elseif (!$following && !$followed) {
1420                         $relation = 0;
1421                 }
1422
1423                 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1424         } catch (Throwable $e) {
1425                 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1426         }
1427
1428         return $relation;
1429 }
1430
1431 /**
1432  * @param $data
1433  * @return array
1434  */
1435 function twitter_user_to_contact($data)
1436 {
1437         if (empty($data->id_str)) {
1438                 return [];
1439         }
1440
1441         $baseurl = 'https://twitter.com';
1442         $url = $baseurl . '/' . $data->screen_name;
1443         $addr = $data->screen_name . '@twitter.com';
1444
1445         $fields = [
1446                 'url'      => $url,
1447                 'nurl'     => Strings::normaliseLink($url),
1448                 'uri-id'   => ItemURI::getIdByURI($url),
1449                 'network'  => Protocol::TWITTER,
1450                 'alias'    => 'twitter::' . $data->id_str,
1451                 'baseurl'  => $baseurl,
1452                 'name'     => $data->name,
1453                 'nick'     => $data->screen_name,
1454                 'addr'     => $addr,
1455                 'location' => $data->location,
1456                 'about'    => $data->description,
1457                 'photo'    => twitter_fix_avatar($data->profile_image_url_https),
1458                 'header'   => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1459         ];
1460
1461         return $fields;
1462 }
1463
1464 function twitter_get_contact($data, int $uid = 0)
1465 {
1466         $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1467         if (DBA::isResult($contact)) {
1468                 return $contact['id'];
1469         } else {
1470                 return twitter_fetch_contact($uid, $data, false);
1471         }
1472 }
1473
1474 function twitter_fetch_contact($uid, $data, $create_user)
1475 {
1476         $fields = twitter_user_to_contact($data);
1477
1478         if (empty($fields)) {
1479                 return -1;
1480         }
1481
1482         // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1483         $avatar = $fields['photo'];
1484         unset($fields['photo']);
1485
1486         // Update the public contact
1487         $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1488         if (DBA::isResult($pcontact)) {
1489                 $cid = $pcontact['id'];
1490         } else {
1491                 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1492         }
1493
1494         if (!empty($cid)) {
1495                 Contact::update($fields, ['id' => $cid]);
1496                 Contact::updateAvatar($cid, $avatar);
1497         } else {
1498                 Logger::notice('No contact found', ['fields' => $fields]);
1499         }
1500
1501         $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1502         if (!DBA::isResult($contact) && empty($cid)) {
1503                 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1504                 return 0;
1505         } elseif (!$create_user) {
1506                 return $cid;
1507         }
1508
1509         if (!DBA::isResult($contact)) {
1510                 $relation = twitter_get_relation($uid, $data->screen_name);
1511
1512                 // create contact record
1513                 $fields['uid'] = $uid;
1514                 $fields['created'] = DateTimeFormat::utcNow();
1515                 $fields['poll'] = 'twitter::' . $data->id_str;
1516                 $fields['rel'] = $relation;
1517                 $fields['priority'] = 1;
1518                 $fields['writable'] = true;
1519                 $fields['blocked'] = false;
1520                 $fields['readonly'] = false;
1521                 $fields['pending'] = false;
1522
1523                 if (!Contact::insert($fields)) {
1524                         return false;
1525                 }
1526
1527                 $contact_id = DBA::lastInsertId();
1528
1529                 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1530         } else {
1531                 if ($contact['readonly'] || $contact['blocked']) {
1532                         Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1533                         return -1;
1534                 }
1535
1536                 $contact_id = $contact['id'];
1537                 $update = false;
1538
1539                 // Update the contact relation once per day
1540                 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1541                         $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1542                         $update = true;
1543                 }
1544
1545                 if ($contact['name'] != $data->name) {
1546                         $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1547                         $update = true;
1548                 }
1549
1550                 if ($contact['nick'] != $data->screen_name) {
1551                         $fields['uri-date'] = DateTimeFormat::utcNow();
1552                         $update = true;
1553                 }
1554
1555                 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1556                         $update = true;
1557                 }
1558
1559                 if ($update) {
1560                         $fields['updated'] = DateTimeFormat::utcNow();
1561                         Contact::update($fields, ['id' => $contact['id']]);
1562                         Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1563                 }
1564         }
1565
1566         Contact::updateAvatar($contact_id, $avatar);
1567
1568         if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1569                 twitter_auto_follow($uid, $data);
1570         }
1571
1572         return $contact_id;
1573 }
1574
1575 /**
1576  * Follow a fediverse account that is proived in the name or the profile
1577  *
1578  * @param integer $uid
1579  * @param object $data
1580  */
1581 function twitter_auto_follow(int $uid, object $data)
1582 {
1583         $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1584
1585         // Search for user@domain.tld in the name
1586         if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1587                 if (twitter_add_contact($match[1], true, $uid)) {
1588                         return;
1589                 }
1590         }
1591
1592         // Search for @user@domain.tld in the description
1593         if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1594                 if (twitter_add_contact($match[1], true, $uid)) {
1595                         return;
1596                 }
1597         }
1598
1599         // Search for user@domain.tld in the description
1600         // We don't probe here, since this could be a mail address
1601         if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1602                 if (twitter_add_contact($match[1], false, $uid)) {
1603                         return;
1604                 }
1605         }
1606
1607         // Search for profile links in the description
1608         foreach ($data->entities->description->urls as $url) {
1609                 if (!empty($url->expanded_url)) {
1610                         // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1611                         twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1612                 }
1613         }
1614 }
1615
1616 /**
1617  * Check if the provided address is a fediverse account and adds it
1618  *
1619  * @param string $addr
1620  * @param boolean $probe
1621  * @param integer $uid
1622  * @return boolean
1623  */
1624 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1625 {
1626         $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1627         if (empty($contact)) {
1628                 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1629                 return false;
1630         }
1631
1632         if (!in_array($contact['network'], Protocol::FEDERATED)) {
1633                 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1634                 return false;
1635         }
1636
1637         if (Contact::isSharing($contact['id'], $uid)) {
1638                 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1639                 return true;
1640         }
1641
1642         Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1643         Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1644
1645         return true;
1646 }
1647
1648 /**
1649  * @param string $screen_name
1650  * @return stdClass|null
1651  * @throws Exception
1652  */
1653 function twitter_fetchuser($screen_name)
1654 {
1655         $ckey = DI::config()->get('twitter', 'consumerkey');
1656         $csecret = DI::config()->get('twitter', 'consumersecret');
1657
1658         try {
1659                 // Fetching user data
1660                 $connection = new TwitterOAuth($ckey, $csecret);
1661                 $parameters = ['screen_name' => $screen_name];
1662                 $user = $connection->get('users/show', $parameters);
1663         } catch (TwitterOAuthException $e) {
1664                 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1665                 return null;
1666         }
1667
1668         if (!is_object($user)) {
1669                 return null;
1670         }
1671
1672         return $user;
1673 }
1674
1675 /**
1676  * Replaces Twitter entities with Friendica-friendly links.
1677  *
1678  * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1679  *
1680  * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1681  * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1682  * index to be correct even after the last replacement.
1683  *
1684  * @param string   $body
1685  * @param stdClass $status
1686  * @return array
1687  * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1688  */
1689 function twitter_expand_entities($body, stdClass $status)
1690 {
1691         $plain = $body;
1692         $contains_urls = false;
1693
1694         $taglist = [];
1695
1696         $replacementList = [];
1697
1698         foreach ($status->entities->hashtags AS $hashtag) {
1699                 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1700                 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1701
1702                 $replacementList[$hashtag->indices[0]] = [
1703                         'replace' => $replace,
1704                         'length' => $hashtag->indices[1] - $hashtag->indices[0],
1705                 ];
1706         }
1707
1708         foreach ($status->entities->user_mentions AS $mention) {
1709                 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1710                 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1711
1712                 $replacementList[$mention->indices[0]] = [
1713                         'replace' => $replace,
1714                         'length' => $mention->indices[1] - $mention->indices[0],
1715                 ];
1716         }
1717
1718         foreach ($status->entities->urls ?? [] as $url) {
1719                 $plain = str_replace($url->url, '', $plain);
1720
1721                 if ($url->url && $url->expanded_url && $url->display_url) {
1722                         // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1723                         if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1724                                 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1725                         ) {
1726                                 $replacementList[$url->indices[0]] = [
1727                                         'replace' => '',
1728                                         'length' => $url->indices[1] - $url->indices[0],
1729                                 ];
1730                                 continue;
1731                         }
1732
1733                         $contains_urls = true;
1734
1735                         $expanded_url = $url->expanded_url;
1736
1737                         // Quickfix: Workaround for URL with '[' and ']' in it
1738                         if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1739                                 $expanded_url = $url->url;
1740                         }
1741
1742                         $replacementList[$url->indices[0]] = [
1743                                 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1744                                 'length' => $url->indices[1] - $url->indices[0],
1745                         ];
1746                 }
1747         }
1748
1749         krsort($replacementList);
1750
1751         foreach ($replacementList as $startIndex => $parameters) {
1752                 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1753         }
1754
1755         $body = trim($body);
1756
1757         return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1758 }
1759
1760 /**
1761  * Store entity attachments
1762  *
1763  * @param integer $uriId
1764  * @param object $post Twitter object with the post
1765  */
1766 function twitter_store_attachments(int $uriId, $post)
1767 {
1768         if (!empty($post->extended_entities->media)) {
1769                 foreach ($post->extended_entities->media AS $medium) {
1770                         switch ($medium->type) {
1771                                 case 'photo':
1772                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1773
1774                                         $attachment['url'] = $medium->media_url_https . '?name=large';
1775                                         $attachment['width'] = $medium->sizes->large->w;
1776                                         $attachment['height'] = $medium->sizes->large->h;
1777
1778                                         if ($medium->sizes->small->w != $attachment['width']) {
1779                                                 $attachment['preview'] = $medium->media_url_https . '?name=small';
1780                                                 $attachment['preview-width'] = $medium->sizes->small->w;
1781                                                 $attachment['preview-height'] = $medium->sizes->small->h;
1782                                         }
1783
1784                                         $attachment['name'] = $medium->display_url ?? null;
1785                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1786                                         Logger::debug('Photo attachment', ['attachment' => $attachment]);
1787                                         Post\Media::insert($attachment);
1788                                         break;
1789                                 case 'video':
1790                                 case 'animated_gif':
1791                                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1792                                         if (is_array($medium->video_info->variants)) {
1793                                                 $bitrate = 0;
1794                                                 // We take the video with the highest bitrate
1795                                                 foreach ($medium->video_info->variants AS $variant) {
1796                                                         if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1797                                                                 $attachment['url'] = $variant->url;
1798                                                                 $bitrate = $variant->bitrate;
1799                                                         }
1800                                                 }
1801                                         }
1802
1803                                         $attachment['name'] = $medium->display_url ?? null;
1804                                         $attachment['preview'] = $medium->media_url_https . ':small';
1805                                         $attachment['preview-width'] = $medium->sizes->small->w;
1806                                         $attachment['preview-height'] = $medium->sizes->small->h;
1807                                         $attachment['description'] = $medium->ext_alt_text ?? null;
1808                                         Logger::debug('Video attachment', ['attachment' => $attachment]);
1809                                         Post\Media::insert($attachment);
1810                                         break;
1811                                 default:
1812                                         Logger::notice('Unknown media type', ['medium' => $medium]);
1813                         }
1814                 }
1815         }
1816
1817         if (!empty($post->entities->urls)) {
1818                 foreach ($post->entities->urls as $url) {
1819                         $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1820                         Logger::debug('Attached link', ['attachment' => $attachment]);
1821                         Post\Media::insert($attachment);
1822                 }
1823         }
1824 }
1825
1826 /**
1827  * @brief Fetch media entities and add media links to the body
1828  *
1829  * @param object  $post      Twitter object with the post
1830  * @param array   $postarray Array of the item that is about to be posted
1831  * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1832  */
1833 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1834 {
1835         // There are no media entities? So we quit.
1836         if (empty($post->extended_entities->media)) {
1837                 return;
1838         }
1839
1840         // This is a pure media post, first search for all media urls
1841         $media = [];
1842         foreach ($post->extended_entities->media AS $medium) {
1843                 if (!isset($media[$medium->url])) {
1844                         $media[$medium->url] = '';
1845                 }
1846                 switch ($medium->type) {
1847                         case 'photo':
1848                                 if (!empty($medium->ext_alt_text)) {
1849                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1850                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1851                                 } else {
1852                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1853                                 }
1854
1855                                 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1856                                 $postarray['post-type'] = Item::PT_IMAGE;
1857                                 break;
1858                         case 'video':
1859                                 // Currently deactivated, since this causes the video to be display before the content
1860                                 // We have to figure out a better way for declaring the post type and the display style.
1861                                 //$postarray['post-type'] = Item::PT_VIDEO;
1862                         case 'animated_gif':
1863                                 if (!empty($medium->ext_alt_text)) {
1864                                         Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1865                                         $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1866                                 } else {
1867                                         $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1868                                 }
1869
1870                                 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1871                                 if (is_array($medium->video_info->variants)) {
1872                                         $bitrate = 0;
1873                                         // We take the video with the highest bitrate
1874                                         foreach ($medium->video_info->variants AS $variant) {
1875                                                 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1876                                                         $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1877                                                         $bitrate = $variant->bitrate;
1878                                                 }
1879                                         }
1880                                 }
1881                                 break;
1882                 }
1883         }
1884
1885         if ($uriId != -1) {
1886                 foreach ($media AS $key => $value) {
1887                         $postarray['body'] = str_replace($key, '', $postarray['body']);
1888                 }
1889                 return;
1890         }
1891
1892         // Now we replace the media urls.
1893         foreach ($media AS $key => $value) {
1894                 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1895         }
1896 }
1897
1898 /**
1899  * Undocumented function
1900  *
1901  * @param App $a
1902  * @param integer $uid User ID
1903  * @param object $post Incoming Twitter post
1904  * @param array $self
1905  * @param bool $create_user Should users be created?
1906  * @param bool $only_existing_contact Only import existing contacts if set to "true"
1907  * @param bool $noquote
1908  * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1909  * @return array item array
1910  */
1911 function twitter_createpost(App $a, int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1912 {
1913         $postarray = [];
1914         $postarray['network'] = Protocol::TWITTER;
1915         $postarray['uid'] = $uid;
1916         $postarray['wall'] = 0;
1917         $postarray['uri'] = 'twitter::' . $post->id_str;
1918         $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1919         $postarray['source'] = json_encode($post);
1920         $postarray['direction'] = Conversation::PULL;
1921
1922         if (empty($uriId)) {
1923                 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1924         }
1925
1926         // Don't import our own comments
1927         if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1928                 Logger::info('Item found', ['extid' => $postarray['uri']]);
1929                 return [];
1930         }
1931
1932         $contactid = 0;
1933
1934         if ($post->in_reply_to_status_id_str != '') {
1935                 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1936
1937                 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1938                 if (!DBA::isResult($item)) {
1939                         $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1940                 }
1941
1942                 if (DBA::isResult($item)) {
1943                         $postarray['thr-parent'] = $item['uri'];
1944                         $postarray['object-type'] = Activity\ObjectType::COMMENT;
1945                 } else {
1946                         $postarray['object-type'] = Activity\ObjectType::NOTE;
1947                 }
1948
1949                 // Is it me?
1950                 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1951
1952                 if ($post->user->id_str == $own_id) {
1953                         $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1954                         if (DBA::isResult($self)) {
1955                                 $contactid = $self['id'];
1956
1957                                 $postarray['owner-id']     = Contact::getIdForURL($self['url']);
1958                                 $postarray['owner-name']   = $self['name'];
1959                                 $postarray['owner-link']   = $self['url'];
1960                                 $postarray['owner-avatar'] = $self['photo'];
1961                         } else {
1962                                 Logger::error('No self contact found', ['uid' => $uid]);
1963                                 return [];
1964                         }
1965                 }
1966                 // Don't create accounts of people who just comment something
1967                 $create_user = false;
1968         } else {
1969                 $postarray['object-type'] = Activity\ObjectType::NOTE;
1970         }
1971
1972         if ($contactid == 0) {
1973                 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1974
1975                 $postarray['owner-id']     = twitter_get_contact($post->user);
1976                 $postarray['owner-name']   = $post->user->name;
1977                 $postarray['owner-link']   = 'https://twitter.com/' . $post->user->screen_name;
1978                 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1979         }
1980
1981         if (($contactid == 0) && !$only_existing_contact) {
1982                 $contactid = $self['id'];
1983         } elseif ($contactid <= 0) {
1984                 Logger::info('Contact ID is zero or less than zero.');
1985                 return [];
1986         }
1987
1988         $postarray['contact-id']    = $contactid;
1989         $postarray['verb']          = Activity::POST;
1990         $postarray['author-id']     = $postarray['owner-id'];
1991         $postarray['author-name']   = $postarray['owner-name'];
1992         $postarray['author-link']   = $postarray['owner-link'];
1993         $postarray['author-avatar'] = $postarray['owner-avatar'];
1994         $postarray['plink']         = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1995         $postarray['app']           = strip_tags($post->source);
1996
1997         if ($post->user->protected) {
1998                 $postarray['private']   = Item::PRIVATE;
1999                 $postarray['allow_cid'] = '<' . $self['id'] . '>';
2000         } else {
2001                 $postarray['private']   = Item::UNLISTED;
2002                 $postarray['allow_cid'] = '';
2003         }
2004
2005         if (!empty($post->full_text)) {
2006                 $postarray['body'] = $post->full_text;
2007         } else {
2008                 $postarray['body'] = $post->text;
2009         }
2010
2011         // When the post contains links then use the correct object type
2012         if (count($post->entities->urls) > 0) {
2013                 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2014         }
2015
2016         // Search for media links
2017         twitter_media_entities($post, $postarray, $uriId);
2018
2019         $converted = twitter_expand_entities($postarray['body'], $post);
2020
2021         // When the post contains external links then images or videos are just "decorations".
2022         if (!empty($converted['urls'])) {
2023                 $postarray['post-type'] = Item::PT_NOTE;
2024         }
2025
2026         $postarray['body'] = $converted['body'];
2027         $postarray['created'] = DateTimeFormat::utc($post->created_at);
2028         $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2029
2030         if ($uriId > 0) {
2031                 twitter_store_tags($uriId, $converted['taglist']);
2032                 twitter_store_attachments($uriId, $post);
2033         }
2034
2035         if (!empty($post->place->name)) {
2036                 $postarray['location'] = $post->place->name;
2037         }
2038         if (!empty($post->place->full_name)) {
2039                 $postarray['location'] = $post->place->full_name;
2040         }
2041         if (!empty($post->geo->coordinates)) {
2042                 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2043         }
2044         if (!empty($post->coordinates->coordinates)) {
2045                 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2046         }
2047         if (!empty($post->retweeted_status)) {
2048                 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
2049
2050                 if (empty($retweet)) {
2051                         return [];
2052                 }
2053
2054                 if (!$noquote) {
2055                         // Store the original tweet
2056                         Item::insert($retweet);
2057
2058                         // CHange the other post into a reshare activity
2059                         $postarray['verb'] = Activity::ANNOUNCE;
2060                         $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2061                         $postarray['object-type'] = Activity\ObjectType::NOTE;
2062
2063                         $postarray['thr-parent'] = $retweet['uri'];
2064                 } else {
2065                         $retweet['source']       = $postarray['source'];
2066                         $retweet['direction']    = $postarray['direction'];
2067                         $retweet['private']      = $postarray['private'];
2068                         $retweet['allow_cid']    = $postarray['allow_cid'];
2069                         $retweet['contact-id']   = $postarray['contact-id'];
2070                         $retweet['owner-id']     = $postarray['owner-id'];
2071                         $retweet['owner-name']   = $postarray['owner-name'];
2072                         $retweet['owner-link']   = $postarray['owner-link'];
2073                         $retweet['owner-avatar'] = $postarray['owner-avatar'];
2074
2075                         $postarray = $retweet;
2076                 }
2077         }
2078
2079         if (!empty($post->quoted_status)) {
2080                 if ($noquote) {
2081                         // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2082                         $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2083                 } else {
2084                         $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
2085                         if (!empty($quoted)) {
2086                                 Item::insert($quoted);
2087                                 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2088                                 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2089
2090                                 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2091                                                 $quoted['author-name'],
2092                                                 $quoted['author-link'],
2093                                                 $quoted['author-avatar'],
2094                                                 $quoted['plink'],
2095                                                 $quoted['created'],
2096                                                 $post['guid'] ?? ''
2097                                         );
2098
2099                                 $postarray['body'] .= $quoted['body'] . '[/share]';
2100                         } else {
2101                                 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2102                                 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2103                         }
2104                 }
2105         }
2106
2107         return $postarray;
2108 }
2109
2110 /**
2111  * Store tags and mentions
2112  *
2113  * @param integer $uriId
2114  * @param array $taglist
2115  * @return void
2116  */
2117 function twitter_store_tags(int $uriId, array $taglist)
2118 {
2119         foreach ($taglist as $tag) {
2120                 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2121         }
2122 }
2123
2124 function twitter_fetchparentposts(App $a, int $uid, $post, TwitterOAuth $connection, array $self)
2125 {
2126         Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2127
2128         $posts = [];
2129
2130         while (!empty($post->in_reply_to_status_id_str)) {
2131                 try {
2132                         $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2133                 } catch (TwitterOAuthException $e) {
2134                         Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2135                         break;
2136                 }
2137
2138                 if (empty($post)) {
2139                         Logger::info("twitter_fetchparentposts: Can't fetch post");
2140                         break;
2141                 }
2142
2143                 if (empty($post->id_str)) {
2144                         Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2145                         break;
2146                 }
2147
2148                 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2149                         break;
2150                 }
2151
2152                 $posts[] = $post;
2153         }
2154
2155         Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2156
2157         $posts = array_reverse($posts);
2158
2159         if (!empty($posts)) {
2160                 foreach ($posts as $post) {
2161                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2162
2163                         if (empty($postarray)) {
2164                                 continue;
2165                         }
2166
2167                         $item = Item::insert($postarray);
2168
2169                         $postarray['id'] = $item;
2170
2171                         Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2172                 }
2173         }
2174 }
2175
2176 /**
2177  * Fetches the posts received by the Twitter user
2178  *
2179  * @param App $a
2180  * @param int $uid
2181  * @return void
2182  * @throws Exception
2183  */
2184 function twitter_fetchhometimeline(App $a, int $uid): void
2185 {
2186         $ckey    = DI::config()->get('twitter', 'consumerkey');
2187         $csecret = DI::config()->get('twitter', 'consumersecret');
2188         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2189         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2190         $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2191         $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2192
2193         Logger::info('Fetching timeline', ['uid' => $uid]);
2194
2195         $application_name = DI::config()->get('twitter', 'application_name');
2196
2197         if ($application_name == '') {
2198                 $application_name = DI::baseUrl()->getHostname();
2199         }
2200
2201         $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2202
2203         try {
2204                 $own_contact = twitter_fetch_own_contact($a, $uid);
2205         } catch (TwitterOAuthException $e) {
2206                 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2207                 return;
2208         }
2209
2210         $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2211         if (DBA::isResult($contact)) {
2212                 $own_id = $contact['nick'];
2213         } else {
2214                 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2215                 return;
2216         }
2217
2218         $self = User::getOwnerDataById($uid);
2219         if ($self === false) {
2220                 Logger::warning('Own contact not found', ['uid' => $uid]);
2221                 return;
2222         }
2223
2224         $parameters = [
2225                 'exclude_replies' => false,
2226                 'trim_user' => false,
2227                 'contributor_details' => true,
2228                 'include_rts' => true,
2229                 'tweet_mode' => 'extended',
2230                 'include_ext_alt_text' => true,
2231                 //'count' => 200,
2232         ];
2233
2234         // Fetching timeline
2235         $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2236
2237         $first_time = ($lastid == '');
2238
2239         if ($lastid != '') {
2240                 $parameters['since_id'] = $lastid;
2241         }
2242
2243         try {
2244                 $items = $connection->get('statuses/home_timeline', $parameters);
2245         } catch (TwitterOAuthException $e) {
2246                 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2247                 return;
2248         }
2249
2250         if (!is_array($items)) {
2251                 Logger::notice('home timeline is no array', ['items' => $items]);
2252                 return;
2253         }
2254
2255         if (empty($items)) {
2256                 Logger::info('No new timeline content', ['uid' => $uid]);
2257                 return;
2258         }
2259
2260         $posts = array_reverse($items);
2261
2262         Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2263
2264         if (count($posts)) {
2265                 foreach ($posts as $post) {
2266                         if ($post->id_str > $lastid) {
2267                                 $lastid = $post->id_str;
2268                                 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2269                         }
2270
2271                         if ($first_time) {
2272                                 continue;
2273                         }
2274
2275                         if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2276                                 Logger::info('Skip previously sent post');
2277                                 continue;
2278                         }
2279
2280                         if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2281                                 Logger::info('Skip post that will be mirrored');
2282                                 continue;
2283                         }
2284
2285                         if ($post->in_reply_to_status_id_str != '') {
2286                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2287                         }
2288
2289                         Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2290
2291                         $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
2292
2293                         if (empty($postarray)) {
2294                                 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2295                                 continue;
2296                         }
2297
2298                         $notify = false;
2299
2300                         if (empty($postarray['thr-parent'])) {
2301                                 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2302                                 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2303                                         $notify = Worker::PRIORITY_MEDIUM;
2304                                 }
2305                         }
2306
2307                         $item = Item::insert($postarray, $notify);
2308                         $postarray['id'] = $item;
2309
2310                         Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2311                 }
2312         }
2313         DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2314
2315         Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2316
2317         // Fetching mentions
2318         $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2319
2320         $first_time = ($lastid == '');
2321
2322         if ($lastid != '') {
2323                 $parameters['since_id'] = $lastid;
2324         }
2325
2326         try {
2327                 $items = $connection->get('statuses/mentions_timeline', $parameters);
2328         } catch (TwitterOAuthException $e) {
2329                 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2330                 return;
2331         }
2332
2333         if (!is_array($items)) {
2334                 Logger::notice('mentions are no arrays', ['items' => $items]);
2335                 return;
2336         }
2337
2338         $posts = array_reverse($items);
2339
2340         Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2341
2342         if (count($posts)) {
2343                 foreach ($posts as $post) {
2344                         if ($post->id_str > $lastid) {
2345                                 $lastid = $post->id_str;
2346                         }
2347
2348                         if ($first_time) {
2349                                 continue;
2350                         }
2351
2352                         if ($post->in_reply_to_status_id_str != '') {
2353                                 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2354                         }
2355
2356                         $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2357
2358                         if (empty($postarray)) {
2359                                 continue;
2360                         }
2361
2362                         $item = Item::insert($postarray);
2363
2364                         Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2365                 }
2366         }
2367
2368         DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2369
2370         Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2371 }
2372
2373 function twitter_fetch_own_contact(App $a, int $uid)
2374 {
2375         $ckey    = DI::config()->get('twitter', 'consumerkey');
2376         $csecret = DI::config()->get('twitter', 'consumersecret');
2377         $otoken  = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2378         $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2379
2380         $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2381
2382         $contact_id = 0;
2383
2384         if ($own_id == '') {
2385                 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2386
2387                 // Fetching user data
2388                 // get() may throw TwitterOAuthException, but we will catch it later
2389                 $user = $connection->get('account/verify_credentials');
2390                 if (empty($user->id_str)) {
2391                         return false;
2392                 }
2393
2394                 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2395
2396                 $contact_id = twitter_fetch_contact($uid, $user, true);
2397         } else {
2398                 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2399                 if (DBA::isResult($contact)) {
2400                         $contact_id = $contact['id'];
2401                 } else {
2402                         DI::pConfig()->delete($uid, 'twitter', 'own_id');
2403                 }
2404         }
2405
2406         return $contact_id;
2407 }
2408
2409 function twitter_is_retweet(App $a, int $uid, string $body): bool
2410 {
2411         $body = trim($body);
2412
2413         // Skip if it isn't a pure repeated messages
2414         // Does it start with a share?
2415         if (strpos($body, '[share') > 0) {
2416                 return false;
2417         }
2418
2419         // Does it end with a share?
2420         if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2421                 return false;
2422         }
2423
2424         $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2425         // Skip if there is no shared message in there
2426         if ($body == $attributes) {
2427                 return false;
2428         }
2429
2430         $link = '';
2431         preg_match("/link='(.*?)'/ism", $attributes, $matches);
2432         if (!empty($matches[1])) {
2433                 $link = $matches[1];
2434         }
2435
2436         preg_match('/link="(.*?)"/ism', $attributes, $matches);
2437         if (!empty($matches[1])) {
2438                 $link = $matches[1];
2439         }
2440
2441         $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2442         if ($id == $link) {
2443                 return false;
2444         }
2445         return twitter_retweet($uid, $id);
2446 }
2447
2448 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2449 {
2450         Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2451
2452         $result = twitter_api_post('statuses/retweet', $id, $uid);
2453
2454         Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2455
2456         if (!empty($item_id) && !empty($result->id_str)) {
2457                 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2458                 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2459         }
2460
2461         return !isset($result->errors);
2462 }
2463
2464 function twitter_update_mentions(string $body): string
2465 {
2466         $URLSearchString = '^\[\]';
2467         $return = preg_replace_callback(
2468                 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2469                 function ($matches) {
2470                         if (strpos($matches[1], 'twitter.com')) {
2471                                 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2472                         } else {
2473                                 $return = $matches[2] . ' (' . $matches[1] . ')';
2474                         }
2475
2476                         return $return;
2477                 },
2478                 $body
2479         );
2480
2481         return $return;
2482 }
2483
2484 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2485 {
2486         if (empty($author_contact)) {
2487                 return $content . "\n\n" . $attributes['link'];
2488         }
2489
2490         if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2491                 $mention = '@' . $author_contact['nick'];
2492         } else {
2493                 $mention = $author_contact['addr'];
2494         }
2495
2496         return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];
2497 }