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) ?: null;
532 // Authoritative probe should set the result even if the probe was unsuccessful
533 if ($hookData['network'] == Protocol::TWITTER && empty($hookData['result'])) {
534 $hookData['result'] = [];
538 function twitter_item_by_link(array &$hookData)
540 // Don't overwrite an existing result
541 if (isset($hookData['item_id'])) {
546 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $hookData['uri'], $matches)) {
550 // From now on, any early return should abort the whole chain since we've established it was a Twitter URL
551 $hookData['item_id'] = false;
553 // Node-level configuration check
554 if (empty(DI::config()->get('twitter', 'consumerkey')) || empty(DI::config()->get('twitter', 'consumersecret'))) {
558 // No anonymous import
559 if (!$hookData['uid']) {
564 empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthtoken'))
565 || empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthsecret'))
567 DI::sysmsg()->addNotice(DI::l10n()->t('Please connect a Twitter account in your Social Network settings to import Twitter posts.'));
571 $status = twitter_statuses_show($matches[1]);
573 if (empty($status->id_str)) {
574 DI::sysmsg()->addNotice(DI::l10n()->t('Twitter post not found.'));
578 $item = twitter_createpost($hookData['uid'], $status, [], true, false, false);
580 $hookData['item_id'] = Item::insert($item);
584 function twitter_api_post(string $apiPath, string $pid, int $uid): ?object
590 return twitter_api_call($uid, $apiPath, ['id' => $pid]);
593 function twitter_api_call(int $uid, string $apiPath, array $parameters = []): ?object
595 $ckey = DI::config()->get('twitter', 'consumerkey');
596 $csecret = DI::config()->get('twitter', 'consumersecret');
597 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
598 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
600 // If the addon is not configured (general or for this user) quit here
601 if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
606 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
607 $result = $connection->post($apiPath, $parameters);
609 if ($connection->getLastHttpCode() != 200) {
610 throw new Exception($result->errors[0]->message ?? json_encode($result), $connection->getLastHttpCode());
613 if (!empty($result->errors)) {
614 throw new Exception($result->errors[0]->message, $result->errors[0]->code);
617 Logger::info('[twitter] API call successful', ['apiPath' => $apiPath, 'parameters' => $parameters]);
618 Logger::debug('[twitter] API call result', ['apiPath' => $apiPath, 'parameters' => $parameters, 'result' => $result]);
621 } catch (TwitterOAuthException $twitterOAuthException) {
622 Logger::notice('Unable to communicate with twitter', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $twitterOAuthException->getCode(), 'exception' => $twitterOAuthException]);
624 } catch (Exception $e) {
625 Logger::notice('[twitter] API call failed', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $e->getCode(), 'message' => $e->getMessage()]);
630 function twitter_get_id(string $uri)
632 if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
636 $id = substr($uri, 9);
637 if (!is_numeric($id)) {
644 function twitter_post_hook(array &$b)
646 DI::logger()->debug('Invoke post hook', $b);
649 twitter_delete_item($b);
654 if (!DI::pConfig()->get($b['uid'], 'twitter', 'import')
655 && ($b['private'] || ($b['created'] !== $b['edited']))) {
659 $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
663 if ($b['parent'] != $b['id']) {
664 Logger::debug('Got comment', ['item' => $b]);
666 // Looking if its a reply to a twitter post
667 if (!twitter_get_id($b['parent-uri']) &&
668 !twitter_get_id($b['extid']) &&
669 !twitter_get_id($b['thr-parent'])) {
670 Logger::info('No twitter post', ['parent' => $b['parent']]);
674 $condition = ['uri' => $b['thr-parent'], 'uid' => $b['uid']];
675 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
676 if (!DBA::isResult($thr_parent)) {
677 Logger::notice('No parent found', ['thr-parent' => $b['thr-parent']]);
681 if ($thr_parent['author-network'] == Protocol::TWITTER) {
682 $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
683 $nicknameplain = '@' . $thr_parent['author-nick'];
685 Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b['body']]);
686 if ((strpos($b['body'], $nickname) === false) && (strpos($b['body'], $nicknameplain) === false)) {
687 $b['body'] = $nickname . ' ' . $b['body'];
691 Logger::debug('Parent found', ['parent' => $thr_parent]);
693 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
697 // Dont't post if the post doesn't belong to us.
698 // This is a check for forum postings
699 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
700 if ($b['contact-id'] != $self['id']) {
705 if ($b['verb'] == Activity::LIKE) {
706 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
708 twitter_api_post('favorites/create', twitter_get_id($b['thr-parent']), $b['uid']);
713 if ($b['verb'] == Activity::ANNOUNCE) {
714 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
715 twitter_retweet($b['uid'], twitter_get_id($b['thr-parent']));
719 if ($b['created'] !== $b['edited']) {
723 // if post comes from twitter don't send it back
724 if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
728 if ($b['app'] == 'Twitter') {
732 Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
734 DI::pConfig()->load($b['uid'], 'twitter');
736 $ckey = DI::config()->get('twitter', 'consumerkey');
737 $csecret = DI::config()->get('twitter', 'consumersecret');
738 $otoken = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
739 $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
741 if ($ckey && $csecret && $otoken && $osecret) {
742 Logger::info('We have customer key and oauth stuff, going to send.');
744 // If it's a repeated message from twitter then do a native retweet and exit
745 if (twitter_is_retweet($b['uid'], $b['body'])) {
749 Codebird::setConsumerKey($ckey, $csecret);
750 $cb = Codebird::getInstance();
751 $cb->setToken($otoken, $osecret);
753 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
755 // Set the timeout for upload to 30 seconds
756 $connection->setTimeouts(10, 30);
760 // Handling non-native reshares
761 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
763 function (array $attributes, array $author_contact, $content, $is_quote_share) {
764 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
768 $b['body'] = twitter_update_mentions($b['body']);
770 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
771 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
772 $msg = $msgarr['text'];
774 if (($msg == '') && isset($msgarr['title'])) {
775 $msg = Plaintext::shorten($msgarr['title'], $max_char - 50, $b['uid']);
778 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
779 if (!empty($msgarr['url']) && (strpos($msg, $msgarr['url']) === false) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
780 $msg .= "\n" . $msgarr['url'];
784 Logger::notice('Empty message', ['id' => $b['id']]);
788 // and now tweet it :-)
791 if (!empty($msgarr['images']) || !empty($msgarr['remote_images'])) {
792 Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images'] ?? [], 'remote_images' => $msgarr['remote_images'] ?? []]);
795 foreach ($msgarr['images'] ?? [] as $image) {
796 if (count($media_ids) == 4) {
800 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
801 } catch (\Throwable $th) {
802 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
806 foreach ($msgarr['remote_images'] ?? [] as $image) {
807 if (count($media_ids) == 4) {
811 $media_ids[] = twitter_upload_image($connection, $cb, $image, $b);
812 } catch (\Throwable $th) {
813 Logger::warning('Error while uploading image', ['code' => $th->getCode(), 'message' => $th->getMessage()]);
816 $post['media_ids'] = implode(',', $media_ids);
817 if (empty($post['media_ids'])) {
818 unset($post['media_ids']);
820 } catch (Exception $e) {
821 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
825 if (!DI::pConfig()->get($b['uid'], 'twitter', 'thread') || empty($msgarr['parts']) || (count($msgarr['parts']) == 1)) {
826 Logger::debug('Post single message', ['id' => $b['id']]);
828 $post['status'] = $msg;
831 $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
834 $result = $connection->post('statuses/update', $post);
835 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
837 if (!empty($result->source)) {
838 DI::keyValue()->set('twitter_application_name', strip_tags($result->source));
841 if (!empty($result->errors)) {
842 Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
844 } elseif ($thr_parent) {
845 Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
846 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
850 $in_reply_to_status_id = twitter_get_id($thr_parent['uri']);
852 $in_reply_to_status_id = 0;
855 Logger::debug('Post message thread', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
856 foreach ($msgarr['parts'] as $key => $part) {
857 $post['status'] = $part;
859 if ($in_reply_to_status_id) {
860 $post['in_reply_to_status_id'] = $in_reply_to_status_id;
863 $result = $connection->post('statuses/update', $post);
864 Logger::debug('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $result]);
866 if (!empty($result->errors)) {
867 Logger::warning('Send to Twitter failed', ['part' => $key, 'id' => $b['id'], 'error' => $result->errors]);
870 } elseif ($key == 0) {
871 Logger::debug('Updating extid', ['part' => $key, 'id' => $b['id'], 'extid' => $result->id_str]);
872 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
875 if (!empty($result->source)) {
876 $application_name = strip_tags($result->source);
879 $in_reply_to_status_id = $result->id_str;
880 unset($post['media_ids']);
883 if (!empty($application_name)) {
884 DI::keyValue()->set('twitter_application_name', strip_tags($application_name));
890 function twitter_upload_image($connection, $cb, array $image, array $item)
892 if (!empty($image['id'])) {
893 $photo = Photo::selectFirst([], ['id' => $image['id']]);
895 $photo = Photo::createPhotoForExternalResource($image['url']);
898 $tempfile = tempnam(System::getTempPath(), 'cache');
899 file_put_contents($tempfile, Photo::getImageForPhoto($photo));
901 Logger::info('Uploading', ['id' => $item['id'], 'image' => $image]);
902 $media = $connection->upload('media/upload', ['media' => $tempfile]);
906 if (isset($media->media_id_string)) {
907 $media_id = $media->media_id_string;
909 if (!empty($image['description'])) {
910 $data = ['media_id' => $media->media_id_string,
911 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
912 $ret = $cb->media_metadata_create($data);
913 Logger::info('Metadata create', ['id' => $item['id'], 'data' => $data, 'return' => $ret]);
916 Logger::error('Failed upload', ['id' => $item['id'], 'image' => $image['url'], 'return' => $media]);
917 throw new Exception('Failed upload of ' . $image['url']);
923 function twitter_delete_item(array $item)
925 if (!$item['deleted']) {
929 if ($item['parent'] != $item['id']) {
930 Logger::debug('Deleting comment/announce', ['item' => $item]);
932 // Looking if it's a reply to a twitter post
933 if (!twitter_get_id($item['parent-uri']) &&
934 !twitter_get_id($item['extid']) &&
935 !twitter_get_id($item['thr-parent'])) {
936 Logger::info('No twitter post', ['parent' => $item['parent']]);
940 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
941 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
942 if (!DBA::isResult($thr_parent)) {
943 Logger::notice('No parent found', ['thr-parent' => $item['thr-parent']]);
947 Logger::debug('Parent found', ['parent' => $thr_parent]);
949 if (!strstr($item['extid'], 'twitter')) {
950 DI::logger()->info('Not a Twitter post', ['extid' => $item['extid']]);
954 // Don't delete if the post doesn't belong to us.
955 // This is a check for forum postings
956 $self = DBA::selectFirst('contact', ['id'], ['uid' => $item['uid'], 'self' => true]);
957 if ($item['contact-id'] != $self['id']) {
958 DI::logger()->info('Don\'t delete if the post doesn\'t belong to the user', ['contact-id' => $item['contact-id'], 'self' => $self['id']]);
964 * @TODO Remaining caveat: Comments posted on Twitter and imported in Friendica do not trigger any Notifier task,
965 * possibly because they are private to the user and don't require any remote deletion notifications sent.
966 * Comments posted on Friendica and mirrored on Twitter trigger the Notifier task and the Twitter counter-part
967 * will be deleted accordingly.
969 if ($item['verb'] == Activity::POST) {
970 Logger::info('Delete post/comment', ['uid' => $item['uid'], 'id' => twitter_get_id($item['extid'])]);
971 twitter_api_post('statuses/destroy', twitter_get_id($item['extid']), $item['uid']);
975 if ($item['verb'] == Activity::LIKE) {
976 Logger::info('Unlike', ['uid' => $item['uid'], 'id' => twitter_get_id($item['thr-parent'])]);
977 twitter_api_post('favorites/destroy', twitter_get_id($item['thr-parent']), $item['uid']);
981 if ($item['verb'] == Activity::ANNOUNCE && !empty($thr_parent['uri'])) {
982 Logger::info('Unretweet', ['uid' => $item['uid'], 'extid' => $thr_parent['uri'], 'id' => twitter_get_id($thr_parent['uri'])]);
983 twitter_api_post('statuses/unretweet', twitter_get_id($thr_parent['uri']), $item['uid']);
988 function twitter_addon_admin_post()
990 DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
991 DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
994 function twitter_addon_admin(string &$o)
996 $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
998 $o = Renderer::replaceMacros($t, [
999 '$submit' => DI::l10n()->t('Save Settings'),
1000 // name, label, value, help, [extra values]
1001 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
1002 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
1006 function twitter_cron()
1008 $last = DI::keyValue()->get('twitter_last_poll');
1010 $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
1011 if (!$poll_interval) {
1012 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
1016 $next = $last + ($poll_interval * 60);
1017 if ($next > time()) {
1018 Logger::notice('twitter: poll intervall not reached');
1022 Logger::notice('twitter: cron_start');
1024 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
1025 foreach ($pconfigs as $rr) {
1026 Logger::notice('Fetching', ['user' => $rr['uid']]);
1027 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
1030 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
1031 if ($abandon_days < 1) {
1035 $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
1037 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1038 foreach ($pconfigs as $rr) {
1039 if ($abandon_days != 0) {
1040 if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
1041 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
1046 Logger::notice('importing timeline', ['user' => $rr['uid']]);
1047 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
1050 // check for new contacts once a day
1051 $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
1052 if($last_contact_check)
1053 $next_contact_check = $last_contact_check + 86400;
1055 $next_contact_check = 0;
1057 if($next_contact_check <= time()) {
1058 pumpio_getallusers($rr["uid"]);
1059 DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1064 Logger::notice('twitter: cron_end');
1066 DI::keyValue()->set('twitter_last_poll', time());
1069 function twitter_expire()
1071 $days = DI::config()->get('twitter', 'expire');
1077 Logger::notice('Start deleting expired posts');
1079 $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
1080 while ($row = Post::fetch($r)) {
1081 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1082 Item::markForDeletionById($row['id']);
1086 Logger::notice('End deleting expired posts');
1088 Logger::notice('Start expiry');
1090 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1091 foreach ($pconfigs as $rr) {
1092 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1093 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1096 Logger::notice('End expiry');
1099 function twitter_prepare_body(array &$b)
1101 if ($b['item']['network'] != Protocol::TWITTER) {
1105 if ($b['preview']) {
1108 $item['plink'] = DI::baseUrl() . '/display/' . $item['guid'];
1110 $condition = ['uri' => $item['thr-parent'], 'uid' => DI::userSession()->getLocalUserId()];
1111 $orig_post = Post::selectFirst(['author-link'], $condition);
1112 if (DBA::isResult($orig_post)) {
1113 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1114 $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1115 $nicknameplain = '@' . $nicknameplain;
1117 if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1118 $item['body'] = $nickname . ' ' . $item['body'];
1122 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1123 $msg = $msgarr['text'];
1125 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1126 $msg .= ' ' . $msgarr['url'];
1129 if (isset($msgarr['image'])) {
1130 $msg .= ' ' . $msgarr['image'];
1133 $b['html'] = nl2br(htmlspecialchars($msg));
1137 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1139 if ($twitterOAuth === null) {
1140 $ckey = DI::config()->get('twitter', 'consumerkey');
1141 $csecret = DI::config()->get('twitter', 'consumersecret');
1143 if (empty($ckey) || empty($csecret)) {
1144 return new stdClass();
1147 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1150 $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1152 return $twitterOAuth->get('statuses/show', $parameters);
1156 * Parse Twitter status URLs since Twitter removed OEmbed
1158 * @param array $b Expected format:
1160 * 'url' => [URL to parse],
1161 * 'format' => 'json'|'',
1162 * 'text' => Output parameter
1164 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1166 function twitter_parse_link(array &$b)
1168 // Only handle Twitter status URLs
1169 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1173 $status = twitter_statuses_show($matches[1]);
1175 if (empty($status->id)) {
1179 $item = twitter_createpost(0, $status, [], true, false, true);
1184 if ($b['format'] == 'json') {
1186 foreach ($status->extended_entities->media ?? [] as $media) {
1187 if (!empty($media->media_url_https)) {
1189 'src' => $media->media_url_https,
1190 'width' => $media->sizes->thumb->w,
1191 'height' => $media->sizes->thumb->h,
1199 'url' => $item['plink'],
1200 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1201 'text' => BBCode::toPlaintext($item['body'], false),
1202 'images' => $images,
1204 'contentType' => 'attachment',
1208 $b['text'] = BBCode::getShareOpeningTag(
1209 $item['author-name'],
1210 $item['author-link'],
1211 $item['author-avatar'],
1215 $b['text'] .= $item['body'] . '[/share]';
1220 /*********************
1224 *********************/
1228 * @brief Build the item array for the mirrored post
1230 * @param integer $uid User id
1231 * @param object $post Twitter object with the post
1233 * @return array item data to be posted
1235 function twitter_do_mirrorpost(int $uid, $post)
1237 $datarray['uid'] = $uid;
1238 $datarray['extid'] = 'twitter::' . $post->id;
1239 $datarray['title'] = '';
1241 if (!empty($post->retweeted_status)) {
1242 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1243 $item = twitter_createpost($uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1249 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1250 $item['author-name'],
1251 $item['author-link'],
1252 $item['author-avatar'],
1257 $datarray['body'] .= $item['body'] . '[/share]';
1259 $item = twitter_createpost($uid, $post, ['id' => 0], false, false, false, -1);
1265 $datarray['body'] = $item['body'];
1268 $datarray['app'] = $item['app'];
1269 $datarray['verb'] = $item['verb'];
1271 if (isset($item['location'])) {
1272 $datarray['location'] = $item['location'];
1275 if (isset($item['coord'])) {
1276 $datarray['coord'] = $item['coord'];
1283 * Fetches the Twitter user's own posts
1289 function twitter_fetchtimeline(int $uid): void
1291 $ckey = DI::config()->get('twitter', 'consumerkey');
1292 $csecret = DI::config()->get('twitter', 'consumersecret');
1293 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1294 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1295 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastid');
1297 $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
1299 if ($application_name == '') {
1300 $application_name = DI::baseUrl()->getHost();
1303 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1305 // Ensure to have the own contact
1307 twitter_fetch_own_contact($uid);
1308 } catch (TwitterOAuthException $e) {
1309 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1314 'exclude_replies' => true,
1315 'trim_user' => false,
1316 'contributor_details' => true,
1317 'include_rts' => true,
1318 'tweet_mode' => 'extended',
1319 'include_ext_alt_text' => true,
1322 $first_time = ($lastid == '');
1324 if ($lastid != '') {
1325 $parameters['since_id'] = $lastid;
1329 $items = $connection->get('statuses/user_timeline', $parameters);
1330 } catch (TwitterOAuthException $e) {
1331 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1335 if (!is_array($items)) {
1336 Logger::notice('No items', ['user' => $uid]);
1340 $posts = array_reverse($items);
1342 Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1344 if (count($posts)) {
1345 foreach ($posts as $post) {
1346 if ($post->id_str > $lastid) {
1347 $lastid = $post->id_str;
1348 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1352 Logger::notice('First time, continue');
1356 if (stristr($post->source, $application_name)) {
1357 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1360 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1362 $mirrorpost = twitter_do_mirrorpost($uid, $post);
1364 if (empty($mirrorpost['body'])) {
1365 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1369 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1371 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::PREPARED);
1374 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1375 Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1378 function twitter_fix_avatar($avatar)
1380 $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1382 $info = Images::getInfoFromURLCached($new_avatar);
1384 $new_avatar = $avatar;
1390 function twitter_get_relation($uid, $target, $contact = [])
1392 if (isset($contact['rel'])) {
1393 $relation = $contact['rel'];
1398 $ckey = DI::config()->get('twitter', 'consumerkey');
1399 $csecret = DI::config()->get('twitter', 'consumersecret');
1400 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1401 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1402 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1404 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1405 $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1408 $status = $connection->get('friendships/show', $parameters);
1409 if ($connection->getLastHttpCode() !== 200) {
1410 throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1413 $following = $status->relationship->source->following;
1414 $followed = $status->relationship->source->followed_by;
1416 if ($following && !$followed) {
1417 $relation = Contact::SHARING;
1418 } elseif (!$following && $followed) {
1419 $relation = Contact::FOLLOWER;
1420 } elseif ($following && $followed) {
1421 $relation = Contact::FRIEND;
1422 } elseif (!$following && !$followed) {
1426 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1427 } catch (Throwable $e) {
1428 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1438 function twitter_user_to_contact($data)
1440 if (empty($data->id_str)) {
1444 $baseurl = 'https://twitter.com';
1445 $url = $baseurl . '/' . $data->screen_name;
1446 $addr = $data->screen_name . '@twitter.com';
1450 'nurl' => Strings::normaliseLink($url),
1451 'uri-id' => ItemURI::getIdByURI($url),
1452 'network' => Protocol::TWITTER,
1453 'alias' => 'twitter::' . $data->id_str,
1454 'baseurl' => $baseurl,
1455 'name' => $data->name,
1456 'nick' => $data->screen_name,
1458 'location' => $data->location,
1459 'about' => $data->description,
1460 'photo' => twitter_fix_avatar($data->profile_image_url_https),
1461 'header' => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1467 function twitter_get_contact($data, int $uid = 0)
1469 $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1470 if (DBA::isResult($contact)) {
1471 return $contact['id'];
1473 return twitter_fetch_contact($uid, $data, false);
1477 function twitter_fetch_contact($uid, $data, $create_user)
1479 $fields = twitter_user_to_contact($data);
1481 if (empty($fields)) {
1485 // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1486 $avatar = $fields['photo'];
1487 unset($fields['photo']);
1489 // Update the public contact
1490 $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1491 if (DBA::isResult($pcontact)) {
1492 $cid = $pcontact['id'];
1494 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1498 Contact::update($fields, ['id' => $cid]);
1499 Contact::updateAvatar($cid, $avatar);
1501 Logger::notice('No contact found', ['fields' => $fields]);
1504 $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1505 if (!DBA::isResult($contact) && empty($cid)) {
1506 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1508 } elseif (!$create_user) {
1512 if (!DBA::isResult($contact)) {
1513 $relation = twitter_get_relation($uid, $data->screen_name);
1515 // create contact record
1516 $fields['uid'] = $uid;
1517 $fields['created'] = DateTimeFormat::utcNow();
1518 $fields['poll'] = 'twitter::' . $data->id_str;
1519 $fields['rel'] = $relation;
1520 $fields['priority'] = 1;
1521 $fields['writable'] = true;
1522 $fields['blocked'] = false;
1523 $fields['readonly'] = false;
1524 $fields['pending'] = false;
1526 if (!Contact::insert($fields)) {
1530 $contact_id = DBA::lastInsertId();
1532 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1534 if ($contact['readonly'] || $contact['blocked']) {
1535 Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1539 $contact_id = $contact['id'];
1542 // Update the contact relation once per day
1543 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1544 $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1548 if ($contact['name'] != $data->name) {
1549 $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1553 if ($contact['nick'] != $data->screen_name) {
1554 $fields['uri-date'] = DateTimeFormat::utcNow();
1558 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1563 $fields['updated'] = DateTimeFormat::utcNow();
1564 Contact::update($fields, ['id' => $contact['id']]);
1565 Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1569 Contact::updateAvatar($contact_id, $avatar);
1571 if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1572 twitter_auto_follow($uid, $data);
1579 * Follow a fediverse account that is proived in the name or the profile
1581 * @param integer $uid
1582 * @param object $data
1584 function twitter_auto_follow(int $uid, object $data)
1586 $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1588 // Search for user@domain.tld in the name
1589 if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1590 if (twitter_add_contact($match[1], true, $uid)) {
1595 // Search for @user@domain.tld in the description
1596 if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1597 if (twitter_add_contact($match[1], true, $uid)) {
1602 // Search for user@domain.tld in the description
1603 // We don't probe here, since this could be a mail address
1604 if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1605 if (twitter_add_contact($match[1], false, $uid)) {
1610 // Search for profile links in the description
1611 foreach ($data->entities->description->urls as $url) {
1612 if (!empty($url->expanded_url)) {
1613 // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1614 twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1620 * Check if the provided address is a fediverse account and adds it
1622 * @param string $addr
1623 * @param boolean $probe
1624 * @param integer $uid
1627 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1629 $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1630 if (empty($contact)) {
1631 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1635 if (!in_array($contact['network'], Protocol::FEDERATED)) {
1636 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1640 if (Contact::isSharing($contact['id'], $uid)) {
1641 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1645 Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1646 Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1652 * @param string $screen_name
1653 * @return stdClass|null
1656 function twitter_fetchuser($screen_name)
1658 $ckey = DI::config()->get('twitter', 'consumerkey');
1659 $csecret = DI::config()->get('twitter', 'consumersecret');
1662 // Fetching user data
1663 $connection = new TwitterOAuth($ckey, $csecret);
1664 $parameters = ['screen_name' => $screen_name];
1665 $user = $connection->get('users/show', $parameters);
1666 } catch (TwitterOAuthException $e) {
1667 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1671 if (!is_object($user)) {
1679 * Replaces Twitter entities with Friendica-friendly links.
1681 * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1683 * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1684 * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1685 * index to be correct even after the last replacement.
1687 * @param string $body
1688 * @param stdClass $status
1690 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1692 function twitter_expand_entities($body, stdClass $status)
1695 $contains_urls = false;
1699 $replacementList = [];
1701 foreach ($status->entities->hashtags AS $hashtag) {
1702 $replace = '#[url=' . DI::baseUrl() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1703 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1705 $replacementList[$hashtag->indices[0]] = [
1706 'replace' => $replace,
1707 'length' => $hashtag->indices[1] - $hashtag->indices[0],
1711 foreach ($status->entities->user_mentions AS $mention) {
1712 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1713 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1715 $replacementList[$mention->indices[0]] = [
1716 'replace' => $replace,
1717 'length' => $mention->indices[1] - $mention->indices[0],
1721 foreach ($status->entities->urls ?? [] as $url) {
1722 $plain = str_replace($url->url, '', $plain);
1724 if ($url->url && $url->expanded_url && $url->display_url) {
1725 // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1726 if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1727 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1729 $replacementList[$url->indices[0]] = [
1731 'length' => $url->indices[1] - $url->indices[0],
1736 $contains_urls = true;
1738 $expanded_url = $url->expanded_url;
1740 // Quickfix: Workaround for URL with '[' and ']' in it
1741 if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1742 $expanded_url = $url->url;
1745 $replacementList[$url->indices[0]] = [
1746 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1747 'length' => $url->indices[1] - $url->indices[0],
1752 krsort($replacementList);
1754 foreach ($replacementList as $startIndex => $parameters) {
1755 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1758 $body = trim($body);
1760 return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1764 * Store entity attachments
1766 * @param integer $uriId
1767 * @param object $post Twitter object with the post
1769 function twitter_store_attachments(int $uriId, $post)
1771 if (!empty($post->extended_entities->media)) {
1772 foreach ($post->extended_entities->media AS $medium) {
1773 switch ($medium->type) {
1775 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1777 $attachment['url'] = $medium->media_url_https . '?name=large';
1778 $attachment['width'] = $medium->sizes->large->w;
1779 $attachment['height'] = $medium->sizes->large->h;
1781 if ($medium->sizes->small->w != $attachment['width']) {
1782 $attachment['preview'] = $medium->media_url_https . '?name=small';
1783 $attachment['preview-width'] = $medium->sizes->small->w;
1784 $attachment['preview-height'] = $medium->sizes->small->h;
1787 $attachment['name'] = $medium->display_url ?? null;
1788 $attachment['description'] = $medium->ext_alt_text ?? null;
1789 Logger::debug('Photo attachment', ['attachment' => $attachment]);
1790 Post\Media::insert($attachment);
1793 case 'animated_gif':
1794 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1795 if (is_array($medium->video_info->variants)) {
1797 // We take the video with the highest bitrate
1798 foreach ($medium->video_info->variants AS $variant) {
1799 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1800 $attachment['url'] = $variant->url;
1801 $bitrate = $variant->bitrate;
1806 $attachment['name'] = $medium->display_url ?? null;
1807 $attachment['preview'] = $medium->media_url_https . ':small';
1808 $attachment['preview-width'] = $medium->sizes->small->w;
1809 $attachment['preview-height'] = $medium->sizes->small->h;
1810 $attachment['description'] = $medium->ext_alt_text ?? null;
1811 Logger::debug('Video attachment', ['attachment' => $attachment]);
1812 Post\Media::insert($attachment);
1815 Logger::notice('Unknown media type', ['medium' => $medium]);
1820 if (!empty($post->entities->urls)) {
1821 foreach ($post->entities->urls as $url) {
1822 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1823 Logger::debug('Attached link', ['attachment' => $attachment]);
1824 Post\Media::insert($attachment);
1830 * @brief Fetch media entities and add media links to the body
1832 * @param object $post Twitter object with the post
1833 * @param array $postarray Array of the item that is about to be posted
1834 * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1836 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1838 // There are no media entities? So we quit.
1839 if (empty($post->extended_entities->media)) {
1843 // This is a pure media post, first search for all media urls
1845 foreach ($post->extended_entities->media AS $medium) {
1846 if (!isset($media[$medium->url])) {
1847 $media[$medium->url] = '';
1849 switch ($medium->type) {
1851 if (!empty($medium->ext_alt_text)) {
1852 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1853 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1855 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1858 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1859 $postarray['post-type'] = Item::PT_IMAGE;
1862 // Currently deactivated, since this causes the video to be display before the content
1863 // We have to figure out a better way for declaring the post type and the display style.
1864 //$postarray['post-type'] = Item::PT_VIDEO;
1865 case 'animated_gif':
1866 if (!empty($medium->ext_alt_text)) {
1867 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1868 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1870 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1873 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1874 if (is_array($medium->video_info->variants)) {
1876 // We take the video with the highest bitrate
1877 foreach ($medium->video_info->variants AS $variant) {
1878 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1879 $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1880 $bitrate = $variant->bitrate;
1889 foreach ($media AS $key => $value) {
1890 $postarray['body'] = str_replace($key, '', $postarray['body']);
1895 // Now we replace the media urls.
1896 foreach ($media AS $key => $value) {
1897 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1902 * Undocumented function
1904 * @param integer $uid User ID
1905 * @param object $post Incoming Twitter post
1906 * @param array $self
1907 * @param bool $create_user Should users be created?
1908 * @param bool $only_existing_contact Only import existing contacts if set to "true"
1909 * @param bool $noquote
1910 * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1911 * @return array item array
1913 function twitter_createpost(int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1916 $postarray['network'] = Protocol::TWITTER;
1917 $postarray['uid'] = $uid;
1918 $postarray['wall'] = 0;
1919 $postarray['uri'] = 'twitter::' . $post->id_str;
1920 $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1921 $postarray['source'] = json_encode($post);
1922 $postarray['direction'] = Conversation::PULL;
1924 if (empty($uriId)) {
1925 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1928 // Don't import our own comments
1929 if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1930 Logger::info('Item found', ['extid' => $postarray['uri']]);
1936 if ($post->in_reply_to_status_id_str != '') {
1937 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1939 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1940 if (!DBA::isResult($item)) {
1941 $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1944 if (DBA::isResult($item)) {
1945 $postarray['thr-parent'] = $item['uri'];
1946 $postarray['object-type'] = Activity\ObjectType::COMMENT;
1948 $postarray['object-type'] = Activity\ObjectType::NOTE;
1952 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1954 if ($post->user->id_str == $own_id) {
1955 $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1956 if (DBA::isResult($self)) {
1957 $contactid = $self['id'];
1959 $postarray['owner-id'] = Contact::getIdForURL($self['url']);
1960 $postarray['owner-name'] = $self['name'];
1961 $postarray['owner-link'] = $self['url'];
1962 $postarray['owner-avatar'] = $self['photo'];
1964 Logger::error('No self contact found', ['uid' => $uid]);
1968 // Don't create accounts of people who just comment something
1969 $create_user = false;
1971 $postarray['object-type'] = Activity\ObjectType::NOTE;
1974 if ($contactid == 0) {
1975 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1977 $postarray['owner-id'] = twitter_get_contact($post->user);
1978 $postarray['owner-name'] = $post->user->name;
1979 $postarray['owner-link'] = 'https://twitter.com/' . $post->user->screen_name;
1980 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1983 if (($contactid == 0) && !$only_existing_contact) {
1984 $contactid = $self['id'];
1985 } elseif ($contactid <= 0) {
1986 Logger::info('Contact ID is zero or less than zero.');
1990 $postarray['contact-id'] = $contactid;
1991 $postarray['verb'] = Activity::POST;
1992 $postarray['author-id'] = $postarray['owner-id'];
1993 $postarray['author-name'] = $postarray['owner-name'];
1994 $postarray['author-link'] = $postarray['owner-link'];
1995 $postarray['author-avatar'] = $postarray['owner-avatar'];
1996 $postarray['plink'] = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1997 $postarray['app'] = strip_tags($post->source);
1999 if ($post->user->protected) {
2000 $postarray['private'] = Item::PRIVATE;
2001 $postarray['allow_cid'] = '<' . $self['id'] . '>';
2003 $postarray['private'] = Item::UNLISTED;
2004 $postarray['allow_cid'] = '';
2007 if (!empty($post->full_text)) {
2008 $postarray['body'] = $post->full_text;
2010 $postarray['body'] = $post->text;
2013 // When the post contains links then use the correct object type
2014 if (count($post->entities->urls) > 0) {
2015 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
2018 // Search for media links
2019 twitter_media_entities($post, $postarray, $uriId);
2021 $converted = twitter_expand_entities($postarray['body'], $post);
2023 // When the post contains external links then images or videos are just "decorations".
2024 if (!empty($converted['urls'])) {
2025 $postarray['post-type'] = Item::PT_NOTE;
2028 $postarray['body'] = $converted['body'];
2029 $postarray['created'] = DateTimeFormat::utc($post->created_at);
2030 $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2033 twitter_store_tags($uriId, $converted['taglist']);
2034 twitter_store_attachments($uriId, $post);
2037 if (!empty($post->place->name)) {
2038 $postarray['location'] = $post->place->name;
2040 if (!empty($post->place->full_name)) {
2041 $postarray['location'] = $post->place->full_name;
2043 if (!empty($post->geo->coordinates)) {
2044 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2046 if (!empty($post->coordinates->coordinates)) {
2047 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2049 if (!empty($post->retweeted_status)) {
2050 $retweet = twitter_createpost($uid, $post->retweeted_status, $self, false, false, $noquote);
2052 if (empty($retweet)) {
2057 // Store the original tweet
2058 Item::insert($retweet);
2060 // CHange the other post into a reshare activity
2061 $postarray['verb'] = Activity::ANNOUNCE;
2062 $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2063 $postarray['object-type'] = Activity\ObjectType::NOTE;
2065 $postarray['thr-parent'] = $retweet['uri'];
2067 $retweet['source'] = $postarray['source'];
2068 $retweet['direction'] = $postarray['direction'];
2069 $retweet['private'] = $postarray['private'];
2070 $retweet['allow_cid'] = $postarray['allow_cid'];
2071 $retweet['contact-id'] = $postarray['contact-id'];
2072 $retweet['owner-id'] = $postarray['owner-id'];
2073 $retweet['owner-name'] = $postarray['owner-name'];
2074 $retweet['owner-link'] = $postarray['owner-link'];
2075 $retweet['owner-avatar'] = $postarray['owner-avatar'];
2077 $postarray = $retweet;
2081 if (!empty($post->quoted_status)) {
2083 // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2084 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2086 $quoted = twitter_createpost(0, $post->quoted_status, $self, false, false, true);
2087 if (!empty($quoted)) {
2088 Item::insert($quoted);
2089 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2090 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2092 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2093 $quoted['author-name'],
2094 $quoted['author-link'],
2095 $quoted['author-avatar'],
2101 $postarray['body'] .= $quoted['body'] . '[/share]';
2103 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2104 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2113 * Store tags and mentions
2115 * @param integer $uriId
2116 * @param array $taglist
2119 function twitter_store_tags(int $uriId, array $taglist)
2121 foreach ($taglist as $tag) {
2122 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2126 function twitter_fetchparentposts(int $uid, $post, TwitterOAuth $connection, array $self)
2128 Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2132 while (!empty($post->in_reply_to_status_id_str)) {
2134 $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2135 } catch (TwitterOAuthException $e) {
2136 Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2141 Logger::info("twitter_fetchparentposts: Can't fetch post");
2145 if (empty($post->id_str)) {
2146 Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2150 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2157 Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2159 $posts = array_reverse($posts);
2161 if (!empty($posts)) {
2162 foreach ($posts as $post) {
2163 $postarray = twitter_createpost($uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2165 if (empty($postarray)) {
2169 $item = Item::insert($postarray);
2171 $postarray['id'] = $item;
2173 Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2179 * Fetches the posts received by the Twitter user
2185 function twitter_fetchhometimeline(int $uid): void
2187 $ckey = DI::config()->get('twitter', 'consumerkey');
2188 $csecret = DI::config()->get('twitter', 'consumersecret');
2189 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2190 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2191 $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2192 $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2194 Logger::info('Fetching timeline', ['uid' => $uid]);
2196 $application_name = DI::keyValue()->get('twitter_application_name') ?? '';
2198 if ($application_name == '') {
2199 $application_name = DI::baseUrl()->getHost();
2202 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2205 $own_contact = twitter_fetch_own_contact($uid);
2206 } catch (TwitterOAuthException $e) {
2207 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2211 $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2212 if (DBA::isResult($contact)) {
2213 $own_id = $contact['nick'];
2215 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2219 $self = User::getOwnerDataById($uid);
2220 if ($self === false) {
2221 Logger::warning('Own contact not found', ['uid' => $uid]);
2226 'exclude_replies' => false,
2227 'trim_user' => false,
2228 'contributor_details' => true,
2229 'include_rts' => true,
2230 'tweet_mode' => 'extended',
2231 'include_ext_alt_text' => true,
2235 // Fetching timeline
2236 $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2238 $first_time = ($lastid == '');
2240 if ($lastid != '') {
2241 $parameters['since_id'] = $lastid;
2245 $items = $connection->get('statuses/home_timeline', $parameters);
2246 } catch (TwitterOAuthException $e) {
2247 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2251 if (!is_array($items)) {
2252 Logger::notice('home timeline is no array', ['items' => $items]);
2256 if (empty($items)) {
2257 Logger::info('No new timeline content', ['uid' => $uid]);
2261 $posts = array_reverse($items);
2263 Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2265 if (count($posts)) {
2266 foreach ($posts as $post) {
2267 if ($post->id_str > $lastid) {
2268 $lastid = $post->id_str;
2269 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2276 if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2277 Logger::info('Skip previously sent post');
2281 if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2282 Logger::info('Skip post that will be mirrored');
2286 if ($post->in_reply_to_status_id_str != '') {
2287 twitter_fetchparentposts($uid, $post, $connection, $self);
2290 Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2292 $postarray = twitter_createpost($uid, $post, $self, $create_user, true, false);
2294 if (empty($postarray)) {
2295 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2301 if (empty($postarray['thr-parent'])) {
2302 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2303 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2304 $notify = Worker::PRIORITY_MEDIUM;
2308 $postarray['wall'] = (bool)$notify;
2310 $item = Item::insert($postarray, $notify);
2311 $postarray['id'] = $item;
2313 Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2316 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2318 Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2320 // Fetching mentions
2321 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2323 $first_time = ($lastid == '');
2325 if ($lastid != '') {
2326 $parameters['since_id'] = $lastid;
2330 $items = $connection->get('statuses/mentions_timeline', $parameters);
2331 } catch (TwitterOAuthException $e) {
2332 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2336 if (!is_array($items)) {
2337 Logger::notice('mentions are no arrays', ['items' => $items]);
2341 $posts = array_reverse($items);
2343 Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2345 if (count($posts)) {
2346 foreach ($posts as $post) {
2347 if ($post->id_str > $lastid) {
2348 $lastid = $post->id_str;
2355 if ($post->in_reply_to_status_id_str != '') {
2356 twitter_fetchparentposts($uid, $post, $connection, $self);
2359 $postarray = twitter_createpost($uid, $post, $self, false, !$create_user, false);
2361 if (empty($postarray)) {
2365 $item = Item::insert($postarray);
2367 Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2371 DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2373 Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2376 function twitter_fetch_own_contact(int $uid)
2378 $ckey = DI::config()->get('twitter', 'consumerkey');
2379 $csecret = DI::config()->get('twitter', 'consumersecret');
2380 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2381 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2383 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2387 if ($own_id == '') {
2388 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2390 // Fetching user data
2391 // get() may throw TwitterOAuthException, but we will catch it later
2392 $user = $connection->get('account/verify_credentials');
2393 if (empty($user->id_str)) {
2397 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2399 $contact_id = twitter_fetch_contact($uid, $user, true);
2401 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2402 if (DBA::isResult($contact)) {
2403 $contact_id = $contact['id'];
2405 DI::pConfig()->delete($uid, 'twitter', 'own_id');
2412 function twitter_is_retweet(int $uid, string $body): bool
2414 $body = trim($body);
2416 // Skip if it isn't a pure repeated messages
2417 // Does it start with a share?
2418 if (strpos($body, '[share') > 0) {
2422 // Does it end with a share?
2423 if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2427 $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2428 // Skip if there is no shared message in there
2429 if ($body == $attributes) {
2434 preg_match("/link='(.*?)'/ism", $attributes, $matches);
2435 if (!empty($matches[1])) {
2436 $link = $matches[1];
2439 preg_match('/link="(.*?)"/ism', $attributes, $matches);
2440 if (!empty($matches[1])) {
2441 $link = $matches[1];
2444 $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2448 return twitter_retweet($uid, $id);
2451 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2453 Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2455 $result = twitter_api_post('statuses/retweet', $id, $uid);
2457 Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2459 if (!empty($item_id) && !empty($result->id_str)) {
2460 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2461 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2464 return !isset($result->errors);
2467 function twitter_update_mentions(string $body): string
2469 $URLSearchString = '^\[\]';
2470 $return = preg_replace_callback(
2471 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2472 function ($matches) {
2473 if (strpos($matches[1], 'twitter.com')) {
2474 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2476 $return = $matches[2] . ' (' . $matches[1] . ')';
2487 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2489 if (empty($author_contact)) {
2490 return $content . "\n\n" . $attributes['link'];
2493 if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2494 $mention = '@' . $author_contact['nick'];
2496 $mention = $author_contact['addr'];
2499 return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];