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>
10 * Copyright (c) 2011-2013 Tobias Diekershoff, Michael Vogel, Hypolite Petovan
11 * All rights reserved.
13 * Redistribution and use in source and binary forms, with or without
14 * modification, are permitted provided that the following conditions are met:
15 * * Redistributions of source code must retain the above copyright notice,
16 * this list of conditions and the following disclaimer.
17 * * Redistributions in binary form must reproduce the above
18 * * copyright notice, this list of conditions and the following disclaimer in
19 * the documentation and/or other materials provided with the distribution.
20 * * Neither the name of the <organization> nor the names of its contributors
21 * may be used to endorse or promote products derived from this software
22 * without specific prior written permission.
24 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27 * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT,
28 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
29 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
30 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
31 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
32 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
33 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36 /* Twitter Addon for Friendica
38 * Author: Tobias Diekershoff
39 * tobias.diekershoff@gmx.net
41 * License:3-clause BSD license
44 * To use this addon you need a OAuth Consumer key pair (key & secret)
45 * you can get it from Twitter at https://twitter.com/apps
47 * Register your Friendica site as "Client" application with "Read & Write" access
48 * we do not need "Twitter as login". When you've registered the app you get the
49 * OAuth Consumer key and secret pair for your application/site.
51 * Add this key pair to your config/twitter.config.php file or use the admin panel.
55 * 'consumerkey' => '',
56 * 'consumersecret' => '',
60 * To activate the addon itself add it to the system.addon
61 * setting. After this, your user can configure their Twitter account settings
62 * from "Settings -> Addon Settings".
64 * Requirements: PHP5, curl
67 use Abraham\TwitterOAuth\TwitterOAuth;
68 use Abraham\TwitterOAuth\TwitterOAuthException;
69 use Codebird\Codebird;
71 use Friendica\Content\Text\BBCode;
72 use Friendica\Content\Text\Plaintext;
73 use Friendica\Core\Hook;
74 use Friendica\Core\Logger;
75 use Friendica\Core\Protocol;
76 use Friendica\Core\Renderer;
77 use Friendica\Core\Worker;
78 use Friendica\Database\DBA;
80 use Friendica\Model\Contact;
81 use Friendica\Model\Conversation;
82 use Friendica\Model\Group;
83 use Friendica\Model\Item;
84 use Friendica\Model\ItemURI;
85 use Friendica\Model\Post;
86 use Friendica\Model\Tag;
87 use Friendica\Model\User;
88 use Friendica\Protocol\Activity;
89 use Friendica\Core\Config\Util\ConfigFileLoader;
90 use Friendica\Core\System;
91 use Friendica\Util\DateTimeFormat;
92 use Friendica\Util\Images;
93 use Friendica\Util\Strings;
95 require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
97 define('TWITTER_DEFAULT_POLL_INTERVAL', 5); // given in minutes
99 function twitter_install()
101 // we need some hooks, for the configuration and for sending tweets
102 Hook::register('load_config' , __FILE__, 'twitter_load_config');
103 Hook::register('connector_settings' , __FILE__, 'twitter_settings');
104 Hook::register('connector_settings_post', __FILE__, 'twitter_settings_post');
105 Hook::register('hook_fork' , __FILE__, 'twitter_hook_fork');
106 Hook::register('post_local' , __FILE__, 'twitter_post_local');
107 Hook::register('notifier_normal' , __FILE__, 'twitter_post_hook');
108 Hook::register('jot_networks' , __FILE__, 'twitter_jot_nets');
109 Hook::register('cron' , __FILE__, 'twitter_cron');
110 Hook::register('support_follow' , __FILE__, 'twitter_support_follow');
111 Hook::register('follow' , __FILE__, 'twitter_follow');
112 Hook::register('unfollow' , __FILE__, 'twitter_unfollow');
113 Hook::register('block' , __FILE__, 'twitter_block');
114 Hook::register('unblock' , __FILE__, 'twitter_unblock');
115 Hook::register('expire' , __FILE__, 'twitter_expire');
116 Hook::register('prepare_body' , __FILE__, 'twitter_prepare_body');
117 Hook::register('check_item_notification', __FILE__, 'twitter_check_item_notification');
118 Hook::register('probe_detect' , __FILE__, 'twitter_probe_detect');
119 Hook::register('item_by_link' , __FILE__, 'twitter_item_by_link');
120 Hook::register('parse_link' , __FILE__, 'twitter_parse_link');
121 Logger::info('installed twitter');
126 function twitter_load_config(App $a, ConfigFileLoader $loader)
128 $a->getConfigCache()->load($loader->loadAddonConfig('twitter'));
131 function twitter_check_item_notification(App $a, array &$notification_data)
133 $own_id = DI::pConfig()->get($notification_data['uid'], 'twitter', 'own_id');
135 $own_user = Contact::selectFirst(['url'], ['uid' => $notification_data['uid'], 'alias' => 'twitter::'.$own_id]);
137 $notification_data['profiles'][] = $own_user['url'];
141 function twitter_support_follow(App $a, array &$data)
143 if ($data['protocol'] == Protocol::TWITTER) {
144 $data['result'] = true;
148 function twitter_follow(App $a, array &$contact)
150 Logger::info('Check if contact is twitter contact', ['url' => $contact['url']]);
152 if (!strstr($contact['url'], '://twitter.com') && !strstr($contact['url'], '@twitter.com')) {
156 // contact seems to be a twitter contact, so continue
157 $nickname = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $contact['url']);
158 $nickname = str_replace('@twitter.com', '', $nickname);
160 $uid = $a->getLoggedInUserId();
162 if (!twitter_api_contact('friendships/create', ['network' => Protocol::TWITTER, 'nick' => $nickname], $uid)) {
167 $user = twitter_fetchuser($nickname);
169 $contact_id = twitter_fetch_contact($uid, $user, true);
171 $contact = Contact::getById($contact_id, ['name', 'nick', 'url', 'addr', 'batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'photo', 'priority', 'network', 'alias', 'pubkey']);
173 if (DBA::isResult($contact)) {
174 $contact['contact'] = $contact;
178 function twitter_unfollow(App $a, array &$hook_data)
180 $hook_data['result'] = twitter_api_contact('friendships/destroy', $hook_data['contact'], $hook_data['uid']);
183 function twitter_block(App $a, array &$hook_data)
185 $hook_data['result'] = twitter_api_contact('blocks/create', $hook_data['contact'], $hook_data['uid']);
187 if ($hook_data['result'] === true) {
188 $cdata = Contact::getPublicAndUserContactID($hook_data['contact']['id'], $hook_data['uid']);
189 Contact::remove($cdata['user']);
193 function twitter_unblock(App $a, array &$hook_data)
195 $hook_data['result'] = twitter_api_contact('blocks/destroy', $hook_data['contact'], $hook_data['uid']);
198 function twitter_api_contact(string $apiPath, array $contact, int $uid): ?bool
200 if ($contact['network'] !== Protocol::TWITTER) {
204 return (bool)twitter_api_call($uid, $apiPath, ['screen_name' => $contact['nick']]);
207 function twitter_jot_nets(App $a, array &$jotnets_fields)
209 if (!DI::userSession()->getLocalUserId()) {
213 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post')) {
214 $jotnets_fields[] = [
215 'type' => 'checkbox',
218 DI::l10n()->t('Post to Twitter'),
219 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default')
226 function twitter_settings_post(App $a)
228 if (!DI::userSession()->getLocalUserId()) {
231 // don't check twitter settings if twitter submit button is not clicked
232 if (empty($_POST['twitter-disconnect']) && empty($_POST['twitter-submit'])) {
236 if (!empty($_POST['twitter-disconnect'])) {
238 * if the twitter-disconnect checkbox is set, clear the OAuth key/secret pair
239 * from the user configuration
241 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumerkey');
242 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'consumersecret');
243 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
244 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
245 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post');
246 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default');
247 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
248 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'thread');
249 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts');
250 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'import');
251 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'create_user');
252 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow');
253 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'own_id');
255 if (isset($_POST['twitter-pin'])) {
256 // if the user supplied us with a PIN from Twitter, let the magic of OAuth happen
257 Logger::notice('got a Twitter PIN');
258 $ckey = DI::config()->get('twitter', 'consumerkey');
259 $csecret = DI::config()->get('twitter', 'consumersecret');
260 // the token and secret for which the PIN was generated were hidden in the settings
261 // form as token and token2, we need a new connection to Twitter using these token
262 // and secret to request a Access Token with the PIN
264 if (empty($_POST['twitter-pin'])) {
265 throw new Exception(DI::l10n()->t('You submitted an empty PIN, please Sign In with Twitter again to get a new one.'));
268 $connection = new TwitterOAuth($ckey, $csecret, $_POST['twitter-token'], $_POST['twitter-token2']);
269 $token = $connection->oauth('oauth/access_token', ['oauth_verifier' => $_POST['twitter-pin']]);
270 // ok, now that we have the Access Token, save them in the user config
271 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken', $token['oauth_token']);
272 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret', $token['oauth_token_secret']);
273 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', 1);
274 } catch(Exception $e) {
275 DI::sysmsg()->addNotice($e->getMessage());
276 } catch(TwitterOAuthException $e) {
277 DI::sysmsg()->addNotice($e->getMessage());
280 // if no PIN is supplied in the POST variables, the user has changed the setting
281 // to post a tweet for every new __public__ posting to the wall
282 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post', intval($_POST['twitter-enable']));
283 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default', intval($_POST['twitter-default']));
284 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'thread', intval($_POST['twitter-thread']));
285 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts', intval($_POST['twitter-mirror']));
286 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'import', intval($_POST['twitter-import']));
287 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'create_user', intval($_POST['twitter-create_user']));
288 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow', intval($_POST['twitter-auto_follow']));
290 if (!intval($_POST['twitter-mirror'])) {
291 DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'twitter', 'lastid');
297 function twitter_settings(App $a, array &$data)
299 if (!DI::userSession()->getLocalUserId()) {
303 $user = User::getById(DI::userSession()->getLocalUserId());
305 DI::page()->registerStylesheet(__DIR__ . '/twitter.css', 'all');
308 * 1) Check that we have global consumer key & secret
309 * 2) If no OAuthtoken & stuff is present, generate button to get some
310 * 3) Checkbox for "Send public notices (280 chars only)
312 $ckey = DI::config()->get('twitter', 'consumerkey');
313 $csecret = DI::config()->get('twitter', 'consumersecret');
314 $otoken = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthtoken');
315 $osecret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'oauthsecret');
317 $enabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
318 $defenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'));
319 $threadenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'thread'));
320 $mirrorenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'mirror_posts'));
321 $importenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'import'));
322 $create_userenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'create_user'));
323 $auto_followenabled = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'auto_follow'));
325 // Hide the submit button by default
328 if ((!$ckey) && (!$csecret)) {
329 /* no global consumer keys
330 * display warning and skip personal config
332 $html = '<p>' . DI::l10n()->t('No consumer key pair for Twitter found. Please contact your site administrator.') . '</p>';
334 // ok we have a consumer key pair now look into the OAuth stuff
335 if ((!$otoken) && (!$osecret)) {
336 /* the user has not yet connected the account to twitter...
337 * get a temporary OAuth key/secret pair and display a button with
338 * which the user can request a PIN to connect the account to a
339 * account at Twitter.
341 $connection = new TwitterOAuth($ckey, $csecret);
343 $result = $connection->oauth('oauth/request_token', ['oauth_callback' => 'oob']);
345 $html = '<p>' . DI::l10n()->t('At this Friendica instance the Twitter addon was enabled but you have not yet connected your account to your Twitter account. To do so click the button below to get a PIN from Twitter which you have to copy into the input box below and submit the form. Only your <strong>public</strong> posts will be posted to Twitter.') . '</p>';
346 $html .= '<a href="' . $connection->url('oauth/authorize', ['oauth_token' => $result['oauth_token']]) . '" target="_twitter"><img src="addon/twitter/lighter.png" alt="' . DI::l10n()->t('Log in with Twitter') . '"></a>';
347 $html .= '<div id="twitter-pin-wrapper">';
348 $html .= '<label id="twitter-pin-label" for="twitter-pin">' . DI::l10n()->t('Copy the PIN from Twitter here') . '</label>';
349 $html .= '<input id="twitter-pin" type="text" name="twitter-pin" />';
350 $html .= '<input id="twitter-token" type="hidden" name="twitter-token" value="' . $result['oauth_token'] . '" />';
351 $html .= '<input id="twitter-token2" type="hidden" name="twitter-token2" value="' . $result['oauth_token_secret'] . '" />';
355 } catch (TwitterOAuthException $e) {
356 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
360 * we have an OAuth key / secret pair for the user
361 * so let's give a chance to disable the postings to Twitter
363 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
365 $account = $connection->get('account/verify_credentials');
366 if (property_exists($account, 'screen_name') &&
367 property_exists($account, 'description') &&
368 property_exists($account, 'profile_image_url')
370 $connected = DI::l10n()->t('Currently connected to: <a href="https://twitter.com/%1$s" target="_twitter">%1$s</a>', $account->screen_name);
372 Logger::notice('Invalid twitter info (verify credentials).', ['auth' => TwitterOAuth::class]);
375 if ($user['hidewall']) {
376 $privacy_warning = DI::l10n()->t('<strong>Note</strong>: Due to your privacy settings (<em>Hide your profile details from unknown viewers?</em>) the link potentially included in public postings relayed to Twitter will lead the visitor to a blank page informing the visitor that the access to your profile has been restricted.');
379 $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/twitter/');
380 $html = Renderer::replaceMacros($t, [
382 'connected' => $connected ?? '',
383 'invalid' => DI::l10n()->t('Invalid Twitter info'),
384 'disconnect' => DI::l10n()->t('Disconnect'),
385 'privacy_warning' => $privacy_warning ?? '',
388 '$account' => $account,
389 '$enable' => ['twitter-enable', DI::l10n()->t('Allow posting to Twitter'), $enabled, DI::l10n()->t('If enabled all your <strong>public</strong> postings can be posted to the associated Twitter account. You can choose to do so by default (here) or for every posting separately in the posting options when writing the entry.')],
390 '$default' => ['twitter-default', DI::l10n()->t('Send public postings to Twitter by default'), $defenabled],
391 '$thread' => ['twitter-thread', DI::l10n()->t('Use threads instead of truncating the content'), $threadenabled],
392 '$mirror' => ['twitter-mirror', DI::l10n()->t('Mirror all posts from twitter that are no replies'), $mirrorenabled],
393 '$import' => ['twitter-import', DI::l10n()->t('Import the remote timeline'), $importenabled],
394 '$create_user' => ['twitter-create_user', DI::l10n()->t('Automatically create contacts'), $create_userenabled, DI::l10n()->t('This will automatically create a contact in Friendica as soon as you receive a message from an existing contact via the Twitter network. If you do not enable this, you need to manually add those Twitter contacts in Friendica from whom you would like to see posts here.')],
395 '$auto_follow' => ['twitter-auto_follow', DI::l10n()->t('Follow in fediverse'), $auto_followenabled, DI::l10n()->t('Automatically subscribe to the contact in the fediverse, when a fediverse account is mentioned in name or description and we are following the Twitter contact.')],
398 // Enable the default submit button
400 } catch (TwitterOAuthException $e) {
401 $html = '<p>' . DI::l10n()->t('An error occured: ') . $e->getMessage() . '</p>';
407 'connector' => 'twitter',
408 'title' => DI::l10n()->t('Twitter Import/Export/Mirror'),
409 'enabled' => $enabled,
410 'image' => 'images/twitter.png',
412 'submit' => $submit ?? null,
416 function twitter_hook_fork(App $a, array &$b)
418 DI::logger()->debug('twitter_hook_fork', $b);
420 if ($b['name'] != 'notifier_normal') {
426 // Deletion checks are done in twitter_delete_item()
427 if ($post['deleted']) {
431 // Editing is not supported by the addon
432 if ($post['created'] !== $post['edited']) {
433 DI::logger()->info('Editing is not supported by the addon');
434 $b['execute'] = false;
438 // if post comes from twitter don't send it back
439 if (($post['extid'] == Protocol::TWITTER) || twitter_get_id($post['extid'])) {
440 DI::logger()->info('If post comes from twitter don\'t send it back');
441 $b['execute'] = false;
445 if (substr($post['app'] ?? '', 0, 7) == 'Twitter') {
446 DI::logger()->info('No Twitter app');
447 $b['execute'] = false;
451 if (DI::pConfig()->get($post['uid'], 'twitter', 'import')) {
452 // Don't fork if it isn't a reply to a twitter post
453 if (($post['parent'] != $post['id']) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::TWITTER])) {
454 Logger::notice('No twitter parent found', ['item' => $post['id']]);
455 $b['execute'] = false;
459 // Comments are never exported when we don't import the twitter timeline
460 if (!strstr($post['postopts'] ?? '', 'twitter') || ($post['parent'] != $post['id']) || $post['private']) {
461 DI::logger()->info('Comments are never exported when we don\'t import the twitter timeline');
462 $b['execute'] = false;
468 function twitter_post_local(App $a, array &$b)
474 if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
478 $twitter_post = intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post'));
479 $twitter_enable = (($twitter_post && !empty($_REQUEST['twitter_enable'])) ? intval($_REQUEST['twitter_enable']) : 0);
481 // if API is used, default to the chosen settings
482 if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'))) {
486 if (!$twitter_enable) {
490 if (strlen($b['postopts'])) {
491 $b['postopts'] .= ',';
494 $b['postopts'] .= 'twitter';
497 function twitter_probe_detect(App $a, array &$hookData)
499 // Don't overwrite an existing result
500 if (isset($hookData['result'])) {
504 // Avoid a lookup for the wrong network
505 if (!in_array($hookData['network'], ['', Protocol::TWITTER])) {
509 if (preg_match('=([^@]+)@(?:mobile\.)?twitter\.com$=i', $hookData['uri'], $matches)) {
511 } elseif (preg_match('=^https?://(?:mobile\.)?twitter\.com/(.+)=i', $hookData['uri'], $matches)) {
512 if (strpos($matches[1], '/') !== false) {
513 // Status case: https://twitter.com/<nick>/status/<status id>
515 $hookData['result'] = false;
524 $user = twitter_fetchuser($nick);
527 $hookData['result'] = twitter_user_to_contact($user);
531 function twitter_item_by_link(App $a, array &$hookData)
533 // Don't overwrite an existing result
534 if (isset($hookData['item_id'])) {
539 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $hookData['uri'], $matches)) {
543 // From now on, any early return should abort the whole chain since we've established it was a Twitter URL
544 $hookData['item_id'] = false;
546 // Node-level configuration check
547 if (empty(DI::config()->get('twitter', 'consumerkey')) || empty(DI::config()->get('twitter', 'consumersecret'))) {
551 // No anonymous import
552 if (!$hookData['uid']) {
557 empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthtoken'))
558 || empty(DI::pConfig()->get($hookData['uid'], 'twitter', 'oauthsecret'))
560 DI::sysmsg()->addNotice(DI::l10n()->t('Please connect a Twitter account in your Social Network settings to import Twitter posts.'));
564 $status = twitter_statuses_show($matches[1]);
566 if (empty($status->id_str)) {
567 DI::sysmsg()->addNotice(DI::l10n()->t('Twitter post not found.'));
571 $item = twitter_createpost($a, $hookData['uid'], $status, [], true, false, false);
573 $hookData['item_id'] = Item::insert($item);
577 function twitter_api_post(string $apiPath, string $pid, int $uid): ?object
583 return twitter_api_call($uid, $apiPath, ['id' => $pid]);
586 function twitter_api_call(int $uid, string $apiPath, array $parameters = []): ?object
588 $ckey = DI::config()->get('twitter', 'consumerkey');
589 $csecret = DI::config()->get('twitter', 'consumersecret');
590 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
591 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
593 // If the addon is not configured (general or for this user) quit here
594 if (empty($ckey) || empty($csecret) || empty($otoken) || empty($osecret)) {
599 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
600 $result = $connection->post($apiPath, $parameters);
602 if ($connection->getLastHttpCode() != 200) {
603 throw new Exception($result->errors[0]->message ?? json_encode($result), $connection->getLastHttpCode());
606 if (!empty($result->errors)) {
607 throw new Exception($result->errors[0]->message, $result->errors[0]->code);
610 Logger::info('[twitter] API call successful', ['apiPath' => $apiPath, 'parameters' => $parameters]);
611 Logger::debug('[twitter] API call result', ['apiPath' => $apiPath, 'parameters' => $parameters, 'result' => $result]);
614 } catch (TwitterOAuthException $twitterOAuthException) {
615 Logger::notice('Unable to communicate with twitter', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $twitterOAuthException->getCode(), 'exception' => $twitterOAuthException]);
617 } catch (Exception $e) {
618 Logger::notice('[twitter] API call failed', ['apiPath' => $apiPath, 'parameters' => $parameters, 'code' => $e->getCode(), 'message' => $e->getMessage()]);
623 function twitter_get_id(string $uri)
625 if ((substr($uri, 0, 9) != 'twitter::') || (strlen($uri) <= 9)) {
629 $id = substr($uri, 9);
630 if (!is_numeric($id)) {
637 function twitter_post_hook(App $a, array &$b)
639 DI::logger()->debug('Invoke post hook', $b);
642 twitter_delete_item($b);
647 if (!DI::pConfig()->get($b['uid'], 'twitter', 'import')
648 && ($b['private'] || ($b['created'] !== $b['edited']))) {
652 $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
656 if ($b['parent'] != $b['id']) {
657 Logger::debug('Got comment', ['item' => $b]);
659 // Looking if its a reply to a twitter post
660 if (!twitter_get_id($b['parent-uri']) &&
661 !twitter_get_id($b['extid']) &&
662 !twitter_get_id($b['thr-parent'])) {
663 Logger::info('No twitter post', ['parent' => $b['parent']]);
667 $condition = ['uri' => $b['thr-parent'], 'uid' => $b['uid']];
668 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
669 if (!DBA::isResult($thr_parent)) {
670 Logger::notice('No parent found', ['thr-parent' => $b['thr-parent']]);
674 if ($thr_parent['author-network'] == Protocol::TWITTER) {
675 $nickname = '@[url=' . $thr_parent['author-link'] . ']' . $thr_parent['author-nick'] . '[/url]';
676 $nicknameplain = '@' . $thr_parent['author-nick'];
678 Logger::info('Comparing', ['nickname' => $nickname, 'nicknameplain' => $nicknameplain, 'body' => $b['body']]);
679 if ((strpos($b['body'], $nickname) === false) && (strpos($b['body'], $nicknameplain) === false)) {
680 $b['body'] = $nickname . ' ' . $b['body'];
684 Logger::debug('Parent found', ['parent' => $thr_parent]);
686 if ($b['private'] || !strstr($b['postopts'], 'twitter')) {
690 // Dont't post if the post doesn't belong to us.
691 // This is a check for forum postings
692 $self = DBA::selectFirst('contact', ['id'], ['uid' => $b['uid'], 'self' => true]);
693 if ($b['contact-id'] != $self['id']) {
698 if ($b['verb'] == Activity::LIKE) {
699 Logger::info('Like', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
701 twitter_api_post('favorites/create', twitter_get_id($b['thr-parent']), $b['uid']);
706 if ($b['verb'] == Activity::ANNOUNCE) {
707 Logger::info('Retweet', ['uid' => $b['uid'], 'id' => twitter_get_id($b['thr-parent'])]);
708 twitter_retweet($b['uid'], twitter_get_id($b['thr-parent']));
712 if ($b['created'] !== $b['edited']) {
716 // if post comes from twitter don't send it back
717 if (($b['extid'] == Protocol::TWITTER) || twitter_get_id($b['extid'])) {
721 if ($b['app'] == 'Twitter') {
725 Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
727 DI::pConfig()->load($b['uid'], 'twitter');
729 $ckey = DI::config()->get('twitter', 'consumerkey');
730 $csecret = DI::config()->get('twitter', 'consumersecret');
731 $otoken = DI::pConfig()->get($b['uid'], 'twitter', 'oauthtoken');
732 $osecret = DI::pConfig()->get($b['uid'], 'twitter', 'oauthsecret');
734 if ($ckey && $csecret && $otoken && $osecret) {
735 Logger::info('We have customer key and oauth stuff, going to send.');
737 // If it's a repeated message from twitter then do a native retweet and exit
738 if (twitter_is_retweet($a, $b['uid'], $b['body'])) {
742 Codebird::setConsumerKey($ckey, $csecret);
743 $cb = Codebird::getInstance();
744 $cb->setToken($otoken, $osecret);
746 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
748 // Set the timeout for upload to 30 seconds
749 $connection->setTimeouts(10, 30);
753 // Handling non-native reshares
754 $b['body'] = Friendica\Content\Text\BBCode::convertShare(
756 function (array $attributes, array $author_contact, $content, $is_quote_share) {
757 return twitter_convert_share($attributes, $author_contact, $content, $is_quote_share);
761 $b['body'] = twitter_update_mentions($b['body']);
763 $msgarr = Plaintext::getPost($b, $max_char, true, BBCode::TWITTER);
764 Logger::info('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
765 $msg = $msgarr['text'];
767 if (($msg == '') && isset($msgarr['title'])) {
768 $msg = Plaintext::shorten($msgarr['title'], $max_char - 50, $b['uid']);
771 // Add the link to the body if the type isn't a photo or there are more than 4 images in the post
772 if (!empty($msgarr['url']) && (strpos($msg, $msgarr['url']) === false) && (($msgarr['type'] != 'photo') || empty($msgarr['images']) || (count($msgarr['images']) > 4))) {
773 $msg .= "\n" . $msgarr['url'];
777 Logger::notice('Empty message', ['id' => $b['id']]);
781 // and now tweet it :-)
784 if (!empty($msgarr['images'])) {
785 Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images']]);
788 foreach ($msgarr['images'] as $image) {
789 if (count($media_ids) == 4) {
793 $img_str = DI::httpClient()->fetch($image['url']);
795 $tempfile = tempnam(System::getTempPath(), 'cache');
796 file_put_contents($tempfile, $img_str);
798 Logger::info('Uploading', ['id' => $b['id'], 'image' => $image['url']]);
799 $media = $connection->upload('media/upload', ['media' => $tempfile]);
803 if (isset($media->media_id_string)) {
804 $media_ids[] = $media->media_id_string;
806 if (!empty($image['description'])) {
807 $data = ['media_id' => $media->media_id_string,
808 'alt_text' => ['text' => substr($image['description'], 0, 420)]];
809 $ret = $cb->media_metadata_create($data);
810 Logger::info('Metadata create', ['id' => $b['id'], 'data' => $data, 'return' => $ret]);
813 Logger::error('Failed upload', ['id' => $b['id'], 'image' => $image['url'], 'return' => $media]);
814 throw new Exception('Failed upload of ' . $image['url']);
817 $post['media_ids'] = implode(',', $media_ids);
818 if (empty($post['media_ids'])) {
819 unset($post['media_ids']);
821 } catch (Exception $e) {
822 Logger::warning('Exception when trying to send to Twitter', ['id' => $b['id'], 'message' => $e->getMessage()]);
826 if (!DI::pConfig()->get($b['uid'], 'twitter', 'thread') || empty($msgarr['parts']) || (count($msgarr['parts']) == 1)) {
827 Logger::debug('Post single message', ['id' => $b['id']]);
829 $post['status'] = $msg;
832 $post['in_reply_to_status_id'] = twitter_get_id($thr_parent['uri']);
835 $result = $connection->post('statuses/update', $post);
836 Logger::info('twitter_post send', ['id' => $b['id'], 'result' => $result]);
838 if (!empty($result->source)) {
839 DI::config()->set('twitter', 'application_name', strip_tags($result->source));
842 if (!empty($result->errors)) {
843 Logger::error('Send to Twitter failed', ['id' => $b['id'], 'error' => $result->errors]);
845 } elseif ($thr_parent) {
846 Logger::notice('Post send, updating extid', ['id' => $b['id'], 'extid' => $result->id_str]);
847 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
851 $in_reply_to_status_id = twitter_get_id($thr_parent['uri']);
853 $in_reply_to_status_id = 0;
856 Logger::debug('Post message thread', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
857 foreach ($msgarr['parts'] as $key => $part) {
858 $post['status'] = $part;
860 if ($in_reply_to_status_id) {
861 $post['in_reply_to_status_id'] = $in_reply_to_status_id;
864 $result = $connection->post('statuses/update', $post);
865 Logger::debug('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $result]);
867 if (!empty($result->errors)) {
868 Logger::warning('Send to Twitter failed', ['part' => $key, 'id' => $b['id'], 'error' => $result->errors]);
871 } elseif ($key == 0) {
872 Logger::debug('Updating extid', ['part' => $key, 'id' => $b['id'], 'extid' => $result->id_str]);
873 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $b['id']]);
876 if (!empty($result->source)) {
877 $application_name = strip_tags($result->source);
880 $in_reply_to_status_id = $result->id_str;
881 unset($post['media_ids']);
884 if (!empty($application_name)) {
885 DI::config()->set('twitter', 'application_name', strip_tags($result->source));
891 function twitter_delete_item(array $item)
893 if (!$item['deleted']) {
897 if ($item['parent'] != $item['id']) {
898 Logger::debug('Deleting comment/announce', ['item' => $item]);
900 // Looking if it's a reply to a twitter post
901 if (!twitter_get_id($item['parent-uri']) &&
902 !twitter_get_id($item['extid']) &&
903 !twitter_get_id($item['thr-parent'])) {
904 Logger::info('No twitter post', ['parent' => $item['parent']]);
908 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
909 $thr_parent = Post::selectFirst(['uri', 'extid', 'author-link', 'author-nick', 'author-network'], $condition);
910 if (!DBA::isResult($thr_parent)) {
911 Logger::notice('No parent found', ['thr-parent' => $item['thr-parent']]);
915 Logger::debug('Parent found', ['parent' => $thr_parent]);
917 if (!strstr($item['extid'], 'twitter')) {
918 DI::logger()->info('Not a Twitter post', ['extid' => $item['extid']]);
922 // Don't delete if the post doesn't belong to us.
923 // This is a check for forum postings
924 $self = DBA::selectFirst('contact', ['id'], ['uid' => $item['uid'], 'self' => true]);
925 if ($item['contact-id'] != $self['id']) {
926 DI::logger()->info('Don\'t delete if the post doesn\'t belong to the user', ['contact-id' => $item['contact-id'], 'self' => $self['id']]);
932 * @TODO Remaining caveat: Comments posted on Twitter and imported in Friendica do not trigger any Notifier task,
933 * possibly because they are private to the user and don't require any remote deletion notifications sent.
934 * Comments posted on Friendica and mirrored on Twitter trigger the Notifier task and the Twitter counter-part
935 * will be deleted accordingly.
937 if ($item['verb'] == Activity::POST) {
938 Logger::info('Delete post/comment', ['uid' => $item['uid'], 'id' => twitter_get_id($item['extid'])]);
939 twitter_api_post('statuses/destroy', twitter_get_id($item['extid']), $item['uid']);
943 if ($item['verb'] == Activity::LIKE) {
944 Logger::info('Unlike', ['uid' => $item['uid'], 'id' => twitter_get_id($item['thr-parent'])]);
945 twitter_api_post('favorites/destroy', twitter_get_id($item['thr-parent']), $item['uid']);
949 if ($item['verb'] == Activity::ANNOUNCE && !empty($thr_parent['uri'])) {
950 Logger::info('Unretweet', ['uid' => $item['uid'], 'extid' => $thr_parent['uri'], 'id' => twitter_get_id($thr_parent['uri'])]);
951 twitter_api_post('statuses/unretweet', twitter_get_id($thr_parent['uri']), $item['uid']);
956 function twitter_addon_admin_post(App $a)
958 DI::config()->set('twitter', 'consumerkey', trim($_POST['consumerkey'] ?? ''));
959 DI::config()->set('twitter', 'consumersecret', trim($_POST['consumersecret'] ?? ''));
962 function twitter_addon_admin(App $a, string &$o)
964 $t = Renderer::getMarkupTemplate('admin.tpl', 'addon/twitter/');
966 $o = Renderer::replaceMacros($t, [
967 '$submit' => DI::l10n()->t('Save Settings'),
968 // name, label, value, help, [extra values]
969 '$consumerkey' => ['consumerkey', DI::l10n()->t('Consumer key'), DI::config()->get('twitter', 'consumerkey'), ''],
970 '$consumersecret' => ['consumersecret', DI::l10n()->t('Consumer secret'), DI::config()->get('twitter', 'consumersecret'), ''],
974 function twitter_cron(App $a)
976 $last = DI::config()->get('twitter', 'last_poll');
978 $poll_interval = intval(DI::config()->get('twitter', 'poll_interval'));
979 if (!$poll_interval) {
980 $poll_interval = TWITTER_DEFAULT_POLL_INTERVAL;
984 $next = $last + ($poll_interval * 60);
985 if ($next > time()) {
986 Logger::notice('twitter: poll intervall not reached');
990 Logger::notice('twitter: cron_start');
992 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'mirror_posts', 'v' => true]);
993 foreach ($pconfigs as $rr) {
994 Logger::notice('Fetching', ['user' => $rr['uid']]);
995 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 1, (int) $rr['uid']);
998 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
999 if ($abandon_days < 1) {
1003 $abandon_limit = date(DateTimeFormat::MYSQL, time() - $abandon_days * 86400);
1005 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1006 foreach ($pconfigs as $rr) {
1007 if ($abandon_days != 0) {
1008 if (!DBA::exists('user', ["`uid` = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit])) {
1009 Logger::notice('abandoned account: timeline from user will not be imported', ['user' => $rr['uid']]);
1014 Logger::notice('importing timeline', ['user' => $rr['uid']]);
1015 Worker::add(['priority' => Worker::PRIORITY_MEDIUM, 'force_priority' => true], 'addon/twitter/twitter_sync.php', 2, (int) $rr['uid']);
1018 // check for new contacts once a day
1019 $last_contact_check = DI::pConfig()->get($rr['uid'],'pumpio','contact_check');
1020 if($last_contact_check)
1021 $next_contact_check = $last_contact_check + 86400;
1023 $next_contact_check = 0;
1025 if($next_contact_check <= time()) {
1026 pumpio_getallusers($a, $rr["uid"]);
1027 DI::pConfig()->set($rr['uid'],'pumpio','contact_check',time());
1032 Logger::notice('twitter: cron_end');
1034 DI::config()->set('twitter', 'last_poll', time());
1037 function twitter_expire(App $a)
1039 $days = DI::config()->get('twitter', 'expire');
1045 Logger::notice('Start deleting expired posts');
1047 $r = Post::select(['id', 'guid'], ['deleted' => true, 'network' => Protocol::TWITTER]);
1048 while ($row = Post::fetch($r)) {
1049 Logger::info('[twitter] Delete expired item', ['id' => $row['id'], 'guid' => $row['guid'], 'callstack' => \Friendica\Core\System::callstack()]);
1050 Item::markForDeletionById($row['id']);
1054 Logger::notice('End deleting expired posts');
1056 Logger::notice('Start expiry');
1058 $pconfigs = DBA::selectToArray('pconfig', [], ['cat' => 'twitter', 'k' => 'import', 'v' => true]);
1059 foreach ($pconfigs as $rr) {
1060 Logger::notice('twitter_expire', ['user' => $rr['uid']]);
1061 Item::expire($rr['uid'], $days, Protocol::TWITTER, true);
1064 Logger::notice('End expiry');
1067 function twitter_prepare_body(App $a, array &$b)
1069 if ($b['item']['network'] != Protocol::TWITTER) {
1073 if ($b['preview']) {
1076 $item['plink'] = DI::baseUrl()->get() . '/display/' . $item['guid'];
1078 $condition = ['uri' => $item['thr-parent'], 'uid' => DI::userSession()->getLocalUserId()];
1079 $orig_post = Post::selectFirst(['author-link'], $condition);
1080 if (DBA::isResult($orig_post)) {
1081 $nicknameplain = preg_replace("=https?://twitter.com/(.*)=ism", "$1", $orig_post['author-link']);
1082 $nickname = '@[url=' . $orig_post['author-link'] . ']' . $nicknameplain . '[/url]';
1083 $nicknameplain = '@' . $nicknameplain;
1085 if ((strpos($item['body'], $nickname) === false) && (strpos($item['body'], $nicknameplain) === false)) {
1086 $item['body'] = $nickname . ' ' . $item['body'];
1090 $msgarr = Plaintext::getPost($item, $max_char, true, BBCode::TWITTER);
1091 $msg = $msgarr['text'];
1093 if (isset($msgarr['url']) && ($msgarr['type'] != 'photo')) {
1094 $msg .= ' ' . $msgarr['url'];
1097 if (isset($msgarr['image'])) {
1098 $msg .= ' ' . $msgarr['image'];
1101 $b['html'] = nl2br(htmlspecialchars($msg));
1105 function twitter_statuses_show(string $id, TwitterOAuth $twitterOAuth = null)
1107 if ($twitterOAuth === null) {
1108 $ckey = DI::config()->get('twitter', 'consumerkey');
1109 $csecret = DI::config()->get('twitter', 'consumersecret');
1111 if (empty($ckey) || empty($csecret)) {
1112 return new stdClass();
1115 $twitterOAuth = new TwitterOAuth($ckey, $csecret);
1118 $parameters = ['trim_user' => false, 'tweet_mode' => 'extended', 'id' => $id, 'include_ext_alt_text' => true];
1120 return $twitterOAuth->get('statuses/show', $parameters);
1124 * Parse Twitter status URLs since Twitter removed OEmbed
1127 * @param array $b Expected format:
1129 * 'url' => [URL to parse],
1130 * 'format' => 'json'|'',
1131 * 'text' => Output parameter
1133 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1135 function twitter_parse_link(App $a, array &$b)
1137 // Only handle Twitter status URLs
1138 if (!preg_match('#^https?://(?:mobile\.|www\.)?twitter.com/[^/]+/status/(\d+).*#', $b['url'], $matches)) {
1142 $status = twitter_statuses_show($matches[1]);
1144 if (empty($status->id)) {
1148 $item = twitter_createpost($a, 0, $status, [], true, false, true);
1153 if ($b['format'] == 'json') {
1155 foreach ($status->extended_entities->media ?? [] as $media) {
1156 if (!empty($media->media_url_https)) {
1158 'src' => $media->media_url_https,
1159 'width' => $media->sizes->thumb->w,
1160 'height' => $media->sizes->thumb->h,
1168 'url' => $item['plink'],
1169 'title' => DI::l10n()->t('%s on Twitter', $status->user->name),
1170 'text' => BBCode::toPlaintext($item['body'], false),
1171 'images' => $images,
1173 'contentType' => 'attachment',
1177 $b['text'] = BBCode::getShareOpeningTag(
1178 $item['author-name'],
1179 $item['author-link'],
1180 $item['author-avatar'],
1184 $b['text'] .= $item['body'] . '[/share]';
1189 /*********************
1193 *********************/
1197 * @brief Build the item array for the mirrored post
1199 * @param App $a Application class
1200 * @param integer $uid User id
1201 * @param object $post Twitter object with the post
1203 * @return array item data to be posted
1205 function twitter_do_mirrorpost(App $a, int $uid, $post)
1207 $datarray['uid'] = $uid;
1208 $datarray['extid'] = 'twitter::' . $post->id;
1209 $datarray['title'] = '';
1211 if (!empty($post->retweeted_status)) {
1212 // We don't support nested shares, so we mustn't show quotes as shares on retweets
1213 $item = twitter_createpost($a, $uid, $post->retweeted_status, ['id' => 0], false, false, true, -1);
1219 $datarray['body'] = "\n" . BBCode::getShareOpeningTag(
1220 $item['author-name'],
1221 $item['author-link'],
1222 $item['author-avatar'],
1227 $datarray['body'] .= $item['body'] . '[/share]';
1229 $item = twitter_createpost($a, $uid, $post, ['id' => 0], false, false, false, -1);
1235 $datarray['body'] = $item['body'];
1238 $datarray['app'] = $item['app'];
1239 $datarray['verb'] = $item['verb'];
1241 if (isset($item['location'])) {
1242 $datarray['location'] = $item['location'];
1245 if (isset($item['coord'])) {
1246 $datarray['coord'] = $item['coord'];
1253 * Fetches the Twitter user's own posts
1260 function twitter_fetchtimeline(App $a, int $uid): void
1262 $ckey = DI::config()->get('twitter', 'consumerkey');
1263 $csecret = DI::config()->get('twitter', 'consumersecret');
1264 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1265 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1266 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastid');
1268 $application_name = DI::config()->get('twitter', 'application_name');
1270 if ($application_name == '') {
1271 $application_name = DI::baseUrl()->getHostname();
1274 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1276 // Ensure to have the own contact
1278 twitter_fetch_own_contact($a, $uid);
1279 } catch (TwitterOAuthException $e) {
1280 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
1285 'exclude_replies' => true,
1286 'trim_user' => false,
1287 'contributor_details' => true,
1288 'include_rts' => true,
1289 'tweet_mode' => 'extended',
1290 'include_ext_alt_text' => true,
1293 $first_time = ($lastid == '');
1295 if ($lastid != '') {
1296 $parameters['since_id'] = $lastid;
1300 $items = $connection->get('statuses/user_timeline', $parameters);
1301 } catch (TwitterOAuthException $e) {
1302 Logger::notice('Error fetching timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
1306 if (!is_array($items)) {
1307 Logger::notice('No items', ['user' => $uid]);
1311 $posts = array_reverse($items);
1313 Logger::notice('Start processing posts', ['from' => $lastid, 'user' => $uid, 'count' => count($posts)]);
1315 if (count($posts)) {
1316 foreach ($posts as $post) {
1317 if ($post->id_str > $lastid) {
1318 $lastid = $post->id_str;
1319 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1323 Logger::notice('First time, continue');
1327 if (stristr($post->source, $application_name)) {
1328 Logger::notice('Source is application name', ['source' => $post->source, 'application_name' => $application_name]);
1331 Logger::info('Preparing mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1333 $mirrorpost = twitter_do_mirrorpost($a, $uid, $post);
1335 if (empty($mirrorpost['body'])) {
1336 Logger::notice('Body is empty', ['post' => $post, 'mirrorpost' => $mirrorpost]);
1340 Logger::info('Posting mirror post', ['twitter-id' => $post->id_str, 'uid' => $uid]);
1342 Post\Delayed::add($mirrorpost['extid'], $mirrorpost, Worker::PRIORITY_MEDIUM, Post\Delayed::UNPREPARED);
1345 DI::pConfig()->set($uid, 'twitter', 'lastid', $lastid);
1346 Logger::info('Last ID for user ' . $uid . ' is now ' . $lastid);
1349 function twitter_fix_avatar($avatar)
1351 $new_avatar = str_replace('_normal.', '_400x400.', $avatar);
1353 $info = Images::getInfoFromURLCached($new_avatar);
1355 $new_avatar = $avatar;
1361 function twitter_get_relation($uid, $target, $contact = [])
1363 if (isset($contact['rel'])) {
1364 $relation = $contact['rel'];
1369 $ckey = DI::config()->get('twitter', 'consumerkey');
1370 $csecret = DI::config()->get('twitter', 'consumersecret');
1371 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
1372 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
1373 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1375 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
1376 $parameters = ['source_id' => $own_id, 'target_screen_name' => $target];
1379 $status = $connection->get('friendships/show', $parameters);
1380 if ($connection->getLastHttpCode() !== 200) {
1381 throw new Exception($status->errors[0]->message ?? 'HTTP response code ' . $connection->getLastHttpCode(), $status->errors[0]->code ?? $connection->getLastHttpCode());
1384 $following = $status->relationship->source->following;
1385 $followed = $status->relationship->source->followed_by;
1387 if ($following && !$followed) {
1388 $relation = Contact::SHARING;
1389 } elseif (!$following && $followed) {
1390 $relation = Contact::FOLLOWER;
1391 } elseif ($following && $followed) {
1392 $relation = Contact::FRIEND;
1393 } elseif (!$following && !$followed) {
1397 Logger::info('Fetched friendship relation', ['user' => $uid, 'target' => $target, 'relation' => $relation]);
1398 } catch (Throwable $e) {
1399 Logger::notice('Error fetching friendship status', ['uid' => $uid, 'target' => $target, 'message' => $e->getMessage()]);
1409 function twitter_user_to_contact($data)
1411 if (empty($data->id_str)) {
1415 $baseurl = 'https://twitter.com';
1416 $url = $baseurl . '/' . $data->screen_name;
1417 $addr = $data->screen_name . '@twitter.com';
1421 'nurl' => Strings::normaliseLink($url),
1422 'uri-id' => ItemURI::getIdByURI($url),
1423 'network' => Protocol::TWITTER,
1424 'alias' => 'twitter::' . $data->id_str,
1425 'baseurl' => $baseurl,
1426 'name' => $data->name,
1427 'nick' => $data->screen_name,
1429 'location' => $data->location,
1430 'about' => $data->description,
1431 'photo' => twitter_fix_avatar($data->profile_image_url_https),
1432 'header' => $data->profile_banner_url ?? $data->profile_background_image_url_https,
1438 function twitter_get_contact($data, int $uid = 0)
1440 $contact = DBA::selectFirst('contact', ['id'], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1441 if (DBA::isResult($contact)) {
1442 return $contact['id'];
1444 return twitter_fetch_contact($uid, $data, false);
1448 function twitter_fetch_contact($uid, $data, $create_user)
1450 $fields = twitter_user_to_contact($data);
1452 if (empty($fields)) {
1456 // photo comes from twitter_user_to_contact but shouldn't be saved directly in the contact row
1457 $avatar = $fields['photo'];
1458 unset($fields['photo']);
1460 // Update the public contact
1461 $pcontact = DBA::selectFirst('contact', ['id'], ['uid' => 0, 'alias' => 'twitter::' . $data->id_str]);
1462 if (DBA::isResult($pcontact)) {
1463 $cid = $pcontact['id'];
1465 $cid = Contact::getIdForURL($fields['url'], 0, false, $fields);
1469 Contact::update($fields, ['id' => $cid]);
1470 Contact::updateAvatar($cid, $avatar);
1472 Logger::notice('No contact found', ['fields' => $fields]);
1475 $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'alias' => 'twitter::' . $data->id_str]);
1476 if (!DBA::isResult($contact) && empty($cid)) {
1477 Logger::notice('User contact not found', ['uid' => $uid, 'twitter-id' => $data->id_str]);
1479 } elseif (!$create_user) {
1483 if (!DBA::isResult($contact)) {
1484 $relation = twitter_get_relation($uid, $data->screen_name);
1486 // create contact record
1487 $fields['uid'] = $uid;
1488 $fields['created'] = DateTimeFormat::utcNow();
1489 $fields['poll'] = 'twitter::' . $data->id_str;
1490 $fields['rel'] = $relation;
1491 $fields['priority'] = 1;
1492 $fields['writable'] = true;
1493 $fields['blocked'] = false;
1494 $fields['readonly'] = false;
1495 $fields['pending'] = false;
1497 if (!Contact::insert($fields)) {
1501 $contact_id = DBA::lastInsertId();
1503 Group::addMember(User::getDefaultGroup($uid), $contact_id);
1505 if ($contact['readonly'] || $contact['blocked']) {
1506 Logger::notice('Contact is blocked or readonly.', ['nickname' => $contact['nick']]);
1510 $contact_id = $contact['id'];
1513 // Update the contact relation once per day
1514 if ($contact['updated'] < DateTimeFormat::utc('now -24 hours')) {
1515 $fields['rel'] = twitter_get_relation($uid, $data->screen_name, $contact);
1519 if ($contact['name'] != $data->name) {
1520 $fields['name-date'] = $fields['uri-date'] = DateTimeFormat::utcNow();
1524 if ($contact['nick'] != $data->screen_name) {
1525 $fields['uri-date'] = DateTimeFormat::utcNow();
1529 if (($contact['location'] != $data->location) || ($contact['about'] != $data->description)) {
1534 $fields['updated'] = DateTimeFormat::utcNow();
1535 Contact::update($fields, ['id' => $contact['id']]);
1536 Logger::info('Updated contact', ['id' => $contact['id'], 'nick' => $data->screen_name]);
1540 Contact::updateAvatar($contact_id, $avatar);
1542 if (Contact::isSharing($contact_id, $uid, true) && DI::pConfig()->get($uid, 'twitter', 'auto_follow')) {
1543 twitter_auto_follow($uid, $data);
1550 * Follow a fediverse account that is proived in the name or the profile
1552 * @param integer $uid
1553 * @param object $data
1555 function twitter_auto_follow(int $uid, object $data)
1557 $addrpattern = '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6})';
1559 // Search for user@domain.tld in the name
1560 if (preg_match('#' . $addrpattern . '#', $data->name, $match)) {
1561 if (twitter_add_contact($match[1], true, $uid)) {
1566 // Search for @user@domain.tld in the description
1567 if (preg_match('#@' . $addrpattern . '#', $data->description, $match)) {
1568 if (twitter_add_contact($match[1], true, $uid)) {
1573 // Search for user@domain.tld in the description
1574 // We don't probe here, since this could be a mail address
1575 if (preg_match('#' . $addrpattern . '#', $data->description, $match)) {
1576 if (twitter_add_contact($match[1], false, $uid)) {
1581 // Search for profile links in the description
1582 foreach ($data->entities->description->urls as $url) {
1583 if (!empty($url->expanded_url)) {
1584 // We only probe on Mastodon style URL to reduce the number of unsuccessful probes
1585 twitter_add_contact($url->expanded_url, strpos($url->expanded_url, '@'), $uid);
1591 * Check if the provided address is a fediverse account and adds it
1593 * @param string $addr
1594 * @param boolean $probe
1595 * @param integer $uid
1598 function twitter_add_contact(string $addr, bool $probe, int $uid): bool
1600 $contact = Contact::getByURL($addr, $probe ? null : false, ['id', 'url', 'network']);
1601 if (empty($contact)) {
1602 Logger::debug('Not a contact address', ['uid' => $uid, 'probe' => $probe, 'addr' => $addr]);
1606 if (!in_array($contact['network'], Protocol::FEDERATED)) {
1607 Logger::debug('Not a federated network', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1611 if (Contact::isSharing($contact['id'], $uid)) {
1612 Logger::debug('Contact has already been added', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1616 Logger::info('Add contact', ['uid' => $uid, 'addr' => $addr, 'contact' => $contact]);
1617 Worker::add(Worker::PRIORITY_LOW, 'AddContact', $uid, $contact['url']);
1623 * @param string $screen_name
1624 * @return stdClass|null
1627 function twitter_fetchuser($screen_name)
1629 $ckey = DI::config()->get('twitter', 'consumerkey');
1630 $csecret = DI::config()->get('twitter', 'consumersecret');
1633 // Fetching user data
1634 $connection = new TwitterOAuth($ckey, $csecret);
1635 $parameters = ['screen_name' => $screen_name];
1636 $user = $connection->get('users/show', $parameters);
1637 } catch (TwitterOAuthException $e) {
1638 Logger::notice('Error fetching user', ['user' => $screen_name, 'message' => $e->getMessage()]);
1642 if (!is_object($user)) {
1650 * Replaces Twitter entities with Friendica-friendly links.
1652 * The Twitter API gives indices for each entity, which allows for fine-grained replacement.
1654 * First, we need to collect everything that needs to be replaced, what we will replace it with, and the start index.
1655 * Then we sort the indices decreasingly, and we replace from the end of the body to the start in order for the next
1656 * index to be correct even after the last replacement.
1658 * @param string $body
1659 * @param stdClass $status
1661 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1663 function twitter_expand_entities($body, stdClass $status)
1666 $contains_urls = false;
1670 $replacementList = [];
1672 foreach ($status->entities->hashtags AS $hashtag) {
1673 $replace = '#[url=' . DI::baseUrl()->get() . '/search?tag=' . $hashtag->text . ']' . $hashtag->text . '[/url]';
1674 $taglist['#' . $hashtag->text] = ['#', $hashtag->text, ''];
1676 $replacementList[$hashtag->indices[0]] = [
1677 'replace' => $replace,
1678 'length' => $hashtag->indices[1] - $hashtag->indices[0],
1682 foreach ($status->entities->user_mentions AS $mention) {
1683 $replace = '@[url=https://twitter.com/' . rawurlencode($mention->screen_name) . ']' . $mention->screen_name . '[/url]';
1684 $taglist['@' . $mention->screen_name] = ['@', $mention->screen_name, 'https://twitter.com/' . rawurlencode($mention->screen_name)];
1686 $replacementList[$mention->indices[0]] = [
1687 'replace' => $replace,
1688 'length' => $mention->indices[1] - $mention->indices[0],
1692 foreach ($status->entities->urls ?? [] as $url) {
1693 $plain = str_replace($url->url, '', $plain);
1695 if ($url->url && $url->expanded_url && $url->display_url) {
1696 // Quote tweet, we just remove the quoted tweet URL from the body, the share block will be added later.
1697 if (!empty($status->quoted_status) && isset($status->quoted_status_id_str)
1698 && substr($url->expanded_url, -strlen($status->quoted_status_id_str)) == $status->quoted_status_id_str
1700 $replacementList[$url->indices[0]] = [
1702 'length' => $url->indices[1] - $url->indices[0],
1707 $contains_urls = true;
1709 $expanded_url = $url->expanded_url;
1711 // Quickfix: Workaround for URL with '[' and ']' in it
1712 if (strpos($expanded_url, '[') || strpos($expanded_url, ']')) {
1713 $expanded_url = $url->url;
1716 $replacementList[$url->indices[0]] = [
1717 'replace' => '[url=' . $expanded_url . ']' . $url->display_url . '[/url]',
1718 'length' => $url->indices[1] - $url->indices[0],
1723 krsort($replacementList);
1725 foreach ($replacementList as $startIndex => $parameters) {
1726 $body = Strings::substringReplace($body, $parameters['replace'], $startIndex, $parameters['length']);
1729 $body = trim($body);
1731 return ['body' => trim($body), 'plain' => trim($plain), 'taglist' => $taglist, 'urls' => $contains_urls];
1735 * Store entity attachments
1737 * @param integer $uriId
1738 * @param object $post Twitter object with the post
1740 function twitter_store_attachments(int $uriId, $post)
1742 if (!empty($post->extended_entities->media)) {
1743 foreach ($post->extended_entities->media AS $medium) {
1744 switch ($medium->type) {
1746 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::IMAGE];
1748 $attachment['url'] = $medium->media_url_https . '?name=large';
1749 $attachment['width'] = $medium->sizes->large->w;
1750 $attachment['height'] = $medium->sizes->large->h;
1752 if ($medium->sizes->small->w != $attachment['width']) {
1753 $attachment['preview'] = $medium->media_url_https . '?name=small';
1754 $attachment['preview-width'] = $medium->sizes->small->w;
1755 $attachment['preview-height'] = $medium->sizes->small->h;
1758 $attachment['name'] = $medium->display_url ?? null;
1759 $attachment['description'] = $medium->ext_alt_text ?? null;
1760 Logger::debug('Photo attachment', ['attachment' => $attachment]);
1761 Post\Media::insert($attachment);
1764 case 'animated_gif':
1765 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::VIDEO];
1766 if (is_array($medium->video_info->variants)) {
1768 // We take the video with the highest bitrate
1769 foreach ($medium->video_info->variants AS $variant) {
1770 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1771 $attachment['url'] = $variant->url;
1772 $bitrate = $variant->bitrate;
1777 $attachment['name'] = $medium->display_url ?? null;
1778 $attachment['preview'] = $medium->media_url_https . ':small';
1779 $attachment['preview-width'] = $medium->sizes->small->w;
1780 $attachment['preview-height'] = $medium->sizes->small->h;
1781 $attachment['description'] = $medium->ext_alt_text ?? null;
1782 Logger::debug('Video attachment', ['attachment' => $attachment]);
1783 Post\Media::insert($attachment);
1786 Logger::notice('Unknown media type', ['medium' => $medium]);
1791 if (!empty($post->entities->urls)) {
1792 foreach ($post->entities->urls as $url) {
1793 $attachment = ['uri-id' => $uriId, 'type' => Post\Media::UNKNOWN, 'url' => $url->expanded_url, 'name' => $url->display_url];
1794 Logger::debug('Attached link', ['attachment' => $attachment]);
1795 Post\Media::insert($attachment);
1801 * @brief Fetch media entities and add media links to the body
1803 * @param object $post Twitter object with the post
1804 * @param array $postarray Array of the item that is about to be posted
1805 * @param integer $uriId URI Id used to store tags. -1 = don't store tags for this post.
1807 function twitter_media_entities($post, array &$postarray, int $uriId = -1)
1809 // There are no media entities? So we quit.
1810 if (empty($post->extended_entities->media)) {
1814 // This is a pure media post, first search for all media urls
1816 foreach ($post->extended_entities->media AS $medium) {
1817 if (!isset($media[$medium->url])) {
1818 $media[$medium->url] = '';
1820 switch ($medium->type) {
1822 if (!empty($medium->ext_alt_text)) {
1823 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1824 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1826 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1829 $postarray['object-type'] = Activity\ObjectType::IMAGE;
1830 $postarray['post-type'] = Item::PT_IMAGE;
1833 // Currently deactivated, since this causes the video to be display before the content
1834 // We have to figure out a better way for declaring the post type and the display style.
1835 //$postarray['post-type'] = Item::PT_VIDEO;
1836 case 'animated_gif':
1837 if (!empty($medium->ext_alt_text)) {
1838 Logger::info('Got text description', ['alt_text' => $medium->ext_alt_text]);
1839 $media[$medium->url] .= "\n[img=" . $medium->media_url_https .']' . $medium->ext_alt_text . '[/img]';
1841 $media[$medium->url] .= "\n[img]" . $medium->media_url_https . '[/img]';
1844 $postarray['object-type'] = Activity\ObjectType::VIDEO;
1845 if (is_array($medium->video_info->variants)) {
1847 // We take the video with the highest bitrate
1848 foreach ($medium->video_info->variants AS $variant) {
1849 if (($variant->content_type == 'video/mp4') && ($variant->bitrate >= $bitrate)) {
1850 $media[$medium->url] = "\n[video]" . $variant->url . '[/video]';
1851 $bitrate = $variant->bitrate;
1860 foreach ($media AS $key => $value) {
1861 $postarray['body'] = str_replace($key, '', $postarray['body']);
1866 // Now we replace the media urls.
1867 foreach ($media AS $key => $value) {
1868 $postarray['body'] = str_replace($key, "\n" . $value . "\n", $postarray['body']);
1873 * Undocumented function
1876 * @param integer $uid User ID
1877 * @param object $post Incoming Twitter post
1878 * @param array $self
1879 * @param bool $create_user Should users be created?
1880 * @param bool $only_existing_contact Only import existing contacts if set to "true"
1881 * @param bool $noquote
1882 * @param integer $uriId URI Id used to store tags. 0 = create a new one; -1 = don't store tags for this post.
1883 * @return array item array
1885 function twitter_createpost(App $a, int $uid, $post, array $self, $create_user, bool $only_existing_contact, bool $noquote, int $uriId = 0): array
1888 $postarray['network'] = Protocol::TWITTER;
1889 $postarray['uid'] = $uid;
1890 $postarray['wall'] = 0;
1891 $postarray['uri'] = 'twitter::' . $post->id_str;
1892 $postarray['protocol'] = Conversation::PARCEL_TWITTER;
1893 $postarray['source'] = json_encode($post);
1894 $postarray['direction'] = Conversation::PULL;
1896 if (empty($uriId)) {
1897 $uriId = $postarray['uri-id'] = ItemURI::insert(['uri' => $postarray['uri']]);
1900 // Don't import our own comments
1901 if (Post::exists(['extid' => $postarray['uri'], 'uid' => $uid])) {
1902 Logger::info('Item found', ['extid' => $postarray['uri']]);
1908 if ($post->in_reply_to_status_id_str != '') {
1909 $thr_parent = 'twitter::' . $post->in_reply_to_status_id_str;
1911 $item = Post::selectFirst(['uri'], ['uri' => $thr_parent, 'uid' => $uid]);
1912 if (!DBA::isResult($item)) {
1913 $item = Post::selectFirst(['uri'], ['extid' => $thr_parent, 'uid' => $uid, 'gravity' => Item::GRAVITY_COMMENT]);
1916 if (DBA::isResult($item)) {
1917 $postarray['thr-parent'] = $item['uri'];
1918 $postarray['object-type'] = Activity\ObjectType::COMMENT;
1920 $postarray['object-type'] = Activity\ObjectType::NOTE;
1924 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
1926 if ($post->user->id_str == $own_id) {
1927 $self = Contact::selectFirst(['id', 'name', 'url', 'photo'], ['self' => true, 'uid' => $uid]);
1928 if (DBA::isResult($self)) {
1929 $contactid = $self['id'];
1931 $postarray['owner-id'] = Contact::getIdForURL($self['url']);
1932 $postarray['owner-name'] = $self['name'];
1933 $postarray['owner-link'] = $self['url'];
1934 $postarray['owner-avatar'] = $self['photo'];
1936 Logger::error('No self contact found', ['uid' => $uid]);
1940 // Don't create accounts of people who just comment something
1941 $create_user = false;
1943 $postarray['object-type'] = Activity\ObjectType::NOTE;
1946 if ($contactid == 0) {
1947 $contactid = twitter_fetch_contact($uid, $post->user, $create_user);
1949 $postarray['owner-id'] = twitter_get_contact($post->user);
1950 $postarray['owner-name'] = $post->user->name;
1951 $postarray['owner-link'] = 'https://twitter.com/' . $post->user->screen_name;
1952 $postarray['owner-avatar'] = twitter_fix_avatar($post->user->profile_image_url_https);
1955 if (($contactid == 0) && !$only_existing_contact) {
1956 $contactid = $self['id'];
1957 } elseif ($contactid <= 0) {
1958 Logger::info('Contact ID is zero or less than zero.');
1962 $postarray['contact-id'] = $contactid;
1963 $postarray['verb'] = Activity::POST;
1964 $postarray['author-id'] = $postarray['owner-id'];
1965 $postarray['author-name'] = $postarray['owner-name'];
1966 $postarray['author-link'] = $postarray['owner-link'];
1967 $postarray['author-avatar'] = $postarray['owner-avatar'];
1968 $postarray['plink'] = 'https://twitter.com/' . $post->user->screen_name . '/status/' . $post->id_str;
1969 $postarray['app'] = strip_tags($post->source);
1971 if ($post->user->protected) {
1972 $postarray['private'] = Item::PRIVATE;
1973 $postarray['allow_cid'] = '<' . $self['id'] . '>';
1975 $postarray['private'] = Item::UNLISTED;
1976 $postarray['allow_cid'] = '';
1979 if (!empty($post->full_text)) {
1980 $postarray['body'] = $post->full_text;
1982 $postarray['body'] = $post->text;
1985 // When the post contains links then use the correct object type
1986 if (count($post->entities->urls) > 0) {
1987 $postarray['object-type'] = Activity\ObjectType::BOOKMARK;
1990 // Search for media links
1991 twitter_media_entities($post, $postarray, $uriId);
1993 $converted = twitter_expand_entities($postarray['body'], $post);
1995 // When the post contains external links then images or videos are just "decorations".
1996 if (!empty($converted['urls'])) {
1997 $postarray['post-type'] = Item::PT_NOTE;
2000 $postarray['body'] = $converted['body'];
2001 $postarray['created'] = DateTimeFormat::utc($post->created_at);
2002 $postarray['edited'] = DateTimeFormat::utc($post->created_at);
2005 twitter_store_tags($uriId, $converted['taglist']);
2006 twitter_store_attachments($uriId, $post);
2009 if (!empty($post->place->name)) {
2010 $postarray['location'] = $post->place->name;
2012 if (!empty($post->place->full_name)) {
2013 $postarray['location'] = $post->place->full_name;
2015 if (!empty($post->geo->coordinates)) {
2016 $postarray['coord'] = $post->geo->coordinates[0] . ' ' . $post->geo->coordinates[1];
2018 if (!empty($post->coordinates->coordinates)) {
2019 $postarray['coord'] = $post->coordinates->coordinates[1] . ' ' . $post->coordinates->coordinates[0];
2021 if (!empty($post->retweeted_status)) {
2022 $retweet = twitter_createpost($a, $uid, $post->retweeted_status, $self, false, false, $noquote);
2024 if (empty($retweet)) {
2029 // Store the original tweet
2030 Item::insert($retweet);
2032 // CHange the other post into a reshare activity
2033 $postarray['verb'] = Activity::ANNOUNCE;
2034 $postarray['gravity'] = Item::GRAVITY_ACTIVITY;
2035 $postarray['object-type'] = Activity\ObjectType::NOTE;
2037 $postarray['thr-parent'] = $retweet['uri'];
2039 $retweet['source'] = $postarray['source'];
2040 $retweet['direction'] = $postarray['direction'];
2041 $retweet['private'] = $postarray['private'];
2042 $retweet['allow_cid'] = $postarray['allow_cid'];
2043 $retweet['contact-id'] = $postarray['contact-id'];
2044 $retweet['owner-id'] = $postarray['owner-id'];
2045 $retweet['owner-name'] = $postarray['owner-name'];
2046 $retweet['owner-link'] = $postarray['owner-link'];
2047 $retweet['owner-avatar'] = $postarray['owner-avatar'];
2049 $postarray = $retweet;
2053 if (!empty($post->quoted_status)) {
2055 // To avoid recursive share blocks we just provide the link to avoid removing quote context.
2056 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . "/status/" . $post->quoted_status->id_str;
2058 $quoted = twitter_createpost($a, 0, $post->quoted_status, $self, false, false, true);
2059 if (!empty($quoted)) {
2060 Item::insert($quoted);
2061 $post = Post::selectFirst(['guid', 'uri-id'], ['uri' => $quoted['uri'], 'uid' => 0]);
2062 Logger::info('Stored quoted post', ['uid' => $uid, 'uri-id' => $uriId, 'post' => $post]);
2064 $postarray['body'] .= "\n" . BBCode::getShareOpeningTag(
2065 $quoted['author-name'],
2066 $quoted['author-link'],
2067 $quoted['author-avatar'],
2073 $postarray['body'] .= $quoted['body'] . '[/share]';
2075 // Quoted post author is blocked/ignored, so we just provide the link to avoid removing quote context.
2076 $postarray['body'] .= "\n\nhttps://twitter.com/" . $post->quoted_status->user->screen_name . '/status/' . $post->quoted_status->id_str;
2085 * Store tags and mentions
2087 * @param integer $uriId
2088 * @param array $taglist
2091 function twitter_store_tags(int $uriId, array $taglist)
2093 foreach ($taglist as $tag) {
2094 Tag::storeByHash($uriId, $tag[0], $tag[1], $tag[2]);
2098 function twitter_fetchparentposts(App $a, int $uid, $post, TwitterOAuth $connection, array $self)
2100 Logger::info('Fetching parent posts', ['user' => $uid, 'post' => $post->id_str]);
2104 while (!empty($post->in_reply_to_status_id_str)) {
2106 $post = twitter_statuses_show($post->in_reply_to_status_id_str, $connection);
2107 } catch (TwitterOAuthException $e) {
2108 Logger::notice('Error fetching parent post', ['uid' => $uid, 'post' => $post->id_str, 'message' => $e->getMessage()]);
2113 Logger::info("twitter_fetchparentposts: Can't fetch post");
2117 if (empty($post->id_str)) {
2118 Logger::info('twitter_fetchparentposts: This is not a post', ['post' => $post]);
2122 if (Post::exists(['uri' => 'twitter::' . $post->id_str, 'uid' => $uid])) {
2129 Logger::info('twitter_fetchparentposts: Fetching ' . count($posts) . ' parents');
2131 $posts = array_reverse($posts);
2133 if (!empty($posts)) {
2134 foreach ($posts as $post) {
2135 $postarray = twitter_createpost($a, $uid, $post, $self, false, !DI::pConfig()->get($uid, 'twitter', 'create_user'), false);
2137 if (empty($postarray)) {
2141 $item = Item::insert($postarray);
2143 $postarray['id'] = $item;
2145 Logger::notice('twitter_fetchparentpost: User ' . $self['nick'] . ' posted parent timeline item ' . $item);
2151 * Fetches the posts received by the Twitter user
2158 function twitter_fetchhometimeline(App $a, int $uid): void
2160 $ckey = DI::config()->get('twitter', 'consumerkey');
2161 $csecret = DI::config()->get('twitter', 'consumersecret');
2162 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2163 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2164 $create_user = DI::pConfig()->get($uid, 'twitter', 'create_user');
2165 $mirror_posts = DI::pConfig()->get($uid, 'twitter', 'mirror_posts');
2167 Logger::info('Fetching timeline', ['uid' => $uid]);
2169 $application_name = DI::config()->get('twitter', 'application_name');
2171 if ($application_name == '') {
2172 $application_name = DI::baseUrl()->getHostname();
2175 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2178 $own_contact = twitter_fetch_own_contact($a, $uid);
2179 } catch (TwitterOAuthException $e) {
2180 Logger::notice('Error fetching own contact', ['uid' => $uid, 'message' => $e->getMessage()]);
2184 $contact = Contact::selectFirst(['nick'], ['id' => $own_contact, 'uid' => $uid]);
2185 if (DBA::isResult($contact)) {
2186 $own_id = $contact['nick'];
2188 Logger::notice('Own twitter contact not found', ['uid' => $uid]);
2192 $self = User::getOwnerDataById($uid);
2193 if ($self === false) {
2194 Logger::warning('Own contact not found', ['uid' => $uid]);
2199 'exclude_replies' => false,
2200 'trim_user' => false,
2201 'contributor_details' => true,
2202 'include_rts' => true,
2203 'tweet_mode' => 'extended',
2204 'include_ext_alt_text' => true,
2208 // Fetching timeline
2209 $lastid = DI::pConfig()->get($uid, 'twitter', 'lasthometimelineid');
2211 $first_time = ($lastid == '');
2213 if ($lastid != '') {
2214 $parameters['since_id'] = $lastid;
2218 $items = $connection->get('statuses/home_timeline', $parameters);
2219 } catch (TwitterOAuthException $e) {
2220 Logger::notice('Error fetching home timeline', ['uid' => $uid, 'message' => $e->getMessage()]);
2224 if (!is_array($items)) {
2225 Logger::notice('home timeline is no array', ['items' => $items]);
2229 if (empty($items)) {
2230 Logger::info('No new timeline content', ['uid' => $uid]);
2234 $posts = array_reverse($items);
2236 Logger::notice('Processing timeline', ['lastid' => $lastid, 'uid' => $uid, 'count' => count($posts)]);
2238 if (count($posts)) {
2239 foreach ($posts as $post) {
2240 if ($post->id_str > $lastid) {
2241 $lastid = $post->id_str;
2242 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2249 if (stristr($post->source, $application_name) && $post->user->screen_name == $own_id) {
2250 Logger::info('Skip previously sent post');
2254 if ($mirror_posts && $post->user->screen_name == $own_id && $post->in_reply_to_status_id_str == '') {
2255 Logger::info('Skip post that will be mirrored');
2259 if ($post->in_reply_to_status_id_str != '') {
2260 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2263 Logger::info('Preparing post ' . $post->id_str . ' for user ' . $uid);
2265 $postarray = twitter_createpost($a, $uid, $post, $self, $create_user, true, false);
2267 if (empty($postarray)) {
2268 Logger::info('Empty post ' . $post->id_str . ' and user ' . $uid);
2274 if (empty($postarray['thr-parent'])) {
2275 $contact = DBA::selectFirst('contact', [], ['id' => $postarray['contact-id'], 'self' => false]);
2276 if (DBA::isResult($contact) && Item::isRemoteSelf($contact, $postarray)) {
2277 $notify = Worker::PRIORITY_MEDIUM;
2281 $item = Item::insert($postarray, $notify);
2282 $postarray['id'] = $item;
2284 Logger::notice('User ' . $uid . ' posted home timeline item ' . $item);
2287 DI::pConfig()->set($uid, 'twitter', 'lasthometimelineid', $lastid);
2289 Logger::info('Last timeline ID for user ' . $uid . ' is now ' . $lastid);
2291 // Fetching mentions
2292 $lastid = DI::pConfig()->get($uid, 'twitter', 'lastmentionid');
2294 $first_time = ($lastid == '');
2296 if ($lastid != '') {
2297 $parameters['since_id'] = $lastid;
2301 $items = $connection->get('statuses/mentions_timeline', $parameters);
2302 } catch (TwitterOAuthException $e) {
2303 Logger::notice('Error fetching mentions', ['uid' => $uid, 'message' => $e->getMessage()]);
2307 if (!is_array($items)) {
2308 Logger::notice('mentions are no arrays', ['items' => $items]);
2312 $posts = array_reverse($items);
2314 Logger::info('Fetching mentions for user ' . $uid . ' ' . sizeof($posts) . ' items');
2316 if (count($posts)) {
2317 foreach ($posts as $post) {
2318 if ($post->id_str > $lastid) {
2319 $lastid = $post->id_str;
2326 if ($post->in_reply_to_status_id_str != '') {
2327 twitter_fetchparentposts($a, $uid, $post, $connection, $self);
2330 $postarray = twitter_createpost($a, $uid, $post, $self, false, !$create_user, false);
2332 if (empty($postarray)) {
2336 $item = Item::insert($postarray);
2338 Logger::notice('User ' . $uid . ' posted mention timeline item ' . $item);
2342 DI::pConfig()->set($uid, 'twitter', 'lastmentionid', $lastid);
2344 Logger::info('Last mentions ID for user ' . $uid . ' is now ' . $lastid);
2347 function twitter_fetch_own_contact(App $a, int $uid)
2349 $ckey = DI::config()->get('twitter', 'consumerkey');
2350 $csecret = DI::config()->get('twitter', 'consumersecret');
2351 $otoken = DI::pConfig()->get($uid, 'twitter', 'oauthtoken');
2352 $osecret = DI::pConfig()->get($uid, 'twitter', 'oauthsecret');
2354 $own_id = DI::pConfig()->get($uid, 'twitter', 'own_id');
2358 if ($own_id == '') {
2359 $connection = new TwitterOAuth($ckey, $csecret, $otoken, $osecret);
2361 // Fetching user data
2362 // get() may throw TwitterOAuthException, but we will catch it later
2363 $user = $connection->get('account/verify_credentials');
2364 if (empty($user->id_str)) {
2368 DI::pConfig()->set($uid, 'twitter', 'own_id', $user->id_str);
2370 $contact_id = twitter_fetch_contact($uid, $user, true);
2372 $contact = Contact::selectFirst(['id'], ['uid' => $uid, 'alias' => 'twitter::' . $own_id]);
2373 if (DBA::isResult($contact)) {
2374 $contact_id = $contact['id'];
2376 DI::pConfig()->delete($uid, 'twitter', 'own_id');
2383 function twitter_is_retweet(App $a, int $uid, string $body): bool
2385 $body = trim($body);
2387 // Skip if it isn't a pure repeated messages
2388 // Does it start with a share?
2389 if (strpos($body, '[share') > 0) {
2393 // Does it end with a share?
2394 if (strlen($body) > (strrpos($body, '[/share]') + 8)) {
2398 $attributes = preg_replace("/\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "$1", $body);
2399 // Skip if there is no shared message in there
2400 if ($body == $attributes) {
2405 preg_match("/link='(.*?)'/ism", $attributes, $matches);
2406 if (!empty($matches[1])) {
2407 $link = $matches[1];
2410 preg_match('/link="(.*?)"/ism', $attributes, $matches);
2411 if (!empty($matches[1])) {
2412 $link = $matches[1];
2415 $id = preg_replace("=https?://twitter.com/(.*)/status/(.*)=ism", "$2", $link);
2419 return twitter_retweet($uid, $id);
2422 function twitter_retweet(int $uid, int $id, int $item_id = 0): bool
2424 Logger::info('Retweeting', ['user' => $uid, 'id' => $id]);
2426 $result = twitter_api_post('statuses/retweet', $id, $uid);
2428 Logger::info('Retweeted', ['user' => $uid, 'id' => $id, 'result' => $result]);
2430 if (!empty($item_id) && !empty($result->id_str)) {
2431 Logger::notice('Update extid', ['id' => $item_id, 'extid' => $result->id_str]);
2432 Item::update(['extid' => 'twitter::' . $result->id_str], ['id' => $item_id]);
2435 return !isset($result->errors);
2438 function twitter_update_mentions(string $body): string
2440 $URLSearchString = '^\[\]';
2441 $return = preg_replace_callback(
2442 "/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
2443 function ($matches) {
2444 if (strpos($matches[1], 'twitter.com')) {
2445 $return = '@' . substr($matches[1], strrpos($matches[1], '/') + 1);
2447 $return = $matches[2] . ' (' . $matches[1] . ')';
2458 function twitter_convert_share(array $attributes, array $author_contact, string $content, bool $is_quote_share): string
2460 if (empty($author_contact)) {
2461 return $content . "\n\n" . $attributes['link'];
2464 if (!empty($author_contact['network']) && ($author_contact['network'] == Protocol::TWITTER)) {
2465 $mention = '@' . $author_contact['nick'];
2467 $mention = $author_contact['addr'];
2470 return ($is_quote_share ? "\n\n" : '' ) . 'RT ' . $mention . ': ' . $content . "\n\n" . $attributes['link'];