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