3 * Name: Twitter Connector
4 * Description: Bidirectional (posting, relaying and reading) connector for Twitter.
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>
11 * Copyright (c) 2011-2013 Tobias Diekershoff, Michael Vogel, Hypolite Petovan
12 * All rights reserved.
14 * Redistribution and use in source and binary forms, with or without
15 * modification, are permitted provided that the following conditions are met:
16 * * Redistributions of source code must retain the above copyright notice,
17 * this list of conditions and the following disclaimer.
18 * * Redistributions in binary form must reproduce the above
19 * * copyright notice, this list of conditions and the following disclaimer in
20 * the documentation and/or other materials provided with the distribution.
21 * * Neither the name of the <organization> nor the names of its contributors
22 * may be used to endorse or promote products derived from this software
23 * without specific prior written permission.
25 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
26 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
27 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28 * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT,
29 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
30 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
31 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
32 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
33 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
34 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
37 /* Twitter Addon for Friendica
39 * Author: Tobias Diekershoff
40 * tobias.diekershoff@gmx.net
42 * License:3-clause BSD license
45 * To use this addon you need a OAuth Consumer key pair (key & secret)
46 * you can get it from Twitter at https://twitter.com/apps
48 * Register your Friendica site as "Client" application with "Read & Write" access
49 * we do not need "Twitter as login". When you've registered the app you get the
50 * OAuth Consumer key and secret pair for your application/site.
52 * Add this key pair to your config/twitter.config.php file or use the admin panel.
56 * 'consumerkey' => '',
57 * 'consumersecret' => '',
61 * To activate the addon itself add it to the system.addon
62 * setting. After this, your user can configure their Twitter account settings
63 * from "Settings -> Addon Settings".
65 * Requirements: PHP5, curl
68 use Abraham\TwitterOAuth\TwitterOAuth;
69 use Abraham\TwitterOAuth\TwitterOAuthException;
70 use Codebird\Codebird;
72 use Friendica\Content\Text\BBCode;
73 use Friendica\Content\Text\Plaintext;
74 use Friendica\Core\Hook;
75 use Friendica\Core\Logger;
76 use Friendica\Core\Protocol;
77 use Friendica\Core\Renderer;
78 use Friendica\Core\Worker;
79 use Friendica\Database\DBA;
81 use Friendica\Model\Contact;
82 use Friendica\Model\Conversation;
83 use Friendica\Model\Group;
84 use Friendica\Model\Item;
85 use Friendica\Model\ItemURI;
86 use Friendica\Model\Post;
87 use Friendica\Model\Tag;
88 use Friendica\Model\User;
89 use Friendica\Protocol\Activity;
90 use Friendica\Core\Config\Util\ConfigFileManager;
91 use Friendica\Core\System;
92 use Friendica\Model\Photo;
93 use Friendica\Util\DateTimeFormat;
94 use Friendica\Util\Images;
95 use Friendica\Util\Strings;
97 require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
99 define('TWITTER_DEFAULT_POLL_INTERVAL', 5); // given in minutes
101 function twitter_install()
103 // we need some hooks, for the configuration and for sending tweets
104 Hook::register('load_config' , __FILE__, 'twitter_load_config');
105 Hook::register('connector_settings' , __FILE__, 'twitter_settings');
106 Hook::register('connector_settings_post', __FILE__, 'twitter_settings_post');
107 Hook::register('hook_fork' , __FILE__, 'twitter_hook_fork');
108 Hook::register('post_local' , __FILE__, 'twitter_post_local');
109 Hook::register('notifier_normal' , __FILE__, 'twitter_post_hook');
110 Hook::register('jot_networks' , __FILE__, 'twitter_jot_nets');
111 Hook::register('cron' , __FILE__, 'twitter_cron');
112 Hook::register('support_follow' , __FILE__, 'twitter_support_follow');
113 Hook::register('follow' , __FILE__, 'twitter_follow');
114 Hook::register('unfollow' , __FILE__, 'twitter_unfollow');
115 Hook::register('block' , __FILE__, 'twitter_block');
116 Hook::register('unblock' , __FILE__, 'twitter_unblock');
117 Hook::register('expire' , __FILE__, 'twitter_expire');
118 Hook::register('prepare_body' , __FILE__, 'twitter_prepare_body');
119 Hook::register('check_item_notification', __FILE__, 'twitter_check_item_notification');
120 Hook::register('probe_detect' , __FILE__, 'twitter_probe_detect');
121 Hook::register('item_by_link' , __FILE__, 'twitter_item_by_link');
122 Hook::register('parse_link' , __FILE__, 'twitter_parse_link');
123 Logger::info('installed twitter');
128 function twitter_load_config(ConfigFileManager $loader)
130 DI::app()->getConfigCache()->load($loader->loadAddonConfig('twitter'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
133 function twitter_check_item_notification(array &$notification_data)
135 $own_id = DI::pConfig()->get($notification_data['uid'], 'twitter', 'own_id');
137 $own_user = Contact::selectFirst(['url'], ['uid' => $notification_data['uid'], 'alias' => 'twitter::'.$own_id]);
139 $notification_data['profiles'][] = $own_user['url'];
143 function twitter_support_follow(array &$data)
145 if ($data['protocol'] == Protocol::TWITTER) {
146 $data['result'] = true;
150 function twitter_follow(array &$contact)
152 Logger::info('Check if contact is twitter contact', ['url' => $contact['url']]);
154 if (!strstr($contact['url'], '://twitter.com') && !strstr($contact['url'], '@twitter.com')) {
158 // contact seems to be a twitter contact, so continue
159 $nickname = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $contact['url']);
160 $nickname = str_replace('@twitter.com', '', $nickname);
162 $uid = DI::userSession()->getLocalUserId();
164 if (!twitter_api_contact('friendships/create', ['network' => Protocol::TWITTER, 'nick' => $nickname], $uid)) {
169 $user = twitter_fetchuser($nickname);
171 $contact_id = twitter_fetch_contact($uid, $user, true);
173 $contact = Contact::getById($contact_id, ['name', 'nick', 'url', 'addr', 'batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'photo', 'priority', 'network', 'alias', 'pubkey']);
175 if (DBA::isResult($contact)) {
176 $contact['contact'] = $contact;
180 function twitter_unfollow(array &$hook_data)
182 $hook_data['result'] = twitter_api_contact('friendships/destroy', $hook_data['contact'], $hook_data['uid']);
185 function twitter_block(array &$hook_data)
187 $hook_data['result'] = twitter_api_contact('blocks/create', $hook_data['contact'], $hook_data['uid']);
189 if ($hook_data['result'] === true) {
190 $cdata = Contact::getPublicAndUserContactID($hook_data['contact']['id'], $hook_data['uid']);
191 Contact::remove($cdata['user']);
195 function twitter_unblock(array &$hook_data)
197 $hook_data['result'] = twitter_api_contact('blocks/destroy', $hook_data['contact'], $hook_data['uid']);
200 function twitter_api_contact(string $apiPath, array $contact, int $uid): ?bool
202 if ($contact['network'] !== Protocol::TWITTER) {
206 return (bool)twitter_api_call($uid, $apiPath, ['screen_name' => $contact['nick']]);
209 function twitter_jot_nets(array &$jotnets_fields)
211 if (!DI::userSession()->getLocalUserId()) {
215 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post')) {
216 $jotnets_fields[] = [
217 'type' => 'checkbox',
220 DI::l10n()->t('Post to Twitter'),
221 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default')
228 function twitter_settings_post()
230 if (!DI::userSession()->getLocalUserId()) {
233 // don't check twitter settings if twitter submit button is not clicked
234 if (empty($_POST['twitter-disconnect']) && empty($_POST['twitter-submit'])) {
238 if (!empty($_POST['twitter-disconnect'])) {
240 * if the twitter-disconnect checkbox is set, clear the OAuth key/secret pair
241 * from the user configuration
243 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumerkey');
244 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumersecret');
245 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
246 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
247 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post');
248 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default');
249 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
250 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'thread');
251 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts');
252 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'import');
253 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'create_user');
254 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow');
255 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'own_id');
257 if (isset($_POST['twitter-pin'])) {
258 // if the user supplied us with a PIN from Twitter, let the magic of OAuth happen
259 Logger::notice('got a Twitter PIN');
260 $ckey = DI::config()->get('twitter', 'consumerkey');
261 $csecret = DI::config()->get('twitter', 'consumersecret');
262 // the token and secret for which the PIN was generated were hidden in the settings
263 // form as token and token2, we need a new connection to Twitter using these token
264 // and secret to request a Access Token with the PIN
266 if (empty($_POST['twitter-pin'])) {
267 throw new Exception(DI::l10n()->t('You submitted an empty PIN, please Sign In with Twitter again to get a new one.'));
270 $connection = new TwitterOAuth($ckey, $csecret, $_POST['twitter-token'], $_POST['twitter-token2']);
271 $token = $connection->oauth('oauth/access_token', ['oauth_verifier' => $_POST['twitter-pin']]);
272 // ok, now that we have the Access Token, save them in the user config
273 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken', $token['oauth_token']);
274 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret', $token['oauth_token_secret']);
275 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', 1);
276 } catch(Exception $e) {
277 DI::sysmsg()->addNotice($e->getMessage());
278 } catch(TwitterOAuthException $e) {
279 DI::sysmsg()->addNotice($e->getMessage());
282 // if no PIN is supplied in the POST variables, the user has changed the setting
283 // to post a tweet for every new __public__ posting to the wall
284 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', intval($_POST['twitter-enable']));
285 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default', intval($_POST['twitter-default']));
286 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'thread', intval($_POST['twitter-thread']));
287 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts', intval($_POST['twitter-mirror']));
288 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'import', intval($_POST['twitter-import']));
289 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'create_user', intval($_POST['twitter-create_user']));
290 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow', intval($_POST['twitter-auto_follow']));
292 if (!intval($_POST['twitter-mirror'])) {
293 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
299 function twitter_settings(array &$data)
301 if (!DI::userSession()->getLocalUserId()) {
305 $user = User::getById(DI::userSession()->getLocalUserId());
307 DI::page()->registerStylesheet(__DIR__ . '/twitter.css', 'all');
310 * 1) Check that we have global consumer key & secret
311 * 2) If no OAuthtoken & stuff is present, generate button to get some
312 * 3) Checkbox for "Send public notices (280 chars only)
314 $ckey = DI::config()->get('twitter', 'consumerkey');
315 $csecret = DI::config()->get('twitter', 'consumersecret');
316 $otoken = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
317 $osecret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
319 $enabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
320 $defenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'));
321 $threadenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'thread'));
322 $mirrorenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts'));
323 $importenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'import'));
324 $create_userenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'create_user'));
325 $auto_followenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow'));
327 // Hide the submit button by default
330 if ((!$ckey) && (!$csecret)) {
331 /* no global consumer keys
332 * display warning and skip personal config
334 $html = '<p>' . DI::l10n()->t('No consumer key pair for Twitter found. Please contact your site administrator.') . '</p>';
336 // ok we have a consumer key pair now look into the OAuth stuff
337 if ((!$otoken) && (!$osecret)) {
338 /* the user has not yet connected the account to twitter...
339 * get a temporary OAuth key/secret pair and display a button with
340 * which the user can request a PIN to connect the account to a
341 * account at Twitter.
343 $connection = new TwitterOAuth($ckey, $csecret);
345 $result = $connection->oauth('oauth/request_token', ['oauth_callback' => 'oob']);
347 $html = '<p>' . DI::l10n()->t('At this Friendica instance the Twitter addon was enabled but you have not yet connected your account to your Twitter account. To do so click the button below to get a PIN from Twitter which you have to copy into the input box below and submit the form. Only your <strong>public</strong> posts will be posted to Twitter.') . '</p>';
348 $html .= '<a href="' . $connection->url('oauth/authorize', ['oauth_token' => $result['oauth_token']]) . '" target="_twitter"><img src="addon/twitter/lighter.png" alt="' . DI::l10n()->t('Log in with Twitter') . '"></a>';
349 $html .= '<div id="twitter-pin-wrapper">';
350 $html .= '<label id="twitter-pin-label" for="twitter-pin">' . DI::l10n()->t('Copy the PIN from Twitter here') . '</label>';
351 $html .= '<input id="twitter-pin" type="text" name="twitter-pin" />';
352 $html .= '<input id="twitter-token" type="hidden" name="twitter-token" value="' . $result['oauth_token'] . '" />';
353 $html .= '<input id="twitter-token2" type="hidden" name="twitter-token2" value="' . $result['oauth_token_secret'] . '" />';
357 } catch (TwitterOAuthException $e) {
358 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
362 * we have an OAuth key / secret pair for the user
363 * so let's give a chance to disable the postings to Twitter
365 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
367 $account = $connection->get('account/verify_credentials');
368 if (property_exists($account, 'screen_name') &&
369 property_exists($account, 'description') &&
370 property_exists($account, 'profile_image_url')
372 $connected = DI::l10n()->t('Currently connected to: <a href="https://twitter.com/%1$s" target="_twitter">%1$s</a>', $account->screen_name);
374 Logger::notice('Invalid twitter info (verify credentials).', ['auth' => TwitterOAuth::class]);
377 if ($user['hidewall']) {
378 $privacy_warning = DI::l10n()->t('<strong>Note</strong>: Due to your privacy settings (<em>Hide your profile details from unknown viewers?</em>) the link potentially included in public postings relayed to Twitter will lead the visitor to a blank page informing the visitor that the access to your profile has been restricted.');
381 $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/twitter/');
382 $html = Renderer::replaceMacros($t, [
384 'connected' => $connected ?? '',
385 'invalid' => DI::l10n()->t('Invalid Twitter info'),
386 'disconnect' => DI::l10n()->t('Disconnect'),
387 'privacy_warning' => $privacy_warning ?? '',
390 '$account' => $account,
391 '$enable' => ['twitter-enable', DI::l10n()->t('Allow posting to Twitter'), $enabled, DI::l10n()->t('If enabled all your <strong>public</strong> postings can be posted to the associated Twitter account. You can choose to do so by default (here) or for every posting separately in the posting options when writing the entry.')],
392 '$default' => ['twitter-default', DI::l10n()->t('Send public postings to Twitter by default'), $defenabled],
393 '$thread' => ['twitter-thread', DI::l10n()->t('Use threads instead of truncating the content'), $threadenabled],
394 '$mirror' => ['twitter-mirror', DI::l10n()->t('Mirror all posts from twitter that are no replies'), $mirrorenabled],
395 '$import' => ['twitter-import', DI::l10n()->t('Import the remote timeline'), $importenabled],
396 '$create_user' => ['twitter-create_user', DI::l10n()->t('Automatically create contacts'), $create_userenabled, DI::l10n()->t('This will automatically create a contact in Friendica as soon as you receive a message from an existing contact via the Twitter network. If you do not enable this, you need to manually add those Twitter contacts in Friendica from whom you would like to see posts here.')],
397 '$auto_follow' => ['twitter-auto_follow', DI::l10n()->t('Follow in fediverse'), $auto_followenabled, DI::l10n()->t('Automatically subscribe to the contact in the fediverse, when a fediverse account is mentioned in name or description and we are following the Twitter contact.')],
400 // Enable the default submit button
402 } catch (TwitterOAuthException $e) {
403 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
409 'connector' => 'twitter',
410 'title' => DI::l10n()->t('Twitter Import/Export/Mirror'),
411 'enabled' => $enabled,
412 'image' => 'images/twitter.png',
414 'submit' => $submit ?? null,
418 function twitter_hook_fork(array &$b)
420 DI::logger()->debug('twitter_hook_fork', $b);
422 if ($b['name'] != 'notifier_normal') {
428 // Deletion checks are done in twitter_delete_item()
429 if ($post['deleted']) {
433 // Editing is not supported by the addon
434 if ($post['created'] !== $post['edited']) {
435 DI::logger()->info('Editing is not supported by the addon');
436 $b['execute'] = false;
440 // if post comes from twitter don't send it back
441 if (($post['extid'] == Protocol::TWITTER) || twitter_get_id($post['extid'])) {
442 DI::logger()->info('If post comes from twitter don\'t send it back');
443 $b['execute'] = false;
447 if (substr($post['app'] ?? '', 0, 7) == 'Twitter') {
448 DI::logger()->info('No Twitter app');
449 $b['execute'] = false;
453 if (DI::pConfig()->get($post['uid'], 'twitter', 'import')) {
454 // Don't fork if it isn't a reply to a twitter post
455 if (($post['parent'] != $post['id']) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::TWITTER])) {
456 Logger::notice('No twitter parent found', ['item' => $post['id']]);
457 $b['execute'] = false;
461 // Comments are never exported when we don't import the twitter timeline
462 if (!strstr($post['postopts'] ?? '', 'twitter') || ($post['parent'] != $post['id']) || $post['private']) {
463 DI::logger()->info('Comments are never exported when we don\'t import the twitter timeline');
464 $b['execute'] = false;
470 function twitter_post_local(array &$b)
476 if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
480 $twitter_post = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
481 $twitter_enable = (($twitter_post && !empty($_REQUEST['twitter_enable'])) ? intval($_REQUEST['twitter_enable']) : 0);
483 // if API is used, default to the chosen settings
484 if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'))) {
488 if (!$twitter_enable) {
492 if (strlen($b['postopts'])) {
493 $b['postopts'] .= ',';
496 $b['postopts'] .= 'twitter';
499 function twitter_probe_detect(array &$hookData)
501 // Don't overwrite an existing result
502 if (isset($hookData['result'])) {
506 // Avoid a lookup for the wrong network
507 if (!in_array($hookData['network'], ['', Protocol::TWITTER])) {
511 if (preg_match('=([^@]+)@(?:mobile\.)?twitter\.com$=i', $hookData['uri'], $matches)) {
513 } elseif (preg_match('=^https?://(?:mobile\.)?twitter\.com/(.+)=i', $hookData['uri'], $matches)) {
514 if (strpos($matches[1], '/') !== false) {
515 // Status case: https://twitter.com/<nick>/status/<status id>
517 $hookData['result'] = false;
526 $user = twitter_fetchuser($nick);
529 $hookData['result'] = twitter_user_to_contact($user);
533 function twitter_item_by_link(array &$hookData)
535 // Don't overwrite an existing result
536 if (isset($hookData['item_id'])) {
541 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $hookData['uri'], $matches)) {
545 // From now on, any early return should abort the whole chain since we've established it was a Twitter URL
546 $hookData['item_id'] = false;
548 // Node-level configuration check
549 if (empty(DI::config()->get('twitter', 'consumerkey')) || empty(DI::config()->get('twitter', 'consumersecret'))) {
553 // No anonymous import
554 if (!$hookData['uid']) {
559 empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthtoken'))
560 || empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthsecret'))
562 DI::sysmsg()->addNotice(DI::l10n()->t('Please connect a Twitter account in your Social Network settings to import Twitter posts.'));
566 $status = twitter_statuses_show($matches[1]);
568 if (empty($status->id_str)) {
569 DI::sysmsg()->addNotice(DI::l10n()->t('Twitter post not found.'));
573 $item = twitter_createpost($hookData['uid'], $status, [], true, false, false);
575 $hookData['item_id'] = Item::insert($item);
579 function twitter_api_post(string $apiPath, string $pid, int $uid): ?object
585 return twitter_api_call($uid, $apiPath, ['id' => $pid]);
588 function twitter_api_call(int $uid, string $apiPath, array $parameters = []): ?object
590 $ckey = DI::config()->get('twitter', 'consumerkey');
591 $csecret = DI::config()->get('twitter', 'consumersecret');
592 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
593 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
595 // If the addon is not configured (general or for this user) quit here
596 if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
601 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
602 $result = $connection->post($apiPath, $parameters);
604 if ($connection->getLastHttpCode() != 200) {
605 throw new Exception($result->errors[0]->message ?? json_encode($result), $connection->getLastHttpCode());
608 if (!empty($result->errors)) {
609 throw new Exception($result->errors[0]->message, $result->errors[0]->code);
612 Logger::info('[twitter] API call successful', ['apiPath' => $apiPath, 'parameters' => $parameters]);
613 Logger::debug('[twitter] API call result', ['apiPath' => $apiPath, 'parameters' => $parameters, 'result' => $result]);
616 } catch (TwitterOAuthException $twitterOAuthException) {
617 Logger::notice('Unable to communicate with twitter', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $twitterOAuthException->getCode(), 'exception' => $twitterOAuthException]);
619 } catch (Exception $e) {
620 Logger::notice('[twitter] API call failed', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $e->getCode(), 'message' => $e->getMessage()]);
625 function twitter_get_id(string $uri)
627 if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
631 $id = substr($uri, 9);
632 if (!is_numeric($id)) {
639 function twitter_post_hook(array &$b)
641 DI::logger()->debug('Invoke post hook', $b);
644 twitter_delete_item($b);
649 if (!DI::pConfig()->get($b['uid'], 'twitter', 'import')
650 && ($b['private'] || ($b['created'] !== $b['edited']))) {
654 $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
658 if ($b['parent'] != $b['id']) {
659 Logger::debug('Got comment', ['item' => $b]);
661 // Looking if its a reply to a twitter post
662 if (!twitter_get_id($b['parent-uri']) &&
663 !twitter_get_id($b['extid']) &&
664 !twitter_get_id($b['thr-parent'])) {
665 Logger::info('No twitter post', ['parent' => $b['parent']]);
669 $condition = ['uri' => $b['thr-parent'], 'uid' => $b['uid']];
670 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
671 if (!DBA::isResult($thr_parent)) {
672 Logger::notice('No parent found', ['thr-parent' => $b['thr-parent']]);
676 if ($thr_parent['author-network'] == Protocol::TWITTER) {
677 $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
678 $nicknameplain = '@' . $thr_parent['author-nick'];
680 Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b['body']]);
681 if ((strpos($b['body'], $nickname) === false) && (strpos($b['body'], $nicknameplain) === false)) {
682 $b['body'] = $nickname . ' ' . $b['body'];
686 Logger::debug('Parent found', ['parent' => $thr_parent]);
688 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
692 // Dont't post if the post doesn't belong to us.
693 // This is a check for forum postings
694 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
695 if ($b['contact-id'] != $self['id']) {
700 if ($b['verb'] == Activity::LIKE) {
701 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
703 twitter_api_post('favorites/create', twitter_get_id($b['thr-parent']), $b['uid']);
708 if ($b['verb'] == Activity::ANNOUNCE) {
709 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
710 twitter_retweet($b['uid'], twitter_get_id($b['thr-parent']));
714 if ($b['created'] !== $b['edited']) {
718 // if post comes from twitter don't send it back
719 if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
723 if ($b['app'] == 'Twitter') {
727 Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
729 DI::pConfig()->load($b['uid'], 'twitter');
731 $ckey = DI::config()->get('twitter', 'consumerkey');
732 $csecret = DI::config()->get('twitter', 'consumersecret');
733 $otoken = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
734 $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
736 if ($ckey && $csecret && $otoken && $osecret) {
737 Logger::info('We have customer key and oauth stuff, going to send.');
739 // If it's a repeated message from twitter then do a native retweet and exit
740 if (twitter_is_retweet($b['uid'], $b['body'])) {
744 Codebird::setConsumerKey($ckey, $csecret);
745 $cb = Codebird::getInstance();
746 $cb->setToken($otoken, $osecret);
748 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
750 // Set the timeout for upload to 30 seconds
751 $connection->setTimeouts(10, 30);
755 // Handling non-native reshares
756 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
758 function (array $attributes, array $author_contact, $content, $is_quote_share) {
759 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
763 $b['body'] = twitter_update_mentions($b['body']);
765 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
766 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
767 $msg = $msgarr['text'];
769 if (($msg == '') && isset($msgarr['title'])) {
770 $msg = Plaintext::shorten($msgarr['title'], $max_char - 50, $b['uid']);
773 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
774 if (!empty($msgarr['url']) && (strpos($msg, $msgarr['url']) === false) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
775 $msg .= "\n" . $msgarr['url'];
779 Logger::notice('Empty message', ['id' => $b['id']]);
783 // and now tweet it :-)
786 if (!empty($msgarr['images']) || !empty($msgarr['remote_images'])) {
787 Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images'] ?? [], 'remote_images' => $msgarr['remote_images'] ?? []]);
790 foreach ($msgarr['images'] ?? [] as $image) {
791 if (count($media_ids) == 4) {
795 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
796 } catch (\Throwable $th) {
797 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
801 foreach ($msgarr['remote_images'] ?? [] as $image) {
802 if (count($media_ids) == 4) {
806 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
807 } catch (\Throwable $th) {
808 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
811 $post['media_ids'] = implode(',', $media_ids);
812 if (empty($post['media_ids'])) {
813 unset($post['media_ids']);
815 } catch (Exception $e) {
816 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
820 if (!DI::pConfig()->get($b['uid'], 'twitter', 'thread') || empty($msgarr['parts']) || (count($msgarr['parts']) == 1)) {
821 Logger::debug('Post single message', ['id' => $b['id']]);
823 $post['status'] = $msg;
826 $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
829 $result = $connection->post('statuses/update', $post);
830 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
832 if (!empty($result->source)) {
833 DI::keyValue()->set('twitter_application_name', strip_tags($result->source));
836 if (!empty($result->errors)) {
837 Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
839 } elseif ($thr_parent) {
840 Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
841 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
845 $in_reply_to_status_id = twitter_get_id($thr_parent['uri']);
847 $in_reply_to_status_id = 0;
850 Logger::debug('Post message thread', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
851 foreach ($msgarr['parts'] as $key => $part) {
852 $post['status'] = $part;
854 if ($in_reply_to_status_id) {
855 $post['in_reply_to_status_id'] = $in_reply_to_status_id;
858 $result = $connection->post('statuses/update', $post);
859 Logger::debug('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $result]);
861 if (!empty($result->errors)) {
862 Logger::warning('Send to Twitter failed', ['part' => $key, 'id' => $b['id'], 'error' => $result->errors]);
865 } elseif ($key == 0) {
866 Logger::debug('Updating extid', ['part' => $key, 'id' => $b['id'], 'extid' => $result->id_str]);
867 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
870 if (!empty($result->source)) {
871 $application_name = strip_tags($result->source);
874 $in_reply_to_status_id = $result->id_str;
875 unset($post['media_ids']);
878 if (!empty($application_name)) {
879 DI::keyValue()->set('twitter_application_name', strip_tags($application_name));
885 function twitter_upload_image($connection, $cb, array $image, array $item)
887 if (!empty($image['id'])) {
888 $photo = Photo::selectFirst([], ['id' => $image['id']]);
890 $photo = Photo::createPhotoForExternalResource($image['url']);
893 $tempfile = tempnam(System::getTempPath(), 'cache');
894 file_put_contents($tempfile, Photo::getImageForPhoto($photo));
896 Logger::info('Uploading', ['id' => $item['id'], 'image' => $image]);
897 $media = $connection->upload('media/upload', ['media' => $tempfile]);
901 if (isset($media->media_id_string)) {
902 $media_id = $media->media_id_string;
904 if (!empty($image['description'])) {
905 $data = ['media_id' => $media->media_id_string,
906 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
907 $ret = $cb->media_metadata_create($data);
908 Logger::info('Metadata create', ['id' => $item['id'], 'data' => $data, 'return' => $ret]);
911 Logger::error('Failed upload', ['id' => $item['id'], 'image' => $image['url'], 'return' => $media]);
912 throw new Exception('Failed upload of ' . $image['url']);
918 function twitter_delete_item(array $item)
920 if (!$item['deleted']) {
924 if ($item['parent'] != $item['id']) {
925 Logger::debug('Deleting comment/announce', ['item' => $item]);
927 // Looking if it's a reply to a twitter post
928 if (!twitter_get_id($item['parent-uri']) &&
929 !twitter_get_id($item['extid']) &&
930 !twitter_get_id($item['thr-parent'])) {
931 Logger::info('No twitter post', ['parent' => $item['parent']]);
935 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
936 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
937 if (!DBA::isResult($thr_parent)) {
938 Logger::notice('No parent found', ['thr-parent' => $item['thr-parent']]);
942 Logger::debug('Parent found', ['parent' => $thr_parent]);
944 if (!strstr($item['extid'], 'twitter')) {
945 DI::logger()->info('Not a Twitter post', ['extid' => $item['extid']]);
949 // Don't delete if the post doesn't belong to us.
950 // This is a check for forum postings
951 $self = DBA::selectFirst('contact', ['id'], ['uid' => $item['uid'], 'self' => true]);
952 if ($item['contact-id'] != $self['id']) {
953 DI::logger()->info('Don\'t delete if the post doesn\'t belong to the user', ['contact-id' => $item['contact-id'], 'self' => $self['id']]);
959 * @TODO Remaining caveat: Comments posted on Twitter and imported in Friendica do not trigger any Notifier task,
960 * possibly because they are private to the user and don't require any remote deletion notifications sent.
961 * Comments posted on Friendica and mirrored on Twitter trigger the Notifier task and the Twitter counter-part
962 * will be deleted accordingly.
964 if ($item['verb'] == Activity::POST) {
965 Logger::info('Delete post/comment', ['uid' => $item['uid'], 'id' => twitter_get_id($item['extid'])]);
966 twitter_api_post('statuses/destroy', twitter_get_id($item['extid']), $item['uid']);
970 if ($item['verb'] == Activity::LIKE) {
971 Logger::info('Unlike', ['uid' => $item['uid'], 'id' => twitter_get_id($item['thr-parent'])]);
972 twitter_api_post('favorites/destroy', twitter_get_id($item['thr-parent']), $item['uid']);
976 if ($item['verb'] == Activity::ANNOUNCE && !empty($thr_parent['uri'])) {
977 Logger::info('Unretweet', ['uid' => $item['uid'], 'extid' => $thr_parent['uri'], 'id' => twitter_get_id($thr_parent['uri'])]);
978 twitter_api_post('statuses/unretweet', twitter_get_id($thr_parent['uri']), $item['uid']);
983 function twitter_addon_admin_post()
985 DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
986 DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
989 function twitter_addon_admin(string &$o)
991 $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
993 $o = Renderer::replaceMacros($t, [
994 '$submit' => DI::l10n()->t('Save Settings'),
995 // name, label, value, help, [extra values]
996 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
997 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
1001 function twitter_cron()
1003 $last = DI::keyValue()->get('twitter_last_poll');
1005 $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
1006 if (!$poll_interval) {
1007 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
1011 $next = $last + ($poll_interval * 60);
1012 if ($next > time()) {
1013 Logger::notice('twitter: poll intervall not reached');
1017 Logger::notice('twitter: cron_start');
1019 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
1020 foreach ($pconfigs as $rr) {
1021 Logger::notice('Fetching', ['user' => $rr['uid']]);
1022 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
1025 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
1026 if ($abandon_days < 1) {
1030 $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
1032 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1033 foreach ($pconfigs as $rr) {
1034 if ($abandon_days != 0) {
1035 if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
1036 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
1041 Logger::notice('importing timeline', ['user' => $rr['uid']]);
1042 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
1045 // check for new contacts once a day
1046 $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
1047 if($last_contact_check)
1048 $next_contact_check = $last_contact_check + 86400;
1050 $next_contact_check = 0;
1052 if($next_contact_check <= time()) {
1053 pumpio_getallusers($rr["uid"]);
1054 DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1059 Logger::notice('twitter: cron_end');
1061 DI::keyValue()->set('twitter_last_poll', time());
1064 function twitter_expire()
1066 $days = DI::config()->get('twitter', 'expire');
1072 Logger::notice('Start deleting expired posts');
1074 $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
1075 while ($row = Post::fetch($r)) {
1076 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1077 Item::markForDeletionById($row['id']);
1081 Logger::notice('End deleting expired posts');
1083 Logger::notice('Start expiry');
1085 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1086 foreach ($pconfigs as $rr) {
1087 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1088 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1091 Logger::notice('End expiry');
1094 function twitter_prepare_body(array &$b)
1096 if ($b['item']['network'] != Protocol::TWITTER) {
1100 if ($b['preview']) {
1103 $item['plink'] = DI::baseUrl() . '/display/' . $item['guid'];
1105 $condition = ['uri' => $item['thr-parent'], 'uid' => DI::userSession()->getLocalUserId()];
1106 $orig_post = Post::selectFirst(['author-link'], $condition);
1107 if (DBA::isResult($orig_post)) {
1108 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1109 $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1110 $nicknameplain = '@' . $nicknameplain;
1112 if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1113 $item['body'] = $nickname . ' ' . $item['body'];
1117 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1118 $msg = $msgarr['text'];
1120 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1121 $msg .= ' ' . $msgarr['url'];
1124 if (isset($msgarr['image'])) {
1125 $msg .= ' ' . $msgarr['image'];
1128 $b['html'] = nl2br(htmlspecialchars($msg));
1132 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1134 if ($twitterOAuth === null) {
1135 $ckey = DI::config()->get('twitter', 'consumerkey');
1136 $csecret = DI::config()->get('twitter', 'consumersecret');
1138 if (empty($ckey) || empty($csecret)) {
1139 return new stdClass();
1142 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1145 $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1147 return $twitterOAuth->get('statuses/show', $parameters);
1151 * Parse Twitter status URLs since Twitter removed OEmbed
1153 * @param array $b Expected format:
1155 * 'url' => [URL to parse],
1156 * 'format' => 'json'|'',
1157 * 'text' => Output parameter
1159 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1161 function twitter_parse_link(array &$b)
1163 // Only handle Twitter status URLs
1164 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1168 $status = twitter_statuses_show($matches[1]);
1170 if (empty($status->id)) {
1174 $item = twitter_createpost(0, $status, [], true, false, true);
1179 if ($b['format'] == 'json') {
1181 foreach ($status->extended_entities->media ?? [] as $media) {
1182 if (!empty($media->media_url_https)) {
1184 'src' => $media->media_url_https,
1185 'width' => $media->sizes->thumb->w,
1186 'height' => $media->sizes->thumb->h,
1194 'url' => $item['plink'],
1195 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1196 'text' => BBCode::toPlaintext($item['body'], false),
1197 'images' => $images,
1199 'contentType' => 'attachment',
1203 $b['text'] = BBCode::getShareOpeningTag(
1204 $item['author-name'],
1205 $item['author-link'],
1206 $item['author-avatar'],
1210 $b['text'] .= $item['body'] . '[/share]';
1215 /*********************
1219 *********************/
1223 * @brief Build the item array for the mirrored post
1225 * @param integer $uid User id
1226 * @param object $post Twitter object with the post
1228 * @return array item data to be posted
1230 function twitter_do_mirrorpost(int $uid, $post)
1232 $datarray['uid'] = $uid;
1233 $datarray['extid'] = 'twitter::' . $post->id;
1234 $datarray['title'] = '';
1236 if (!empty($post->retweeted_status)) {
1237 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1238 $item = twitter_createpost($uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1244 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1245 $item['author-name'],
1246 $item['author-link'],
1247 $item['author-avatar'],
1252 $datarray['body'] .= $item['body'] . '[/share]';
1254 $item = twitter_createpost($uid, $post, ['id' => 0], false, false, false, -1);
1260 $datarray['body'] = $item['body'];
1263 $datarray['app'] = $item['app'];
1264 $datarray['verb'] = $item['verb'];
1266 if (isset($item['location'])) {
1267 $datarray['location'] = $item['location'];
1270 if (isset($item['coord'])) {
1271 $datarray['coord'] = $item['coord'];
1278 * Fetches the Twitter user's own posts
1284 function twitter_fetchtimeline(int $uid): void
1286 $ckey = DI::config()->get('twitter', 'consumerkey');
1287 $csecret = DI::config()->get('twitter', 'consumersecret');
1288 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1289 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1290 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastid');
1292 $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
1294 if ($application_name == '') {
1295 $application_name = DI::baseUrl()->getHost();
1298 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1300 // Ensure to have the own contact
1302 twitter_fetch_own_contact($uid);
1303 } catch (TwitterOAuthException $e) {
1304 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1309 'exclude_replies' => true,
1310 'trim_user' => false,
1311 'contributor_details' => true,
1312 'include_rts' => true,
1313 'tweet_mode' => 'extended',
1314 'include_ext_alt_text' => true,
1317 $first_time = ($lastid == '');
1319 if ($lastid != '') {
1320 $parameters['since_id'] = $lastid;
1324 $items = $connection->get('statuses/user_timeline', $parameters);
1325 } catch (TwitterOAuthException $e) {
1326 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1330 if (!is_array($items)) {
1331 Logger::notice('No items', ['user' => $uid]);
1335 $posts = array_reverse($items);
1337 Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1339 if (count($posts)) {
1340 foreach ($posts as $post) {
1341 if ($post->id_str > $lastid) {
1342 $lastid = $post->id_str;
1343 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1347 Logger::notice('First time, continue');
1351 if (stristr($post->source, $application_name)) {
1352 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1355 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1357 $mirrorpost = twitter_do_mirrorpost($uid, $post);
1359 if (empty($mirrorpost['body'])) {
1360 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1364 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1366 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1369 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1370 Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1373 function twitter_fix_avatar($avatar)
1375 $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1377 $info = Images::getInfoFromURLCached($new_avatar);
1379 $new_avatar = $avatar;
1385 function twitter_get_relation($uid, $target, $contact = [])
1387 if (isset($contact['rel'])) {
1388 $relation = $contact['rel'];
1393 $ckey = DI::config()->get('twitter', 'consumerkey');
1394 $csecret = DI::config()->get('twitter', 'consumersecret');
1395 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1396 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1397 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1399 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1400 $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1403 $status = $connection->get('friendships/show', $parameters);
1404 if ($connection->getLastHttpCode() !== 200) {
1405 throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1408 $following = $status->relationship->source->following;
1409 $followed = $status->relationship->source->followed_by;
1411 if ($following && !$followed) {
1412 $relation = Contact::SHARING;
1413 } elseif (!$following && $followed) {
1414 $relation = Contact::FOLLOWER;
1415 } elseif ($following && $followed) {
1416 $relation = Contact::FRIEND;
1417 } elseif (!$following && !$followed) {
1421 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1422 } catch (Throwable $e) {
1423 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1433 function twitter_user_to_contact($data)
1435 if (empty($data->id_str)) {
1439 $baseurl = 'https://twitter.com';
1440 $url = $baseurl . '/' . $data->screen_name;
1441 $addr = $data->screen_name . '@twitter.com';
1445 'nurl' => Strings::normaliseLink($url),
1446 'uri-id' => ItemURI::getIdByURI($url),
1447 'network' => Protocol::TWITTER,
1448 'alias' => 'twitter::' . $data->id_str,
1449 'baseurl' => $baseurl,
1450 'name' => $data->name,
1451 'nick' => $data->screen_name,
1453 'location' => $data->location,
1454 'about' => $data->description,
1455 'photo' => twitter_fix_avatar($data->profile_image_url_https),
1456 'header' => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1462 function twitter_get_contact($data, int $uid = 0)
1464 $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1465 if (DBA::isResult($contact)) {
1466 return $contact['id'];
1468 return twitter_fetch_contact($uid, $data, false);
1472 function twitter_fetch_contact($uid, $data, $create_user)
1474 $fields = twitter_user_to_contact($data);
1476 if (empty($fields)) {
1480 // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1481 $avatar = $fields['photo'];
1482 unset($fields['photo']);
1484 // Update the public contact
1485 $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1486 if (DBA::isResult($pcontact)) {
1487 $cid = $pcontact['id'];
1489 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1493 Contact::update($fields, ['id' => $cid]);
1494 Contact::updateAvatar($cid, $avatar);
1496 Logger::notice('No contact found', ['fields' => $fields]);
1499 $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1500 if (!DBA::isResult($contact) && empty($cid)) {
1501 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1503 } elseif (!$create_user) {
1507 if (!DBA::isResult($contact)) {
1508 $relation = twitter_get_relation($uid, $data->screen_name);
1510 // create contact record
1511 $fields['uid'] = $uid;
1512 $fields['created'] = DateTimeFormat::utcNow();
1513 $fields['poll'] = 'twitter::' . $data->id_str;
1514 $fields['rel'] = $relation;
1515 $fields['priority'] = 1;
1516 $fields['writable'] = true;
1517 $fields['blocked'] = false;
1518 $fields['readonly'] = false;
1519 $fields['pending'] = false;
1521 if (!Contact::insert($fields)) {
1525 $contact_id = DBA::lastInsertId();
1527 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1529 if ($contact['readonly'] || $contact['blocked']) {
1530 Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1534 $contact_id = $contact['id'];
1537 // Update the contact relation once per day
1538 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1539 $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1543 if ($contact['name'] != $data->name) {
1544 $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1548 if ($contact['nick'] != $data->screen_name) {
1549 $fields['uri-date'] = DateTimeFormat::utcNow();
1553 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1558 $fields['updated'] = DateTimeFormat::utcNow();
1559 Contact::update($fields, ['id' => $contact['id']]);
1560 Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1564 Contact::updateAvatar($contact_id, $avatar);
1566 if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1567 twitter_auto_follow($uid, $data);
1574 * Follow a fediverse account that is proived in the name or the profile
1576 * @param integer $uid
1577 * @param object $data
1579 function twitter_auto_follow(int $uid, object $data)
1581 $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1583 // Search for user@domain.tld in the name
1584 if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1585 if (twitter_add_contact($match[1], true, $uid)) {
1590 // Search for @user@domain.tld in the description
1591 if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1592 if (twitter_add_contact($match[1], true, $uid)) {
1597 // Search for user@domain.tld in the description
1598 // We don't probe here, since this could be a mail address
1599 if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1600 if (twitter_add_contact($match[1], false, $uid)) {
1605 // Search for profile links in the description
1606 foreach ($data->entities->description->urls as $url) {
1607 if (!empty($url->expanded_url)) {
1608 // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1609 twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1615 * Check if the provided address is a fediverse account and adds it
1617 * @param string $addr
1618 * @param boolean $probe
1619 * @param integer $uid
1622 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1624 $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1625 if (empty($contact)) {
1626 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1630 if (!in_array($contact['network'], Protocol::FEDERATED)) {
1631 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1635 if (Contact::isSharing($contact['id'], $uid)) {
1636 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1640 Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1641 Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1647 * @param string $screen_name
1648 * @return stdClass|null
1651 function twitter_fetchuser($screen_name)
1653 $ckey = DI::config()->get('twitter', 'consumerkey');
1654 $csecret = DI::config()->get('twitter', 'consumersecret');
1657 // Fetching user data
1658 $connection = new TwitterOAuth($ckey, $csecret);
1659 $parameters = ['screen_name' => $screen_name];
1660 $user = $connection->get('users/show', $parameters);
1661 } catch (TwitterOAuthException $e) {
1662 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1666 if (!is_object($user)) {
1674 * Replaces Twitter entities with Friendica-friendly links.
1676 * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1678 * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1679 * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1680 * index to be correct even after the last replacement.
1682 * @param string $body
1683 * @param stdClass $status
1685 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1687 function twitter_expand_entities($body, stdClass $status)
1690 $contains_urls = false;
1694 $replacementList = [];
1696 foreach ($status->entities->hashtags AS $hashtag) {
1697 $replace = '#[url=' . DI::baseUrl() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1698 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1700 $replacementList[$hashtag->indices[0]] = [
1701 'replace' => $replace,
1702 'length' => $hashtag->indices[1] - $hashtag->indices[0],
1706 foreach ($status->entities->user_mentions AS $mention) {
1707 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1708 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1710 $replacementList[$mention->indices[0]] = [
1711 'replace' => $replace,
1712 'length' => $mention->indices[1] - $mention->indices[0],
1716 foreach ($status->entities->urls ?? [] as $url) {
1717 $plain = str_replace($url->url, '', $plain);
1719 if ($url->url && $url->expanded_url && $url->display_url) {
1720 // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1721 if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1722 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1724 $replacementList[$url->indices[0]] = [
1726 'length' => $url->indices[1] - $url->indices[0],
1731 $contains_urls = true;
1733 $expanded_url = $url->expanded_url;
1735 // Quickfix: Workaround for URL with '[' and ']' in it
1736 if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1737 $expanded_url = $url->url;
1740 $replacementList[$url->indices[0]] = [
1741 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1742 'length' => $url->indices[1] - $url->indices[0],
1747 krsort($replacementList);
1749 foreach ($replacementList as $startIndex => $parameters) {
1750 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1753 $body = trim($body);
1755 return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1759 * Store entity attachments
1761 * @param integer $uriId
1762 * @param object $post Twitter object with the post
1764 function twitter_store_attachments(int $uriId, $post)
1766 if (!empty($post->extended_entities->media)) {
1767 foreach ($post->extended_entities->media AS $medium) {
1768 switch ($medium->type) {
1770 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1772 $attachment['url'] = $medium->media_url_https . '?name=large';
1773 $attachment['width'] = $medium->sizes->large->w;
1774 $attachment['height'] = $medium->sizes->large->h;
1776 if ($medium->sizes->small->w != $attachment['width']) {
1777 $attachment['preview'] = $medium->media_url_https . '?name=small';
1778 $attachment['preview-width'] = $medium->sizes->small->w;
1779 $attachment['preview-height'] = $medium->sizes->small->h;
1782 $attachment['name'] = $medium->display_url ?? null;
1783 $attachment['description'] = $medium->ext_alt_text ?? null;
1784 Logger::debug('Photo attachment', ['attachment' => $attachment]);
1785 Post\Media::insert($attachment);
1788 case 'animated_gif':
1789 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1790 if (is_array($medium->video_info->variants)) {
1792 // We take the video with the highest bitrate
1793 foreach ($medium->video_info->variants AS $variant) {
1794 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1795 $attachment['url'] = $variant->url;
1796 $bitrate = $variant->bitrate;
1801 $attachment['name'] = $medium->display_url ?? null;
1802 $attachment['preview'] = $medium->media_url_https . ':small';
1803 $attachment['preview-width'] = $medium->sizes->small->w;
1804 $attachment['preview-height'] = $medium->sizes->small->h;
1805 $attachment['description'] = $medium->ext_alt_text ?? null;
1806 Logger::debug('Video attachment', ['attachment' => $attachment]);
1807 Post\Media::insert($attachment);
1810 Logger::notice('Unknown media type', ['medium' => $medium]);
1815 if (!empty($post->entities->urls)) {
1816 foreach ($post->entities->urls as $url) {
1817 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1818 Logger::debug('Attached link', ['attachment' => $attachment]);
1819 Post\Media::insert($attachment);
1825 * @brief Fetch media entities and add media links to the body
1827 * @param object $post Twitter object with the post
1828 * @param array $postarray Array of the item that is about to be posted
1829 * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1831 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1833 // There are no media entities? So we quit.
1834 if (empty($post->extended_entities->media)) {
1838 // This is a pure media post, first search for all media urls
1840 foreach ($post->extended_entities->media AS $medium) {
1841 if (!isset($media[$medium->url])) {
1842 $media[$medium->url] = '';
1844 switch ($medium->type) {
1846 if (!empty($medium->ext_alt_text)) {
1847 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1848 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1850 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1853 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1854 $postarray['post-type'] = Item::PT_IMAGE;
1857 // Currently deactivated, since this causes the video to be display before the content
1858 // We have to figure out a better way for declaring the post type and the display style.
1859 //$postarray['post-type'] = Item::PT_VIDEO;
1860 case 'animated_gif':
1861 if (!empty($medium->ext_alt_text)) {
1862 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1863 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1865 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1868 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1869 if (is_array($medium->video_info->variants)) {
1871 // We take the video with the highest bitrate
1872 foreach ($medium->video_info->variants AS $variant) {
1873 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1874 $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1875 $bitrate = $variant->bitrate;
1884 foreach ($media AS $key => $value) {
1885 $postarray['body'] = str_replace($key, '', $postarray['body']);
1890 // Now we replace the media urls.
1891 foreach ($media AS $key => $value) {
1892 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1897 * Undocumented function
1899 * @param integer $uid User ID
1900 * @param object $post Incoming Twitter post
1901 * @param array $self
1902 * @param bool $create_user Should users be created?
1903 * @param bool $only_existing_contact Only import existing contacts if set to "true"
1904 * @param bool $noquote
1905 * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1906 * @return array item array
1908 function twitter_createpost(int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1911 $postarray['network'] = Protocol::TWITTER;
1912 $postarray['uid'] = $uid;
1913 $postarray['wall'] = 0;
1914 $postarray['uri'] = 'twitter::' . $post->id_str;
1915 $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1916 $postarray['source'] = json_encode($post);
1917 $postarray['direction'] = Conversation::PULL;
1919 if (empty($uriId)) {
1920 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1923 // Don't import our own comments
1924 if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1925 Logger::info('Item found', ['extid' => $postarray['uri']]);
1931 if ($post->in_reply_to_status_id_str != '') {
1932 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1934 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1935 if (!DBA::isResult($item)) {
1936 $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1939 if (DBA::isResult($item)) {
1940 $postarray['thr-parent'] = $item['uri'];
1941 $postarray['object-type'] = Activity\ObjectType::COMMENT;
1943 $postarray['object-type'] = Activity\ObjectType::NOTE;
1947 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1949 if ($post->user->id_str == $own_id) {
1950 $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1951 if (DBA::isResult($self)) {
1952 $contactid = $self['id'];
1954 $postarray['owner-id'] = Contact::getIdForURL($self['url']);
1955 $postarray['owner-name'] = $self['name'];
1956 $postarray['owner-link'] = $self['url'];
1957 $postarray['owner-avatar'] = $self['photo'];
1959 Logger::error('No self contact found', ['uid' => $uid]);
1963 // Don't create accounts of people who just comment something
1964 $create_user = false;
1966 $postarray['object-type'] = Activity\ObjectType::NOTE;
1969 if ($contactid == 0) {
1970 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1972 $postarray['owner-id'] = twitter_get_contact($post->user);
1973 $postarray['owner-name'] = $post->user->name;
1974 $postarray['owner-link'] = 'https://twitter.com/' . $post->user->screen_name;
1975 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1978 if (($contactid == 0) && !$only_existing_contact) {
1979 $contactid = $self['id'];
1980 } elseif ($contactid <= 0) {
1981 Logger::info('Contact ID is zero or less than zero.');
1985 $postarray['contact-id'] = $contactid;
1986 $postarray['verb'] = Activity::POST;
1987 $postarray['author-id'] = $postarray['owner-id'];
1988 $postarray['author-name'] = $postarray['owner-name'];
1989 $postarray['author-link'] = $postarray['owner-link'];
1990 $postarray['author-avatar'] = $postarray['owner-avatar'];
1991 $postarray['plink'] = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1992 $postarray['app'] = strip_tags($post->source);
1994 if ($post->user->protected) {
1995 $postarray['private'] = Item::PRIVATE;
1996 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1998 $postarray['private'] = Item::UNLISTED;
1999 $postarray['allow_cid'] = '';
2002 if (!empty($post->full_text)) {
2003 $postarray['body'] = $post->full_text;
2005 $postarray['body'] = $post->text;
2008 // When the post contains links then use the correct object type
2009 if (count($post->entities->urls) > 0) {
2010 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2013 // Search for media links
2014 twitter_media_entities($post, $postarray, $uriId);
2016 $converted = twitter_expand_entities($postarray['body'], $post);
2018 // When the post contains external links then images or videos are just "decorations".
2019 if (!empty($converted['urls'])) {
2020 $postarray['post-type'] = Item::PT_NOTE;
2023 $postarray['body'] = $converted['body'];
2024 $postarray['created'] = DateTimeFormat::utc($post->created_at);
2025 $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2028 twitter_store_tags($uriId, $converted['taglist']);
2029 twitter_store_attachments($uriId, $post);
2032 if (!empty($post->place->name)) {
2033 $postarray['location'] = $post->place->name;
2035 if (!empty($post->place->full_name)) {
2036 $postarray['location'] = $post->place->full_name;
2038 if (!empty($post->geo->coordinates)) {
2039 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2041 if (!empty($post->coordinates->coordinates)) {
2042 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2044 if (!empty($post->retweeted_status)) {
2045 $retweet = twitter_createpost($uid, $post->retweeted_status, $self, false, false, $noquote);
2047 if (empty($retweet)) {
2052 // Store the original tweet
2053 Item::insert($retweet);
2055 // CHange the other post into a reshare activity
2056 $postarray['verb'] = Activity::ANNOUNCE;
2057 $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2058 $postarray['object-type'] = Activity\ObjectType::NOTE;
2060 $postarray['thr-parent'] = $retweet['uri'];
2062 $retweet['source'] = $postarray['source'];
2063 $retweet['direction'] = $postarray['direction'];
2064 $retweet['private'] = $postarray['private'];
2065 $retweet['allow_cid'] = $postarray['allow_cid'];
2066 $retweet['contact-id'] = $postarray['contact-id'];
2067 $retweet['owner-id'] = $postarray['owner-id'];
2068 $retweet['owner-name'] = $postarray['owner-name'];
2069 $retweet['owner-link'] = $postarray['owner-link'];
2070 $retweet['owner-avatar'] = $postarray['owner-avatar'];
2072 $postarray = $retweet;
2076 if (!empty($post->quoted_status)) {
2078 // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2079 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2081 $quoted = twitter_createpost(0, $post->quoted_status, $self, false, false, true);
2082 if (!empty($quoted)) {
2083 Item::insert($quoted);
2084 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2085 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2087 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2088 $quoted['author-name'],
2089 $quoted['author-link'],
2090 $quoted['author-avatar'],
2096 $postarray['body'] .= $quoted['body'] . '[/share]';
2098 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2099 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2108 * Store tags and mentions
2110 * @param integer $uriId
2111 * @param array $taglist
2114 function twitter_store_tags(int $uriId, array $taglist)
2116 foreach ($taglist as $tag) {
2117 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2121 function twitter_fetchparentposts(int $uid, $post, TwitterOAuth $connection, array $self)
2123 Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2127 while (!empty($post->in_reply_to_status_id_str)) {
2129 $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2130 } catch (TwitterOAuthException $e) {
2131 Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2136 Logger::info("twitter_fetchparentposts: Can't fetch post");
2140 if (empty($post->id_str)) {
2141 Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2145 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2152 Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2154 $posts = array_reverse($posts);
2156 if (!empty($posts)) {
2157 foreach ($posts as $post) {
2158 $postarray = twitter_createpost($uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2160 if (empty($postarray)) {
2164 $item = Item::insert($postarray);
2166 $postarray['id'] = $item;
2168 Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2174 * Fetches the posts received by the Twitter user
2180 function twitter_fetchhometimeline(int $uid): void
2182 $ckey = DI::config()->get('twitter', 'consumerkey');
2183 $csecret = DI::config()->get('twitter', 'consumersecret');
2184 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2185 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2186 $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2187 $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2189 Logger::info('Fetching timeline', ['uid' => $uid]);
2191 $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
2193 if ($application_name == '') {
2194 $application_name = DI::baseUrl()->getHost();
2197 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2200 $own_contact = twitter_fetch_own_contact($uid);
2201 } catch (TwitterOAuthException $e) {
2202 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2206 $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2207 if (DBA::isResult($contact)) {
2208 $own_id = $contact['nick'];
2210 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2214 $self = User::getOwnerDataById($uid);
2215 if ($self === false) {
2216 Logger::warning('Own contact not found', ['uid' => $uid]);
2221 'exclude_replies' => false,
2222 'trim_user' => false,
2223 'contributor_details' => true,
2224 'include_rts' => true,
2225 'tweet_mode' => 'extended',
2226 'include_ext_alt_text' => true,
2230 // Fetching timeline
2231 $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2233 $first_time = ($lastid == '');
2235 if ($lastid != '') {
2236 $parameters['since_id'] = $lastid;
2240 $items = $connection->get('statuses/home_timeline', $parameters);
2241 } catch (TwitterOAuthException $e) {
2242 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2246 if (!is_array($items)) {
2247 Logger::notice('home timeline is no array', ['items' => $items]);
2251 if (empty($items)) {
2252 Logger::info('No new timeline content', ['uid' => $uid]);
2256 $posts = array_reverse($items);
2258 Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2260 if (count($posts)) {
2261 foreach ($posts as $post) {
2262 if ($post->id_str > $lastid) {
2263 $lastid = $post->id_str;
2264 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2271 if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2272 Logger::info('Skip previously sent post');
2276 if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2277 Logger::info('Skip post that will be mirrored');
2281 if ($post->in_reply_to_status_id_str != '') {
2282 twitter_fetchparentposts($uid, $post, $connection, $self);
2285 Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2287 $postarray = twitter_createpost($uid, $post, $self, $create_user, true, false);
2289 if (empty($postarray)) {
2290 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2296 if (empty($postarray['thr-parent'])) {
2297 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2298 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2299 $notify = Worker::PRIORITY_MEDIUM;
2303 $postarray['wall'] = (bool)$notify;
2305 $item = Item::insert($postarray, $notify);
2306 $postarray['id'] = $item;
2308 Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2311 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2313 Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2315 // Fetching mentions
2316 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2318 $first_time = ($lastid == '');
2320 if ($lastid != '') {
2321 $parameters['since_id'] = $lastid;
2325 $items = $connection->get('statuses/mentions_timeline', $parameters);
2326 } catch (TwitterOAuthException $e) {
2327 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2331 if (!is_array($items)) {
2332 Logger::notice('mentions are no arrays', ['items' => $items]);
2336 $posts = array_reverse($items);
2338 Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2340 if (count($posts)) {
2341 foreach ($posts as $post) {
2342 if ($post->id_str > $lastid) {
2343 $lastid = $post->id_str;
2350 if ($post->in_reply_to_status_id_str != '') {
2351 twitter_fetchparentposts($uid, $post, $connection, $self);
2354 $postarray = twitter_createpost($uid, $post, $self, false, !$create_user, false);
2356 if (empty($postarray)) {
2360 $item = Item::insert($postarray);
2362 Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2366 DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2368 Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2371 function twitter_fetch_own_contact(int $uid)
2373 $ckey = DI::config()->get('twitter', 'consumerkey');
2374 $csecret = DI::config()->get('twitter', 'consumersecret');
2375 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2376 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2378 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2382 if ($own_id == '') {
2383 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2385 // Fetching user data
2386 // get() may throw TwitterOAuthException, but we will catch it later
2387 $user = $connection->get('account/verify_credentials');
2388 if (empty($user->id_str)) {
2392 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2394 $contact_id = twitter_fetch_contact($uid, $user, true);
2396 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2397 if (DBA::isResult($contact)) {
2398 $contact_id = $contact['id'];
2400 DI::pConfig()->delete($uid, 'twitter', 'own_id');
2407 function twitter_is_retweet(int $uid, string $body): bool
2409 $body = trim($body);
2411 // Skip if it isn't a pure repeated messages
2412 // Does it start with a share?
2413 if (strpos($body, '[share') > 0) {
2417 // Does it end with a share?
2418 if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2422 $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2423 // Skip if there is no shared message in there
2424 if ($body == $attributes) {
2429 preg_match("/link='(.*?)'/ism", $attributes, $matches);
2430 if (!empty($matches[1])) {
2431 $link = $matches[1];
2434 preg_match('/link="(.*?)"/ism', $attributes, $matches);
2435 if (!empty($matches[1])) {
2436 $link = $matches[1];
2439 $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2443 return twitter_retweet($uid, $id);
2446 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2448 Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2450 $result = twitter_api_post('statuses/retweet', $id, $uid);
2452 Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2454 if (!empty($item_id) && !empty($result->id_str)) {
2455 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2456 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2459 return !isset($result->errors);
2462 function twitter_update_mentions(string $body): string
2464 $URLSearchString = '^\[\]';
2465 $return = preg_replace_callback(
2466 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2467 function ($matches) {
2468 if (strpos($matches[1], 'twitter.com')) {
2469 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2471 $return = $matches[2] . ' (' . $matches[1] . ')';
2482 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2484 if (empty($author_contact)) {
2485 return $content . "\n\n" . $attributes['link'];
2488 if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2489 $mention = '@' . $author_contact['nick'];
2491 $mention = $author_contact['addr'];
2494 return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];