<?php
/**
 * Simple Twitter API 1.1 REST Client using cURL extension
 * @author Tim Whitlock, http://timwhitlock.info
 * @license MIT
 */

define('TWITTER_API_TIMEOUT', 5 );  

define('TWITTER_API_USERAGENT', 'PHP/'.PHP_VERSION.'; http://github.com/timwhitlock/php-twitter-api' );  

define('TWITTER_API_BASE', 'https://api.twitter.com/1.1' );

define('TWITTER_OAUTH_REQUEST_TOKEN_URL', 'https://twitter.com/oauth/request_token');

define('TWITTER_OAUTH_AUTHORIZE_URL', 'https://twitter.com/oauth/authorize');

define('TWITTER_OAUTH_AUTHENTICATE_URL', 'https://twitter.com/oauth/authenticate');

define('TWITTER_OAUTH_ACCESS_TOKEN_URL', 'https://twitter.com/oauth/access_token');

define('YOUR_CONSUMER_KEY', 'n717jgpokhnb5bFvQhF8NgCLe');
define('YOUR_CONSUMER_SECRET', '1nFR5O7v9CTNIjlb9sG5isnt1Ty1lKWt5bwbU7GHHRLRklQDBC');
define('SOME_ACCESS_KEY', '3235863432-M7wCxCAaNFM5iua042hQY0T2xl8WPfIY7pyPIvz');
define('SOME_ACCESS_SECRET', 'QuIOWEUkkxnk7saUDtulnqo3uTYmt9t99DZUWJUnlSIAK');

/**
 * Client for the Twitter REST API 1.1
 */
class TwitterApiClient {

    /**
     * Consumer key token for application
     * @var TwitterOAuthToken
     */
    private $Consumer;

    /**
     * Authenticated access token
     * @var TwitterOAuthToken
     */
    private $AccessToken;
    
    /**
     * Whether caching API GET requests
     * @var int
     */
    private $cache_ttl = null;
    
    /**
     * Namespace/prefix for cache keys
     * @var string
     */    
    private $cache_ns;     
    
    /**
     * Registry of last rate limit arrays by full api function call
     * @var array
     */    
    private $last_rate = array();    
    
    /**
     * Last api function called, e.g. "direct_messages/sent"
     * @var string
     */    
    private $last_call;     

    
    /**
     * @internal
     */
    public function __sleep(){
       return array('Consumer','AccessToken');
    }
    
    /**
     * Enable caching of subsequent API calls
     * @return TwitterApiClient
     */
    public function enable_cache( $ttl = 0, $namespace = 'twitter_api_' ){
       if( function_exists('apc_store') ){
          $this->cache_ttl = (int) $ttl;
          $this->cache_ns  = $namespace;
          return $this;
       }
       trigger_error( 'Cannot enable Twitter API cache without APC extension' );
       return $this->disable_cache();
    }
    
    /**
     * Disable caching for susequent API calls
     * @return TwitterApiClient
     */
    public function disable_cache(){
       $this->cache_ttl = null;
       $this->cache_ns  = null;
       return $this;
    }

    /**
     * Test whether the client has full authentication data.
     * Warning: does not validate credentials 
     * @return bool
     */
    public function has_auth(){
        return $this->AccessToken instanceof TwitterOAuthToken && $this->AccessToken->secret;
    }    
    
    /**
     * Unset all logged in credentials - useful in error situations
     * @return TwitterApiClient
     */
    public function deauthorize(){
        $this->AccessToken = null;
        return $this;
    }


    /**
     * Set currently logged in user's OAuth access token
     * @param string consumer api key
     * @param string consumer secret
     * @param string access token
     * @param string access token secret
     * @return TwitterApiClient
     */
    public function set_oauth( $consumer_key, $consumer_secret, $access_key = '', $access_secret = '' ){
        $this->deauthorize();
        $this->Consumer = new TwitterOAuthToken( $consumer_key, $consumer_secret );
        if( $access_key && $access_secret ){
            $this->AccessToken = new TwitterOAuthToken( $access_key, $access_secret );
        }
        return $this;
    }
    

    /**
     * Set consumer oauth token by object
     * @param TwitterOAuthToken
     * @return TwitterApiClient
     */
    public function set_oauth_consumer( TwitterOAuthToken $token ){
        $this->Consumer = $token;
        return $this;
    }
    

