From 780581939a883b80b3f6db01ac43e09b83e0e795 Mon Sep 17 00:00:00 2001 From: quickwick <2566133+quickwick@users.noreply.github.com> Date: Mon, 4 Apr 2022 12:13:05 -0700 Subject: [PATCH] [TwitterV2Bridge] New Bridge for Twitter v2 API (#2471) * New Bridge for Twitter using v2 API * Top comment block, tweaks to match contributing guide * [TwitterV2Bridge] new Bridge (sort of) * Discovered the point of, and re-added, no image scaling option * Fix the phpcs sniff violations (I hope) * More linter fixes, I figured out how to use phpcs locally * Removed unnecessary custom version of getContents function * Limit query to 100 tweets, valid example query, improved error handling * Added config doc (correctly, I hope) with link from DESCRIPTION * little tweak to doc --- bridges/TwitterV2Bridge.php | 573 +++++++++++++++++++++++++++ docs/10_Bridge_Specific/TwitterV2.md | 37 ++ 2 files changed, 610 insertions(+) create mode 100644 bridges/TwitterV2Bridge.php create mode 100644 docs/10_Bridge_Specific/TwitterV2.md diff --git a/bridges/TwitterV2Bridge.php b/bridges/TwitterV2Bridge.php new file mode 100644 index 00000000..5e70871f --- /dev/null +++ b/bridges/TwitterV2Bridge.php @@ -0,0 +1,573 @@ + + Configuration Instructions.'; + const MAINTAINER = 'quickwick'; + const CONFIGURATION = array( + 'twitterv2apitoken' => array( + 'required' => true, + ) + ); + const PARAMETERS = array( + 'global' => array( + 'maxresults' => array( + 'name' => 'Maximum results', + 'required' => false, + 'exampleValue' => '20', + 'title' => 'Maximum number of tweets to retrieve (limit is 100)' + ), + 'nopic' => array( + 'name' => 'Hide profile pictures', + 'type' => 'checkbox', + 'title' => 'Activate to hide profile pictures in content' + ), + 'noimg' => array( + 'name' => 'Hide images in tweets', + 'type' => 'checkbox', + 'title' => 'Activate to hide images in tweets' + ), + 'noimgscaling' => array( + 'name' => 'Disable image scaling', + 'type' => 'checkbox', + 'title' => 'Activate to disable image scaling in tweets (keeps original image)' + ) + ), + 'By username' => array( + 'u' => array( + 'name' => 'username', + 'required' => true, + 'exampleValue' => 'sebsauvage', + 'title' => 'Insert a user name' + ), + 'norep' => array( + 'name' => 'Without replies', + 'type' => 'checkbox', + 'title' => 'Only return initial tweets' + ), + 'noretweet' => array( + 'name' => 'Without retweets', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Hide retweets' + ), + 'nopinned' => array( + 'name' => 'Without pinned tweet', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Hide pinned tweet' + ) + ), + 'By keyword or hashtag' => array( + 'query' => array( + 'name' => 'Keyword or #hashtag', + 'required' => true, + 'exampleValue' => 'rss-bridge OR #rss-bridge', + 'title' => << array( + 'listid' => array( + 'name' => 'List ID', + 'exampleValue' => '31748', + 'required' => true, + 'title' => 'Insert the list id' + ), + 'filter' => array( + 'name' => 'Filter', + 'exampleValue' => 'rss-bridge', + 'required' => false, + 'title' => 'Specify a single term to search for' + ) + ) + ); + + private $apiToken = null; + private $authHeaders = array(); + + public function getName() { + switch($this->queriedContext) { + case 'By keyword or hashtag': + $specific = 'search '; + $param = 'query'; + break; + case 'By username': + $specific = '@'; + $param = 'u'; + break; + case 'By list ID': + return 'Twitter List #' . $this->getInput('listid'); + default: + return parent::getName(); + } + return 'Twitter ' . $specific . $this->getInput($param); + } + + public function collectData() { + // $data will contain an array of all found tweets + $data = null; + // Contains user data (when in by username context) + $user = null; + // Array of all found tweets + $tweets = array(); + + $hideProfilePic = $this->getInput('nopic'); + $hideImages = $this->getInput('noimg'); + $hideReplies = $this->getInput('norep'); + $hideRetweets = $this->getInput('noretweet'); + $hidePinned = $this->getInput('nopinned'); + $maxResults = $this->getInput('maxresults'); + if ($maxResults > 100) { + $maxResults = 100; + } + + // Read API token from config.ini.php, put into Header + $this->apiToken = $this->getOption('twitterv2apitoken'); + $this->authHeaders = array( + 'authorization: Bearer ' . $this->apiToken, + ); + + // Try to get all tweets + switch($this->queriedContext) { + case 'By username': + //Get id from username + $params = array( + 'user.fields' => 'pinned_tweet_id,profile_image_url' + ); + $user = $this->makeApiCall('/users/by/username/' + . $this->getInput('u'), $params); + + //Debug::log('User JSON: ' . json_encode($user)); + if(isset($user->errors)) { + Debug::log('User JSON: ' . json_encode($user)); + returnServerError('Requested username can\'t be found.'); + } + + // Set default params + $params = array( + 'max_results' => (empty($maxResults) ? '10' : $maxResults ), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'user.fields' => 'pinned_tweet_id', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ); + + // Set params to filter out replies and/or retweets + if($hideReplies && $hideRetweets) { + $params['exclude'] = 'replies,retweets'; + } elseif($hideReplies) { + $params['exclude'] = 'replies'; + } elseif($hideRetweets) { + $params['exclude'] = 'retweets'; + } + + // Get the tweets + $data = $this->makeApiCall('/users/' . $user->data->id + . '/tweets', $params); + break; + + case 'By keyword or hashtag': + $params = array( + 'query' => $this->getInput('query'), + 'max_results' => (empty($maxResults) ? '10' : $maxResults ), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ); + + $data = $this->makeApiCall('/tweets/search/recent', $params); + break; + + case 'By list ID': + // Set default params + $params = array( + 'max_results' => (empty($maxResults) ? '10' : $maxResults ), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ); + + $data = $this->makeApiCall('/lists/' . $this->getInput('listid') . + '/tweets', $params); + break; + + default: + returnServerError('Invalid query context !'); + } + + //Debug::log('Data JSON: ' . json_encode($data)); + if((isset($data->errors) && !isset($data->data)) || + (isset($data->meta) && $data->meta->result_count === 0)) { + Debug::log('Data JSON: ' . json_encode($data)); + switch($this->queriedContext) { + case 'By keyword or hashtag': + returnServerError('No results for this query.'); + case 'By username': + returnServerError('Requested username cannnot be found.'); + case 'By list ID': + returnServerError('Requested list cannnot be found'); + } + } + + // figure out the Pinned Tweet Id + if($hidePinned) { + $pinnedTweetId = null; + if(isset($user) && isset($user->data->pinned_tweet_id)) { + $pinnedTweetId = $user->data->pinned_tweet_id; + } + } + + // Extract Media data into array + isset($data->includes->media) ? $includesMedia = $data->includes->media : $includesMedia = null; + + // Extract additional Users data into array + isset($data->includes->users) ? $includesUsers = $data->includes->users : $includesUsers = null; + //Debug::log('Tweets Users JSON: ' . json_encode($includesUsers)); + + // Extract additional Tweets data into array + isset($data->includes->tweets) ? $includesTweets = $data->includes->tweets : $includesTweets = null; + //Debug::log('Includes Tweets JSON: ' . json_encode($includesTweets)); + + // Extract main Tweets data into array + $tweets = $data->data; + //Debug::log('Tweets JSON: ' . json_encode($tweets)); + + // Make another API call to get user and media info for retweets + // Is there some way to get this info included in original API call? + $retweetedData = null; + $retweetedMedia = null; + $retweetedUsers = null; + if(!$hideImages && !$hideRetweets && isset($includesTweets)) { + // There has to be a better PHP way to extract the tweet Ids? + $includesTweetsIds = array(); + foreach($includesTweets as $includesTweet) { + $includesTweetsIds[] = $includesTweet->id; + } + //Debug::log('includesTweetsIds: ' . join(',', $includesTweetsIds)); + + // Set default params for API query + $params = array( + 'ids' => join(',', $includesTweetsIds), + 'tweet.fields' => 'entities,attachments', + 'expansions' => 'author_id,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url', + 'user.fields' => 'id,profile_image_url' + ); + + // Get the retweeted tweets + $retweetedData = $this->makeApiCall('/tweets', $params); + //Debug::log('retweetedData JSON: ' . json_encode($retweetedData)); + + // Extract retweets Media data into array + isset($retweetedData->includes->media) ? $retweetedMedia + = $retweetedData->includes->media : $retweetedMedia = null; + + // Extract retweets additional Users data into array + isset($retweetedData->includes->users) ? $retweetedUsers + = $retweetedData->includes->users : $retweetedUsers = null; + } + + // Create output array with all required elements for each tweet + foreach($tweets as $tweet) { + //Debug::log('Tweet JSON: ' . json_encode($tweet)); + + // Skip pinned tweet + if($hidePinned && $tweet->id === $pinnedTweetId) { + continue; + } + + // Check if Retweet + $isRetweet = false; + if(isset($tweet->referenced_tweets)) { + if($tweet->referenced_tweets[0]->type === 'retweeted') { + $isRetweet = true; + } + } + + // Initialize empty array to hold eventual HTML output + $item = array(); + + // Start setting values needed for HTML output + if($isRetweet || is_null($user)) { + // Replace tweet object with original retweeted object + if($isRetweet) { + foreach($includesTweets as $includesTweet) { + //Debug::log('Includes Tweet JSON: ' . json_encode($includesTweet)); + if($includesTweet->id === $tweet->referenced_tweets[0]->id) { + $tweet = $includesTweet; + break; + } + } + } + + // Skip self-Retweets (can cause duplicate entries in output) + if(isset($user) && $tweet->author_id === $user->data->id) { + continue; + } + + // Get user object for retweeted tweet + $originalUser = new stdClass(); // make the linters stop complaining + if(isset($retweetedUsers)) { + foreach($retweetedUsers as $retweetedUser) { + if($retweetedUser->id === $tweet->author_id) { + $originalUser = $retweetedUser; + break; + } + } + } + if(isset($includesUsers)) { + foreach($includesUsers as $includesUser) { + if($includesUser->id === $tweet->author_id) { + $originalUser = $includesUser; + break; + } + } + } + + $item['username'] = $originalUser->username; + $item['fullname'] = $originalUser->name; + if(isset($originalUser->profile_image_url)) { + $item['avatar'] = $originalUser->profile_image_url; + } else{ + $item['avatar'] = null; + } + } else{ + $item['username'] = $user->data->username; + $item['fullname'] = $user->data->name; + $item['avatar'] = $user->data->profile_image_url; + } + $item['id'] = $tweet->id; + $item['timestamp'] = $tweet->created_at; + $item['uri'] + = self::URI . $item['username'] . '/status/' . $item['id']; + $item['author'] = ($isRetweet ? 'RT: ' : '' ) + . $item['fullname'] + . ' (@' + . $item['username'] . ')'; + + // Convert plain text URLs into HTML hyperlinks + $cleanedTweet = $tweet->text; + //Debug::log('cleanedTweet: ' . $cleanedTweet); + + // Remove 'RT @' from tweet text + // To Do: also remove the full username being retweeted? + if(substr($cleanedTweet, 0, 4) === 'RT @') { + $cleanedTweet = substr($cleanedTweet, 3); + } + + // Perform filtering (skip some tweets) + switch($this->queriedContext) { + case 'By list ID': + // Check if list tweet contains desired filter keyword + // (using raw content) + if($this->getInput('filter')) { + if(stripos($cleanedTweet, + $this->getInput('filter')) === false) { + continue 2; // switch + for-loop! + } + } + break; + case 'By username': + /* This section should be unnecessary, let's confirm + if($hideRetweets && strtolower($item['username']) != + strtolower($this->getInput('u'))) { + continue 2; // switch + for-loop! + } + break; + */ + default: + } + + // Search for and replace URLs in Tweet text + $foundUrls = false; + if(isset($tweet->entities->urls)) { + foreach($tweet->entities->urls as $url) { + $cleanedTweet = str_replace($url->url, + '' . $url->display_url . '', + $cleanedTweet); + $foundUrls = true; + } + } + if($foundUrls === false) { + // fallback to regex'es + $reg_ex = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/'; + if(preg_match($reg_ex, $cleanedTweet, $url)) { + $cleanedTweet = preg_replace($reg_ex, + "{$url[0]} ", + $cleanedTweet); + } + } + + // generate the title + $item['title'] = strip_tags($cleanedTweet); + + // Add avatar + $picture_html = ''; + if(!$hideProfilePic && isset($item['avatar'])) { + $picture_html = << +{$item['username']} + +EOD; + } + + // Get images + $media_html = ''; + if(!$hideImages && isset($tweet->attachments->media_keys)) { + + // Match media_keys in tweet to media list from, put matches + // into new array + $tweetMedia = array(); + // Start by checking the original list of tweet Media includes + if(isset($includesMedia)) { + foreach($includesMedia as $includesMedium) { + if(in_array ($includesMedium->media_key, + $tweet->attachments->media_keys)) { + $tweetMedia[] = $includesMedium; + } + } + } + // If no matches found, check the retweet Media includes + if(empty($tweetMedia) && isset($retweetedMedia)) { + foreach($retweetedMedia as $retweetedMedium) { + if(in_array ($retweetedMedium->media_key, + $tweet->attachments->media_keys)) { + $tweetMedia[] = $retweetedMedium; + } + } + } + + foreach($tweetMedia as $media) { + switch($media->type) { + case 'photo': + $image = $media->url . '?name=orig'; + if ($this->getInput('noimgscaling')) { + $display_image = $media->url; + } else{ + $display_image = $media->url . '?name=thumb'; + } + // add enclosures + $item['enclosures'][] = $image; + + $media_html .= << + + +EOD; + break; + case 'video': + // To Do: Is there a way to easily match this + // to a URL for a link? + $display_image = $media->preview_image_url; + + $media_html .= << +EOD; + break; + case 'animated_gif': + // To Do: Is there a way to easily match this to a + // URL for a link? + $display_image = $media->preview_image_url; + + $media_html .= << +EOD; + break; + default: + Debug::log('Missing support for media type: ' + . $media->type); + } + } + } + + $item['content'] = << + {$picture_html} + +
+
{$cleanedTweet}
+
+
+
{$media_html}
+
+EOD; + + $item['content'] = htmlspecialchars_decode($item['content'], ENT_QUOTES); + + // put out + $this->items[] = $item; + } + + // Sort all tweets in array by date + usort($this->items, array('TwitterV2Bridge', 'compareTweetDate')); + } + + private static function compareTweetDate($tweet1, $tweet2) { + return (strtotime($tweet1['timestamp']) < strtotime($tweet2['timestamp']) ? 1 : -1); + } + + /** + * Tries to make an API call to Twitter. + * @param $api string API entry point + * @param $params array additional URI parmaeters + * @return object json data + */ + private function makeApiCall($api, $params) { + $uri = self::API_URI . $api . '?' . http_build_query($params); + $result = getContents($uri, $this->authHeaders, array(), false); + $data = json_decode($result); + return $data; + } +} diff --git a/docs/10_Bridge_Specific/TwitterV2.md b/docs/10_Bridge_Specific/TwitterV2.md new file mode 100644 index 00000000..1fbcd928 --- /dev/null +++ b/docs/10_Bridge_Specific/TwitterV2.md @@ -0,0 +1,37 @@ +TwitterV2Bridge +=============== + +To automatically retrieve Tweets containing potentially sensitive/age-restricted content, you'll need to acquire your own unique API Bearer token, which will be used by this Bridge to query Twitter's API v2. + +Configuration +------------- + +1. Make a Twitter Developer account + + - Developer Portal: https://dev.twitter.com + + - I will not detail exactly how to do this, as the specific process will likely change over time. You should easily be able to find guides using your search engine of choice. + + - A basic free developer account grants Essential access to the Twitter API v2, which should be sufficient for this bridge. + +2. Create a Twitter Project and App, get Bearer Token + + - Once you have an active Twitter Developer account, sign in to the dev portal + + - Create a new Project (name doesn't matter) + + - Create an App within the Project (again, name doesn't matter) + + - Go to the **Keys and tokens** tab + + - Generate a **Bearer Token** (you don't want the API Key and Secret, or the Access Token and Secret) + +3. Configure RSS-Bridge + + - In **config.ini.php** (in rss-bridge root directory) add following lines at the end: + + ``` + [TwitterV2Bridge] + twitterv2apitoken = %Bearer Token from step 2% + ``` + - If you don't have a **config.ini.php**, create one by making a copy of **config.default.ini.php** \ No newline at end of file