    /**
     * Set access oauth token by object
     * @param TwitterOAuthToken
     * @return TwitterApiClient
     */
    public function set_oauth_access( TwitterOAuthToken $token ){
        $this->AccessToken = $token;
        return $this;
    }

    
    
    /**
     * Contact Twitter for a request token, which will be exchanged for an access token later.
     * @param string callback URL or "oob" for desktop apps (out of bounds)
     * @return TwitterOAuthToken Request token
     */
    public function get_oauth_request_token( $oauth_callback = 'oob' ){
        $params = $this->oauth_exchange( TWITTER_OAUTH_REQUEST_TOKEN_URL, compact('oauth_callback') );
        return new TwitterOAuthToken( $params['oauth_token'], $params['oauth_token_secret'] );
    }



    /**
     * Exchange request token for an access token after authentication/authorization by user
     * @param string verifier passed back from Twitter or copied out of browser window
     * @return TwitterOAuthToken Access token
     */
    public function get_oauth_access_token( $oauth_verifier ){
        $params = $this->oauth_exchange( TWITTER_OAUTH_ACCESS_TOKEN_URL, compact('oauth_verifier') );
        $token = new TwitterOAuthToken( $params['oauth_token'], $params['oauth_token_secret'] );
        $token->user = array (
            'id' => $params['user_id'],
            'screen_name' => $params['screen_name'],
        );
        return $token;
    }    
    
    
    
    /**
     * Basic sanitation of api request arguments
     * @param array original params passed by client code
     * @return array sanitized params that we'll serialize
     */    
    private function sanitize_args( array $_args ){
        // transform some arguments and ensure strings
        // no further validation is performed
        $args = array();
        foreach( $_args as $key => $val ){
            if( is_string($val) ){
                $args[$key] = $val;
            }
            else if( true === $val ){
                $args[$key] = 'true';
            }
            else if( false === $val || null === $val ){
                 $args[$key] = 'false';
            }
            else if( ! is_scalar($val) ){
                throw new TwitterApiException( 'Invalid Twitter parameter ('.gettype($val).') '.$key, -1 );
            }
            else {
                $args[$key] = (string) $val;
            }
        }
        return $args;
    }    
    
    
    
    /**
     * Call API method over HTTP and return serialized data
     * @param string API method, e.g. "users/show"
     * @param array method arguments
     * @param string http request method
     * @return array unserialized data returned from Twitter
     * @throws TwitterApiException
     */
    public function call( $path, array $args = array(), $http_method = 'GET' ){
        $args = $this->sanitize_args( $args );
        // Fetch response from cache if possible / allowed / enabled
        if( $http_method === 'GET' && isset($this->cache_ttl) ){
           $cachekey = $this->cache_ns.$path.'_'.md5( serialize($args) );
           if( preg_match('/^(\d+)-/', $this->AccessToken->key, $reg ) ){
              $cachekey .= '_'.$reg[1];
           }
           $data = apc_fetch( $cachekey );
           if( is_array($data) ){
               return $data;
           }
        }
        $http = $this->rest_request( $path, $args, $http_method );
        // Deserialize response
        $status = $http['status'];
        $data = json_decode( $http['body'], true );
        // unserializable array assumed to be serious error
        if( ! is_array($data) ){
            $err = array( 
                'message' => $http['error'], 
                'code' => -1 
            );
            TwitterApiException::chuck( $err, $status );
        }
        // else could be well-formed error
        if( isset( $data['errors'] ) ) {
            while( $err = array_shift($data['errors']) ){
                $err['message'] = $err['message'];
                if( $data['errors'] ){
                    $message = sprintf('Twitter error #%d', $err['code'] ).' "'.$err['message'].'"';
                    trigger_error( $message, E_USER_WARNING );
                }
                else {
                    TwitterApiException::chuck( $err, $status );
                }
            }
        }
        if( isset($cachekey) ){
           apc_store( $cachekey, $data, $this->cache_ttl );
        }
        return $data;
    }



    /**
     * Call API method over HTTP and return raw response data without caching
     * @param string API method, e.g. "users/show"
     * @param array method arguments
     * @param string http request method
     * @return array structure from http_request
     * @throws TwitterApiException
     */
    public function raw( $path, array $args = array(), $http_method = 'GET' ){
        $args = $this->sanitize_args( $args );
        return $this->rest_request( $path, $args, $http_method );
    }



    /**
     * Perform an OAuth request - these differ somewhat from regular API calls
     * @internal
     */
    private function oauth_exchange( $endpoint, array $args ){
        // build a post request and authenticate via OAuth header
        $params = new TwitterOAuthParams( $args );
        $params->set_consumer( $this->Consumer );
        if( $this->AccessToken ){
            $params->set_token( $this->AccessToken );
        }
        $params->sign_hmac( 'POST', $endpoint );
        $conf = array (
            'method' => 'POST',
            'headers' => array( 'Authorization' => $params->oauth_header() ),
        );
        $http = self::http_request( $endpoint, $conf );
        $body = trim( $http['body'] );
        $stat = $http['status'];
        if( 200 !== $stat ){
            // Twitter might respond as XML, but with an HTML content type for some reason
            if( 0 === strpos($body, '<?') ){
                $xml = simplexml_load_string($body);
                $body = (string) $xml->error;
            }
            throw new TwitterApiException( $body, -1, $stat );
        }
        parse_str( $body, $params );
        if( ! is_array($params) || ! isset($params['oauth_token']) || ! isset($params['oauth_token_secret']) ){
            throw new TwitterApiException( 'Malformed response from Twitter', -1, $stat );
        }
        return $params;   
    }
    
    

    
    /**
     * Sign and execute REST API call
     * @return array
     */
    private function rest_request( $path, array $args, $http_method ){
        // all calls must be authenticated in API 1.1
        if( ! $this->has_auth() ){
            throw new TwitterApiException( 'Twitter client not authenticated', 0, 401 );
        }
        // prepare HTTP request config
        $conf = array (
            'method' => $http_method,
        );
        // build signed URL and request parameters
        $endpoint = TWITTER_API_BASE.'/'.$path.'.json';
        $params = new TwitterOAuthParams( $args );
        $params->set_consumer( $this->Consumer );
        $params->set_token( $this->AccessToken );
        $params->sign_hmac( $http_method, $endpoint );
        if( 'GET' === $http_method ){
            $endpoint .= '?'.$params->serialize();
        }
        else {
            $conf['body'] = $params->serialize();
        }
        $http = self::http_request( $endpoint, $conf );        
        // remember current rate limits for this endpoint
        $this->last_call = $path;
        if( isset($http['headers']['x-rate-limit-limit']) ) {
            $this->last_rate[$path] = array (
                'limit'     => (int) $http['headers']['x-rate-limit-limit'],
                'remaining' => (int) $http['headers']['x-rate-limit-remaining'],
                'reset'     => (int) $http['headers']['x-rate-limit-reset'],
            );
        }
        return $http;
    }    



    /**
     * Abstract HTTP call, currently just uses cURL extension
     * @return array e.g. { body: '', error: '', status: 200, headers: {} }
     */
    public static function http_request( $endpoint, array $conf ){
        $conf += array(
            'body' => '',
            'method'  => 'GET',
            'headers' => array(),
        );

        $ch = curl_init();
        curl_setopt( $ch, CURLOPT_URL, $endpoint );
        curl_setopt( $ch, CURLOPT_TIMEOUT, TWITTER_API_TIMEOUT );
        curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, TWITTER_API_TIMEOUT );
        curl_setopt( $ch, CURLOPT_USERAGENT, TWITTER_API_USERAGENT );
        curl_setopt( $ch, CURLOPT_HEADER, true );
        curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
        
        switch ( $conf['method'] ) {
        case 'GET':
            break;
        case 'POST':
            curl_setopt( $ch, CURLOPT_POST, true );
            curl_setopt( $ch, CURLOPT_POSTFIELDS, $conf['body'] );
            break;
        default:
            throw new TwitterApiException('Unsupported method '.$conf['method'] );    
        }
        
        foreach( $conf['headers'] as $key => $val ){
            $headers[] = $key.': '.$val;
        }
        if( isset($headers) ) {
            curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
        }
        
        // execute and parse response
        $response = curl_exec( $ch );
        if ( 60 === curl_errno($ch) ) { // CURLE_SSL_CACERT
            curl_setopt( $ch, CURLOPT_CAINFO, __DIR__.'/ca-chain-bundle.crt');
            $response = curl_exec($ch);
        }
        $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
        $headers = array();
        $body = '';
        if( $response && $status ){
            list( $header, $body ) = preg_split('/\r\n\r\n/', $response, 2 ); 
            if( preg_match_all('/^(Content[\w\-]+|X-Rate[^:]+):\s*(.+)/mi', $header, $r, PREG_SET_ORDER ) ){
                foreach( $r as $match ){
                    $headers[ strtolower($match[1]) ] = $match[2];
                }        
            }
            curl_close($ch);
        }
        else {
            $error = curl_error( $ch ) or 
            $error = 'No response from Twitter';
            is_resource($ch) and curl_close($ch);
            throw new TwitterApiException( $error );
        }
        return array (
            'body'    => $body,
            'status'  => $status,
            'headers' => $headers,
        );
    }



    /**
     * Get current rate limit, if known. does not look it up
     */
    public function last_rate_limit_data( $func = '' ){
        $func or $func = $this->last_call;
        return isset($this->last_rate[$func]) ? $this->last_rate[$func] : array( 'limit' => 0 );
    }
    
    
    /**
     * Get rate limit allowance for last endpoint request
     */
    public function last_rate_limit_allowance( $func = '' ){
        $data = $this->last_rate_limit_data($func);
        return isset($data['limit']) ? $data['limit'] : null;
    }
    
    
    /**
     * Get number of requests remaining this period for last endpoint request
     */
    public function last_rate_limit_remaining( $func = '' ){
        $data = $this->last_rate_limit_data($func);
        return isset($data['remaining']) ? $data['remaining'] : null;
    }
    
    
    /**
     * Get rate limit reset time for last endpoint request
     */
    public function last_rate_limit_reset( $func = '' ){
        $data = $this->last_rate_limit_data($func);
        return isset($data['reset']) ? $data['reset'] : null;
    }

}







/**
 * Simple token class that holds key and secret
 * @internal
 */
class TwitterOAuthToken {

    public $key;
    public $secret;
    public $verifier;
    public $user;

    public function __construct( $key, $secret = '' ){
        if( ! $key ){
           throw new Exception( 'Invalid OAuth token - Key required even if secret is empty' );
        }
        $this->key = $key;
        $this->secret = $secret;
        $this->verifier = '';
    }

    public function get_authorization_url(){
        return TWITTER_OAUTH_AUTHORIZE_URL.'?oauth_token='.rawurlencode($this->key);
    }

    public function get_authentication_url(){
        return TWITTER_OAUTH_AUTHENTICATE_URL.'?oauth_token='.rawurlencode($this->key);
    }

}





/**
 * Class for compiling, signing and serializing OAuth parameters
 * @internal
 */
class TwitterOAuthParams {
    
    private $args;
    private $consumer_secret;
    private $token_secret;
    
    private static function urlencode( $val ){
        return str_replace( '%7E', '~', rawurlencode($val) );
    }    
    
    public function __construct( array $args = array() ){
        $this->args = $args + array ( 
            'oauth_version' => '1.0',
        );
    }
    
    public function set_consumer( TwitterOAuthToken $Consumer ){
        $this->consumer_secret = $Consumer->secret;
        $this->args['oauth_consumer_key'] = $Consumer->key;
    }   
    
    public function set_token( TwitterOAuthToken $Token ){
        $this->token_secret = $Token->secret;
        $this->args['oauth_token'] = $Token->key;
    }   
    
    private function normalize(){
        $flags = SORT_STRING | SORT_ASC;
        ksort( $this->args, $flags );
        foreach( $this->args as $k => $a ){
            if( is_array($a) ){
                sort( $this->args[$k], $flags );
            }
        }
        return $this->args;
    }
    
    public function serialize(){
        $str = http_build_query( $this->args );
        // PHP_QUERY_RFC3986 requires PHP >= 5.4
        $str = str_replace( array('+','%7E'), array('%20','~'), $str );
        return $str;
    }

    public function sign_hmac( $http_method, $http_rsc ){
        $this->args['oauth_signature_method'] = 'HMAC-SHA1';
        $this->args['oauth_timestamp'] = sprintf('%u', time() );
        $this->args['oauth_nonce'] = sprintf('%f', microtime(true) );
        unset( $this->args['oauth_signature'] );
        $this->normalize();
        $str = $this->serialize();
        $str = strtoupper($http_method).'&'.self::urlencode($http_rsc).'&'.self::urlencode($str);
        $key = self::urlencode($this->consumer_secret).'&'.self::urlencode($this->token_secret);
        $this->args['oauth_signature'] = base64_encode( hash_hmac( 'sha1', $str, $key, true ) );
        return $this->args;
    }

    public function oauth_header(){
        $lines = array();
        foreach( $this->args as $key => $val ){
            $lines[] = self::urlencode($key).'="'.self::urlencode($val).'"';
        }
        return 'OAuth '.implode( ",\n ", $lines );
    }

}






/**
 * HTTP status codes with some overridden for Twitter-related messages.
 * Note these do not replace error text from Twitter, they're for complete API failures.
 * @param int HTTP status code
 * @return string HTTP status text
 */
function _twitter_api_http_status_text( $s ){
    static $codes = array (
        100 => 'Continue',
        101 => 'Switching Protocols',
        
        200 => 'OK',
        201 => 'Created',
        202 => 'Accepted',
        203 => 'Non-Authoritative Information',
        204 => 'No Content',
        205 => 'Reset Content',
        206 => 'Partial Content',
        
        300 => 'Multiple Choices',
        301 => 'Moved Permanently',
        302 => 'Found',
        303 => 'See Other',
        304 => 'Not Modified',
        305 => 'Use Proxy',
        307 => 'Temporary Redirect',
        
        400 => 'Bad Request',
        401 => 'Authorization Required',
        402 => 'Payment Required',
        403 => 'Forbidden',
        404 => 'Not Found',
        405 => 'Method Not Allowed',
        406 => 'Not Acceptable',
        407 => 'Proxy Authentication Required',
        408 => 'Request Time-out',
        409 => 'Conflict',
        410 => 'Gone',
        411 => 'Length Required',
        412 => 'Precondition Failed',
        413 => 'Request Entity Too Large',
        414 => 'Request-URI Too Large',
        415 => 'Unsupported Media Type',
        416 => 'Requested range not satisfiable',
        417 => 'Expectation Failed',
        //  ..
        429 => 'Twitter API rate limit exceeded',
        
        500 => 'Twitter server error',
        501 => 'Not Implemented',
        502 => 'Twitter is not responding',
        503 => 'Twitter is too busy to respond',
        504 => 'Gateway Time-out',
        505 => 'HTTP Version not supported',
    );
    return  isset($codes[$s]) ? $codes[$s] : sprintf('Status %u from Twitter', $s );
}





/**
 * Exception for throwing when Twitter responds with something unpleasant
 */
class TwitterApiException extends Exception {

    /**
     * HTTP Status of error
     * @var int
     */        
    protected $status = 0;        

        
    /**
     * Throw appropriate exception type according to HTTP status code
     * @param array Twitter error data from their response 
     */
    public static function chuck( array $err, $status ){
        $code = isset($err['code']) ? (int) $err['code'] : -1;
        $mess = isset($err['message']) ? trim($err['message']) : '';
        static $classes = array (
            404 => 'TwitterApiNotFoundException',
            429 => 'TwitterApiRateLimitException',
        );
        $eclass = isset($classes[$status]) ? $classes[$status] : __CLASS__;
        throw new $eclass( $mess, $code, $status );
    }
        
        
    /**
     * Construct TwitterApiException with addition of HTTP status code.
     * @overload
     */        
    public function __construct( $message, $code = 0 ){
        if( 2 < func_num_args() ){
            $this->status = (int) func_get_arg(2);
        }
        if( ! $message ){
            $message = _twitter_api_http_status_text($this->status);
        }
        parent::__construct( $message, $code );
    }
    
    
    /**
     * Get HTTP status of error
     * @return int
     */
    public function getStatus(){
        return $this->status;
    }
    
}


/** 404 */
class TwitterApiNotFoundException extends TwitterApiException {
    
}


/** 429 */
class TwitterApiRateLimitException extends TwitterApiException {
    
}
