├── .gitignore ├── README.md ├── action.php ├── actions ├── PostCleanupAction.php ├── aggregator.php ├── delayedmarkovpostaction.php └── delayedmarkovpostaction │ ├── markovfirstorder.php │ └── markovsecondorder.php ├── config.php ├── install.php ├── phirehose ├── OauthPhirehose.php ├── Phirehose.php └── UserstreamPhirehose.php ├── phpunit ├── data │ └── testDelayedMarkovPostActionData.xml └── tests │ ├── DatabaseTest.php │ └── MarkovTest.php ├── run.php ├── storage.php ├── twitterbot.php ├── twitteroauth ├── OAuth.php └── twitteroauth.php └── util ├── TwitterAPI.php └── translate.php /.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | config_orig.php 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP Twitterbot 2 | ============== 3 | 4 | **DEPRECATION NOTICE**: As of the release of [PyBot](https://github.com/magsol/pybot), this Twitter bot implementation has been deprecated. No further updates will be made to this repository. Apologies for any inconvenience; PHP just wasn't made for this sort of thing. 5 | 6 | 7 | This is designed to be a relatively lightweight, flexible framework for 8 | deploying your own customized and automated twittering bot that more or 9 | less does whatever you program it to do. 10 | 11 | - Author: Shannon Quinn 12 | - Project Wiki: http://www.magsol.me/wiki/index.php5?title=SpamBot 13 | - Git repository: https://github.com/magsol/Twitterbot 14 | - Version: 2.0 (alpha) 15 | 16 | Overview 17 | -------- 18 | 19 | The general idea here is to provide a framework by which you can implement 20 | as simple or complex a twitterbot as you like without having to worry about 21 | anything but the specific behavior you want to implement. If you want a bot 22 | that does nothing but sample the public timeline, you can practically use 23 | this framework out of the box. If you want a box that reads posts, makes 24 | posts, adds friends, changes its profile, and develops a cure for cancer you 25 | can absolutely do that too (though it may take a bit of work). The point 26 | is you'll only have to focus on implementing that specific behavior; everything 27 | else (in theory :P ) has been taken care of. 28 | 29 | The core of this framework is the concept of an "Action": ideally, it 30 | encapsulates a single concrete activity the twitterbot performs (posting, 31 | or direct messaging, or changing background images, etc). In order to create 32 | a new action, you begin by subclassing the Action class. 33 | 34 | Say we want our bot to post the current time and a "Hello!" message every hour. 35 | We begin by defining this class as follows: 36 | 37 | require_once(BOTROOT . 'action.php'); 38 | class ClockAction extends Action { 39 | 40 | We are then required to implement at least two other methods: a 41 | constructor and a `run()` method: 42 | 43 | public function __construct($name, $active, $params) { 44 | $this->name = $name; 45 | $this->isActive = $active; 46 | foreach ($params as $k => $v) { 47 | $this->$k = $v; 48 | } 49 | parent::__construct($name, $active, array()); // recommended! 50 | } 51 | 52 | public function run() { 53 | // do stuff here that will post hourly update 54 | // hint: make use of util/TwitterAPI.php 55 | 56 | // if the action completes without any errors, 57 | // return parent::SUCCESS. otherwise, return parent::FAILURE 58 | return parent::SUCCESS; 59 | } 60 | 61 | With this basic framework, you can extend it to do just about anything 62 | you'd like. Set `$this->frequency` in the `__construct()` method you wrote 63 | to provide a different frequency of your Action firing; or, for even 64 | greater control, override the `setNextAttempt()` method to completely 65 | redefine the frequency with which your Action fires. You can override 66 | the `post_run()` method to perform any custom post-Action clean-up or 67 | logging. 68 | 69 | Put your `ClockAction.php` file into the actions/ folder (along with 70 | any additional dependencies it may require), and point this Twitterbot 71 | to it by setting up `config.php` to point to it (details below in the 72 | SETUP section, step 1). Once this is complete, fire up the bot's daemon, 73 | sit back, and let the Twitter trolling begin :) 74 | 75 | Notes 76 | ----- 77 | 78 | *THIS IS STILL VERY MUCH EXPERIMENTAL AND NOT PARTICULARLY ROBUST.* I implemented 79 | the process control without much previous experience in it (most of my experience 80 | is in multithreading with C...not very applicable to this), so it is still very 81 | rough around the edges, particularly with the database connection management. 82 | 83 | If you encounter any bugs, please don't hesitate to report them, either to the 84 | github page, or you can email me: magsol at gmail. 85 | 86 | Requirements 87 | ------------ 88 | 89 | In order to run this bot, you need: 90 | 91 | - PHP 5.x (with pcntl) 92 | - PEAR and its basic libraries 93 | - MySQL 5.x (though will probably work with 4.x) 94 | - A brave soul 95 | 96 | Installation 97 | ----------- 98 | 99 | 1. Fill out the necessary fields in config.php (there are several). 100 | - `BOT_ACCOUNT`: The display name for your bot. 101 | - `BOT_PASSWORD`: The password to log into your bot (will be removed once Twitter's 102 | Streaming API is integrated into OAuth...for now, a necessary evil). 103 | - `CONSUMER_KEY`: OAuth Consumer Key, obtained by creating an app for this bot. 104 | - `CONSUMER_SECRET`: Same as above. 105 | - `OAUTH_TOKEN`: Same as above. 106 | - `OAUTH_TOKEN_SECRET`: Same as above. 107 | - `DB_NAME`: Name of the database this application's data can be stored in. 108 | - `DB_HOST`: Host on which the database resides (usually "localhost"). 109 | - `DB_USER`: Username of the account that has admin access to the DB_NAME database. 110 | - `DB_PASS`: Password for the above user. 111 | 112 | Within the $actions array you have optional definitions (required if you want 113 | the bot to do anything interesting) to customize how your bot behaves. If you 114 | leave everything blank, it will simply aggregate posts from Twitter's public 115 | timeline (you'll see them grow quite fast within your database). 116 | 117 | If you choose to add some fields, you'll need to implement your own Action 118 | subclass that performs whatever action of interest you want. Obeying the usual 119 | object-oriented programming guidelines generally makes for better-behaved bots, 120 | but in general you can have your action do whatever you want. Just be sure 121 | to fill in the required fields for any defined Action: 122 | - name (can be anything, used mainly for debugging) 123 | - class (case-sensitive name of the class you created) 124 | - file (case-sensitive name of the PHP file in which your class resides) 125 | - active (boolean indicating whether or not this action should be fired) 126 | - args (optional array of arguments, specific to your action) 127 | 128 | 2. Run the bot's install script. 129 | 130 | `php install.php` 131 | 132 | This will use the database values defined in your `config.php` to set up your 133 | database's schema. For optimal behavior, please only fire this script once. 134 | It shouldn't cause any unintended behavior if you execute it multiple times 135 | (it will simply quit if it detects the tables exist already), but why would 136 | you need to run the install script multiple times anyway? 137 | 138 | 3. Test the bot's settings. 139 | 140 | `php run.php --tests-only` 141 | 142 | This will have the bot run a battery of tests against the settings you've 143 | indicated. If there are any failures, it will halt immediately and let you 144 | know (wrong database username/password, a missing required field for a custom 145 | Action, etc). 146 | 147 | 4. Run the bot! 148 | 149 | `php run.php` 150 | 151 | You can include a `-h` flag to display all the available options. By default 152 | (run with no arguments), the script will execute the battery of tests like in 153 | step 3 but will, pending all tests passing, start up the bot itself. The bot 154 | will behave as a daemon, detaching itself from the terminal and spawning a child 155 | process for each custom Action defined in `config.php`. In order to kill the 156 | daemon and its child processes, run the command: 157 | 158 | `php run.php --stop` 159 | 160 | Acknowledgements 161 | ---------------- 162 | 163 | There are several people who made this project possible. 164 | 165 | - [Rob Hall](http://cs.cmu.edu/~rjhall) : the original inspiration, with his 166 | hilarious [postmaster9001 bot](http://twitter.com/postmaster9001), whose awful 167 | Perl hack-job implementation inspired me to make something more flexible 168 | and robust. 169 | 170 | - [Abraham of twitteroauth](https://github.com/abraham/twitteroauth) : His library 171 | makes the implementation of OAuth authentication in this project possible. 172 | That's one huge black box that is reduced to one or two lines of code on my 173 | part to worry about. Awesome, awesome work. 174 | 175 | - [Fennb of Phirehose](https://github.com/fennb/phirehose) : Again, a huge, rich, 176 | robust API that makes my life really freaking easy. This makes data aggregation 177 | possible in this project, enabling me to spend my time doing interesting things 178 | with the data rather than finding ways of crawling Twitter. Keep up the good 179 | work. 180 | 181 | - [Sebastian Bergmann of PHPUnit](https://github.com/sebastianbergmann/phpunit/) : 182 | Author of a phenomenal unit-testing framework for PHP, it's still on my to-do 183 | list to incporporate it into this bot's testing. It will definitely happen, 184 | and this guy has made it a lot easier. 185 | 186 | - George Schlossnagle of "Advanced PHP Programming", from which I learned just 187 | about every trick in this project for process management, particularly in 188 | setting up the structure for the Twitterbot and Action classes. It was a 189 | phenomenal resource and made the daemon aspect of this project feasible. 190 | -------------------------------------------------------------------------------- /action.php: -------------------------------------------------------------------------------- 1 | $v) { 40 | $this->$k = $v; 41 | } 42 | $this->name = $name; 43 | $this->isActive = $active; 44 | } 45 | 46 | /** 47 | * This method also needs to be implemented by the subclasses. Dictates 48 | * how the action runs. 49 | * NOTE: If you need access to a database connection, do it in here! 50 | * 51 | * @return Action::SUCCESS if the method runs successfully, 52 | * Action::FAILURE otherwise. 53 | */ 54 | public abstract function run(); 55 | 56 | /** 57 | * Calculates the time for the next firing of this action. 58 | */ 59 | public function setNextAttempt() { 60 | $this->nextAttempt = time() + ($this->frequency * 60); 61 | } 62 | 63 | /** 64 | * Accessor for the nextAttempt field. 65 | * @return The unix timestamp of when this action should fire. 66 | */ 67 | public function getNextAttempt() { 68 | return $this->nextAttempt; 69 | } 70 | 71 | /** 72 | * Accessor for the timeout count. 73 | * @return The number of seconds until this action should be timed out if 74 | * it has not completed. 75 | */ 76 | public function getTimeout() { 77 | return $this->timeout * 60; 78 | } 79 | 80 | /** 81 | * This method can be called after the run() method to perform 82 | * post-processing. 83 | * 84 | * In this case, it logs any failures (if the run() return value is 85 | * Action::FAILURE) and saves the necessary values to the database. 86 | * @param int $status The return code from the child process exiting. 87 | */ 88 | public function post_run($status) { 89 | $this->db = Storage::getDatabase(); 90 | 91 | // log the status of this action 92 | if ($status !== $this->currentStatus) { 93 | $this->previousStatus = $this->currentStatus; 94 | } 95 | if ($status === self::FAILURE) { 96 | if ($this->currentStatus === self::FAILURE) { 97 | // failed consecutive times 98 | $this->db->log($this->name, "Still have not recovered from previous" . 99 | "error!"); 100 | } else { 101 | // this is the first time the action has failed 102 | $this->db->log($this->name, "Error has occurred!"); 103 | } 104 | } else { 105 | // Action::SUCCESS. Log this only if the previous status 106 | // was Action::FAILURE, so we know we've recovered from something. 107 | if ($this->previousStatus === Action::FAILURE) { 108 | $this->db->log($this->name, "Recovered from previous failure."); 109 | } else { 110 | $this->db->log($this->name, "Successful run!"); 111 | } 112 | } 113 | // set the current status 114 | $this->currentStatus = $status; 115 | 116 | // destroy the database connection 117 | unset($this->db); 118 | } 119 | 120 | /** 121 | * Simple accessor for the state of this action. 122 | * @return True if this Action is active, false otherwise. 123 | */ 124 | public function isActive() { 125 | return $this->isActive; 126 | } 127 | 128 | /** 129 | * Change the active state of this action. This is changed through 130 | * signaling. 131 | * @param boolean $state The new active state of this action (true or false). 132 | */ 133 | public function setActive($state) { 134 | $this->isActive = $state; 135 | } 136 | 137 | /** 138 | * Accessor for this action's name. 139 | * @return The name (unique identifier) of this action. 140 | */ 141 | public function getName() { 142 | return $this->name; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /actions/PostCleanupAction.php: -------------------------------------------------------------------------------- 1 | 0.0, this is the percentage of the number of posts to 14 | * delete (from least recent to most recent). 15 | * -numberDelete: If > 0, this is the raw number of posts to delete (from least 16 | * recent to most recent). 17 | * -maxAllowed: if > 0, this is the maximum number of tweets allowed in the 18 | * database, all others will be deleted (from least recent to most recent). 19 | * -delay: Minutes in between firings. 20 | * 21 | * NOTE: If more than one of these values are set, the order of precedence is: 22 | * 1) maxAllowed 23 | * 2) percent 24 | * 3) number 25 | */ 26 | class PostCleanupAction extends Action { 27 | 28 | private $percentDelete = -1.0; 29 | private $numberDelete = -1; 30 | private $maxAllowed = 1000000; 31 | private $delay = 25; 32 | 33 | /** 34 | * Constructor. 35 | * @param string $name 36 | * @param bool $active 37 | * @param array $args 38 | */ 39 | function __construct($name, $active, $args = array()) { 40 | parent::__construct($name, $active, array()); 41 | foreach ($args as $k => $v) { 42 | $this->$k = $v; 43 | } 44 | 45 | if ((isset($args['maxAllowed']) && isset($args['numberDelete'])) || 46 | (isset($args['maxAllowed']) && isset($args['percentDelete']))) { 47 | $this->numberDelete = -1; 48 | $this->percentDelete = -0.1; 49 | } else if (isset($args['percentDelete']) && isset($args['numberDelete'])) { 50 | $this->numberDelete = -0.1; 51 | } 52 | $this->frequency = $this->delay; 53 | $this->setNextAttempt(); 54 | } 55 | 56 | /** 57 | * @see Action::run() 58 | */ 59 | public function run() { 60 | $this->db = Storage::getDatabase(); 61 | 62 | // First, figure out how many tweets there are in the whole database. 63 | $query = 'SELECT COUNT(*) FROM `' . DB_NAME . '`.`' . POST_TABLE . '`'; 64 | $this->db->setQuery($query); 65 | $result = $this->db->query(); 66 | $numPosts = $result[0]['COUNT(*)']; 67 | $toDelete = 0; 68 | if ($this->maxAllowed > 0) { 69 | if ($this->maxAllowed > $numPosts) { 70 | return parent::SUCCESS; 71 | } 72 | $toDelete = $numPosts - $this->maxAllowed; 73 | } else if ($this->number > 0) { 74 | if ($this->number > $numPosts) { 75 | return parent::SUCCESS; 76 | } 77 | // Delete this number of posts. 78 | $toDelete = $this->number; 79 | } else { 80 | $toDelete = intval($this->percent * (double)$numPosts); 81 | } 82 | $query = 'DELETE FROM `' . DB_NAME . '`.`' . POST_TABLE . '` ORDER BY ' . 83 | '`date_saved` ASC LIMIT ' . $toDelete; 84 | $this->db->setQuery($query); 85 | $this->db->query(); 86 | $this->db->log($this->getName(), $toDelete . ' tweet' . 87 | ($toDelete != 1 ? 's' : '') . ' deleted!'); 88 | return parent::SUCCESS; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /actions/aggregator.php: -------------------------------------------------------------------------------- 1 | db)) { $this->db = Storage::getDatabase(); } 29 | 30 | // save the status 31 | $data = json_decode($status, true); 32 | if (is_array($data) && isset($data['user']['screen_name'])) { 33 | $this->db->savePost($data['text'], $data['user']['screen_name']); 34 | } 35 | } 36 | 37 | /** 38 | * (non-PHPdoc) 39 | * @see util/Phirehose::log() 40 | */ 41 | protected function log($message) { 42 | if (!isset($this->db)) { $this->db = Storage::getDatabase(); } 43 | $this->db->log('Phirehose', $message); 44 | } 45 | } 46 | 47 | ?> 48 | -------------------------------------------------------------------------------- /actions/delayedmarkovpostaction.php: -------------------------------------------------------------------------------- 1 | $v) { 47 | $this->$k = $v; 48 | } 49 | $this->setNextAttempt(); 50 | } 51 | 52 | /** 53 | * @see Action::run() 54 | */ 55 | public function run() { 56 | $this->db = Storage::getDatabase(); 57 | 58 | /*** PART 1: Read from saved posts and construct a markov chain ***/ 59 | $posts = $this->db->getPosts($this->useOnlyUnmodeledPosts, 60 | $this->postLimit); 61 | $markov = $this->buildMarkovChain($posts); 62 | if ($this->useOnlyUnmodeledPosts && count($posts) > 0) { 63 | $recent_date = $posts[0]['date_saved']; 64 | $old_date = $posts[count($posts) - 1]['date_saved']; 65 | $this->db->markPostsModeled($old_date, $recent_date); 66 | } 67 | 68 | /*** PART 2: now that we have our markov chain built, sample from it to 69 | * build the post ***/ 70 | $thepost = $this->constructPostFromMarkovChain($markov); 71 | 72 | /*** PART 3: Translate the post and send it, if the API key was set ***/ 73 | $API = TwitterAPI::getTwitterAPI(); 74 | /* 75 | ** GOOGLE TRANSLATE API DEPRECATED ** 76 | if (isset($this->googleKey)) { 77 | $thepost = Translate::translate($thepost, 'en', $this->googleKey); 78 | } 79 | */ 80 | $API->update($thepost); 81 | 82 | // destroy the database connection 83 | unset($this->db); 84 | 85 | // all done 86 | return parent::SUCCESS; 87 | } 88 | 89 | /** 90 | * Overridden from parent class' declaration. Since this action relies 91 | * on a probability distribution to determine its next firing, it is not 92 | * a simple addition of terms. Thus, for this class, the "frequency" field 93 | * is ignored and replaced with "delayMean" and "delayVar". 94 | * 95 | * @see Action::setNextAttempt() 96 | */ 97 | public function setNextAttempt() { 98 | $mean = floatval($this->delayMean); 99 | $var = floatval($this->delayVar); 100 | $rand1 = floatval(mt_rand()) / floatval(mt_getrandmax()); 101 | $rand2 = floatval(mt_rand()) / floatval(mt_getrandmax()); 102 | 103 | // sample from a normal (gaussian) distribution 104 | $delay = intval((sqrt(-2 * log($rand1)) * cos(2 * pi() * $rand2) * $var) + 105 | $mean); 106 | if ($delay <= 0) { $delay = 1; } // sanity check 107 | $this->nextAttempt = time() + intval($delay * 60); 108 | 109 | // log the next attempt 110 | $this->db = Storage::getDatabase(); 111 | $this->db->log($this->getName(), 'Next action firing set for ' . 112 | date('Y-m-d H:i:s', $this->nextAttempt)); 113 | unset($this->db); 114 | } 115 | 116 | /** 117 | * This helper method appends the raw posts from the Twitter public timeline 118 | * to the provided second-order markov chain. It extracts each individual 119 | * post, explodes it into its constituent words, performs any needed 120 | * preprocessing, builds the words in the markov chain, and returns 121 | * the updated markov chain. 122 | * 123 | * @param array $posts JSON-decoded twitter posts from the public timeline. 124 | * @return object A markov chain, updated with the new posts. 125 | */ 126 | private function buildMarkovChain($posts) { 127 | $numposts = count($posts); 128 | $markov = new MarkovSecondOrder(); 129 | for ($j = 0; $j < $numposts; $j++) { 130 | $words = explode(' ', trim($posts[$j]['text'])); 131 | array_unshift($words, '_START1_', '_START2_'); 132 | $words[] = '_STOP_'; 133 | $numwords = count($words); 134 | for ($k = 2; $k < $numwords; $k++) { 135 | $markov->add($words[$k - 2], $words[$k - 1], $words[$k]); 136 | } 137 | } 138 | return $markov; 139 | } 140 | 141 | /** 142 | * This helper method iterates over the second-order markov chain, sampling 143 | * from it appropriately and constructing a post from it. 144 | * 145 | * @param object $markov The second-order markov chain. 146 | * @return string The assembled post. 147 | */ 148 | private function constructPostFromMarkovChain($markov) { 149 | $word1 = '_START1_'; 150 | $word2 = '_START2_'; 151 | $next = ''; 152 | $thepost = ''; 153 | while (($next = $markov->get($word1, $word2)) != '_STOP_') { 154 | $temp = $thepost . $next; 155 | if (strlen($temp) > 140) { 156 | $temp = trim($temp); 157 | break; 158 | } 159 | $thepost = $temp . ' '; 160 | $word1 = $word2; 161 | $word2 = $next; 162 | } 163 | return $thepost; 164 | } 165 | } 166 | 167 | ?> 168 | -------------------------------------------------------------------------------- /actions/delayedmarkovpostaction/markovfirstorder.php: -------------------------------------------------------------------------------- 1 | hash = array(); 16 | } 17 | 18 | /** 19 | * Adds a new word and the word following it to the model (first order) 20 | * @param word 21 | * @param next 22 | */ 23 | public function add($word, $next) { 24 | // first, does the word already exist? 25 | if (isset($this->hash[$word])) { 26 | $this->hash[$word][$next] = (isset($this->hash[$word][$next]) ? 27 | $this->hash[$word][$next] + 1 : 1); 28 | } else { 29 | $this->hash[$word] = array(); 30 | $this->hash[$word][$next] = 1; 31 | } 32 | } 33 | 34 | public function debug() { 35 | print_r($this->hash); 36 | } 37 | 38 | /** 39 | * Returns the next word from the distribution, given the current word. 40 | * @param word 41 | * @return 42 | */ 43 | public function get($word) { 44 | // first, does the word even exist? 45 | if (!isset($this->hash[$word])) { 46 | return ''; 47 | } 48 | $subarr = $this->hash[$word]; 49 | // calculate the sum of the counts of the next possibilities 50 | $sum = array_sum($subarr); 51 | 52 | // generate a random number in this range 53 | $rand = mt_rand(1, $sum); 54 | 55 | // loop again, this time stopping once the counts have 56 | // reached the random number we generated, then return 57 | // that word 58 | $sum = 0; 59 | foreach ($subarr as $w => $count) { 60 | $sum += $count; 61 | if ($sum >= $rand) { 62 | return $w; 63 | } 64 | } 65 | } 66 | } 67 | 68 | ?> 69 | -------------------------------------------------------------------------------- /actions/delayedmarkovpostaction/markovsecondorder.php: -------------------------------------------------------------------------------- 1 | hash = array(); 17 | } 18 | 19 | /** 20 | * Adds a new word to the model, given the two previous words 21 | * @param string $word1 22 | * @param string $word2 23 | * @param string $next 24 | */ 25 | public function add($word1, $word2, $next) { 26 | if (isset($this->hash[$word1])) { 27 | // the first level exists, how about the second? 28 | if (isset($this->hash[$word1][$word2])) { 29 | $this->hash[$word1][$word2][$next] = (isset($this->hash[$word1][$word2][$next]) ? 30 | $this->hash[$word1][$word2][$next] + 1 : 1); 31 | } else { 32 | $this->hash[$word1][$word2] = array(); 33 | $this->hash[$word1][$word2][$next] = 1; 34 | } 35 | } else { 36 | $this->hash[$word1] = array(); 37 | $this->hash[$word1][$word2] = array(); 38 | $this->hash[$word1][$word2][$next] = 1; 39 | } 40 | } 41 | 42 | /** 43 | * Returns the next word in the distribution, given the current word. 44 | * @param string $firstword 45 | * @param string $secondword 46 | * @return string A word sampled from the distribution P(word | $firstword, $secondword) 47 | */ 48 | public function get($firstword, $secondword) { 49 | // does this even exist? 50 | if (!isset($this->hash[$firstword]) || !isset($this->hash[$firstword][$secondword])) { 51 | return ''; 52 | } 53 | 54 | // how many words are there in this sequence of initial tokens? 55 | $wordarr = $this->hash[$firstword][$secondword]; 56 | $totalwords = array_sum($wordarr); 57 | 58 | // generate a random number in this range 59 | $sample = mt_rand(1, $totalwords); 60 | 61 | // loop over the model, stopping once we've exceeded our random number, 62 | // which corresponds to the token we want to sample 63 | $sum = 0; 64 | foreach ($wordarr as $word => $count) { 65 | $sum += $count; 66 | if ($sum >= $sample) { 67 | return $word; 68 | } 69 | } 70 | // should NEVER reach this point 71 | } 72 | } -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | 'Name of your custom action', // Can be anything you want! 17 | 18 | 'class' => 'CaseSensitiveNameOfClass', // The case-sensitive name 19 | // of the action class you 20 | // wrote. 21 | 22 | 'file' => 'name_of_php_file.php', // The name of the PHP file 23 | // (with the .php extension) 24 | // of the file containing 25 | // your custom action class. 26 | 27 | 'active' => true | false, // Determines whether this 28 | // action will run or not. 29 | // If true, this action will 30 | // execute. If false, it 31 | // will be ignored. 32 | 33 | 'args' => array('Any additional arguments'),// An array of any 34 | // additional arguments 35 | // specific to your custom 36 | // action. 37 | ), 38 | */ 39 | ); 40 | 41 | // change the following values to connect to your database 42 | define('DB_NAME', 'your db name here'); // name of the database itself 43 | define('DB_HOST', 'your db host here'); // usually 'localhost' 44 | define('DB_USER', 'your db user here'); // username to connect 45 | define('DB_PASS', 'your db pass here'); // password for the username 46 | 47 | // ----------------------------------------------------------- // 48 | // -- That's it! Don't change anything else below this line -- // 49 | // ----------------------------------------------------------- // 50 | 51 | define('TWITTERBOT_LOCKFILE', '/tmp/.bot_lockfile'); 52 | define('BOTROOT', __DIR__); 53 | define('ACTIONS', BOTROOT . DIRECTORY_SEPARATOR . 'actions' . 54 | DIRECTORY_SEPARATOR); 55 | define('UTIL', BOTROOT . DIRECTORY_SEPARATOR . 'util' . DIRECTORY_SEPARATOR); 56 | define('OAUTH', BOTROOT . DIRECTORY_SEPARATOR . 'twitteroauth' . 57 | DIRECTORY_SEPARATOR); 58 | define('PHIREHOSE', BOTROOT . DIRECTORY_SEPARATOR . 'phirehose' . 59 | DIRECTORY_SEPARATOR); 60 | define('POST_TABLE', 'posts'); 61 | define('LOG_TABLE', 'logs'); 62 | 63 | ?> 64 | -------------------------------------------------------------------------------- /install.php: -------------------------------------------------------------------------------- 1 | setQuery($sql); 20 | $db->query(); 21 | $sql = 'CREATE TABLE IF NOT EXISTS `' . DB_NAME . '`.`' . LOG_TABLE . '` (' . 22 | '`eventid` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ,' . 23 | '`eventtime` DATETIME NOT NULL ,' . 24 | '`message` TEXT NOT NULL)'; 25 | $db->setQuery($sql); 26 | $db->query(); 27 | ?> -------------------------------------------------------------------------------- /phirehose/OauthPhirehose.php: -------------------------------------------------------------------------------- 1 | consumerKey?$this->consumerKey:TWITTER_CONSUMER_KEY; 37 | $oauth['oauth_nonce'] = md5(uniqid(rand(), true)); 38 | $oauth['oauth_signature_method'] = 'HMAC-SHA1'; 39 | $oauth['oauth_timestamp'] = time(); 40 | $oauth['oauth_version'] = '1.0A'; 41 | $oauth['oauth_token'] = $this->username; 42 | if (isset($params['oauth_verifier'])) 43 | { 44 | $oauth['oauth_verifier'] = $params['oauth_verifier']; 45 | unset($params['oauth_verifier']); 46 | } 47 | // encode all oauth values 48 | foreach ($oauth as $k => $v) 49 | $oauth[$k] = $this->encode_rfc3986($v); 50 | 51 | // encode all non '@' params 52 | // keep sigParams for signature generation (exclude '@' params) 53 | // rename '@key' to 'key' 54 | $sigParams = array(); 55 | $hasFile = false; 56 | if (is_array($params)) 57 | { 58 | foreach ($params as $k => $v) 59 | { 60 | if (strncmp('@', $k, 1) !== 0) 61 | { 62 | $sigParams[$k] = $this->encode_rfc3986($v); 63 | $params[$k] = $this->encode_rfc3986($v); 64 | } 65 | else 66 | { 67 | $params[substr($k, 1)] = $v; 68 | unset($params[$k]); 69 | $hasFile = true; 70 | } 71 | } 72 | 73 | if ($hasFile === true) 74 | $sigParams = array(); 75 | } 76 | 77 | $sigParams = array_merge($oauth, (array) $sigParams); 78 | 79 | // sorting 80 | ksort($sigParams); 81 | 82 | // signing 83 | $oauth['oauth_signature'] = $this->encode_rfc3986($this->generateSignature($method, $url, $sigParams)); 84 | return array('request' => $params, 'oauth' => $oauth); 85 | } 86 | 87 | protected function encode_rfc3986($string) 88 | { 89 | return str_replace('+', ' ', str_replace('%7E', '~', rawurlencode(($string)))); 90 | } 91 | 92 | protected function generateSignature($method = null, $url = null, 93 | $params = null) 94 | { 95 | if (empty($method) || empty($url)) 96 | return false; 97 | 98 | // concatenating and encode 99 | $concat = ''; 100 | foreach ((array) $params as $key => $value) 101 | $concat .= "{$key}={$value}&"; 102 | $concat = substr($concat, 0, -1); 103 | $concatenatedParams = $this->encode_rfc3986($concat); 104 | 105 | // normalize url 106 | $urlParts = parse_url($url); 107 | $scheme = strtolower($urlParts['scheme']); 108 | $host = strtolower($urlParts['host']); 109 | $port = isset($urlParts['port']) ? intval($urlParts['port']) : 0; 110 | $retval = strtolower($scheme) . '://' . strtolower($host); 111 | if (!empty($port) && (($scheme === 'http' && $port != 80) || ($scheme === 'https' && $port != 443))) 112 | $retval .= ":{$port}"; 113 | 114 | $retval .= $urlParts['path']; 115 | if (!empty($urlParts['query'])) 116 | $retval .= "?{$urlParts['query']}"; 117 | 118 | $normalizedUrl = $this->encode_rfc3986($retval); 119 | $method = $this->encode_rfc3986($method); // don't need this but why not? 120 | 121 | $signatureBaseString = "{$method}&{$normalizedUrl}&{$concatenatedParams}"; 122 | 123 | # sign the signature string 124 | $key = $this->encode_rfc3986($this->consumerSecret?$this->consumerSecret:TWITTER_CONSUMER_SECRET) . '&' . $this->encode_rfc3986($this->password); 125 | return base64_encode(hash_hmac('sha1', $signatureBaseString, $key, true)); 126 | } 127 | 128 | protected function getOAuthHeader($method, $url, $params = array()) 129 | { 130 | $params = $this->prepareParameters($method, $url, $params); 131 | $oauthHeaders = $params['oauth']; 132 | 133 | $urlParts = parse_url($url); 134 | $oauth = 'OAuth realm="",'; 135 | foreach ($oauthHeaders as $name => $value) 136 | { 137 | $oauth .= "{$name}=\"{$value}\","; 138 | } 139 | $oauth = substr($oauth, 0, -1); 140 | 141 | return $oauth; 142 | } 143 | 144 | /** Overrides base class function */ 145 | protected function getAuthorizationHeader($url,$requestParams) 146 | { 147 | return $this->getOAuthHeader('POST', $url, $requestParams); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /phirehose/Phirehose.php: -------------------------------------------------------------------------------- 1 | 9 | * @version 1.0RC 10 | */ 11 | abstract class Phirehose 12 | { 13 | 14 | /** 15 | * Class constants 16 | */ 17 | const FORMAT_JSON = 'json'; 18 | const FORMAT_XML = 'xml'; 19 | const METHOD_FILTER = 'filter'; 20 | const METHOD_SAMPLE = 'sample'; 21 | const METHOD_RETWEET = 'retweet'; 22 | const METHOD_FIREHOSE = 'firehose'; 23 | const METHOD_LINKS = 'links'; 24 | const METHOD_USER = 'user'; //See UserstreamPhirehose.php 25 | const METHOD_SITE = 'site'; //See UserstreamPhirehose.php 26 | 27 | const EARTH_RADIUS_KM = 6371; 28 | 29 | /** 30 | * @internal Moved from being a const to a variable, because some methods (user and site) need to change it. 31 | */ 32 | protected $URL_BASE = 'https://stream.twitter.com/1.1/statuses/'; 33 | 34 | 35 | /** 36 | * Member Attribs 37 | */ 38 | protected $username; 39 | protected $password; 40 | protected $method; 41 | protected $format; 42 | protected $count; //Can be -150,000 to 150,000. @see http://dev.twitter.com/pages/streaming_api_methods#count 43 | protected $followIds; 44 | protected $trackWords; 45 | protected $locationBoxes; 46 | protected $conn; 47 | protected $fdrPool; 48 | protected $buff; 49 | // State vars 50 | protected $filterChanged; 51 | protected $reconnect; 52 | 53 | /** 54 | * The number of tweets received per second in previous minute; calculated fresh 55 | * just before each call to statusUpdate() 56 | * I.e. if fewer than 30 tweets in last minute then this will be zero; if 30 to 90 then it 57 | * will be 1, if 90 to 150 then 2, etc. 58 | * 59 | * @var integer 60 | */ 61 | protected $statusRate; 62 | 63 | protected $lastErrorNo; 64 | protected $lastErrorMsg; 65 | 66 | /** 67 | * Number of tweets received. 68 | * 69 | * Note: by default this is the sum for last 60 seconds, and is therefore 70 | * reset every 60 seconds. 71 | * To change this behaviour write a custom statusUpdate() function. 72 | * 73 | * @var integer 74 | */ 75 | protected $statusCount=0; 76 | 77 | /** 78 | * The number of calls to $this->checkFilterPredicates(). 79 | * 80 | * By default it is called every 5 seconds, so if doing statusUpdates every 81 | * 60 seconds and then resetting it, this will usually be 12. 82 | * 83 | * @var integer 84 | */ 85 | protected $filterCheckCount=0; 86 | 87 | /** 88 | * Total number of seconds (fractional) spent in the enqueueStatus() calls (i.e. the customized 89 | * function that handles each received tweet). 90 | * 91 | * @var float 92 | */ 93 | protected $enqueueSpent=0; 94 | 95 | /** 96 | * Total number of seconds (fractional) spent in the checkFilterPredicates() calls 97 | * 98 | * @var float 99 | */ 100 | protected $filterCheckSpent=0; 101 | 102 | /** 103 | * Number of seconds since the last tweet arrived (or the keep-alive newline) 104 | * 105 | * @var integer 106 | */ 107 | protected $idlePeriod=0; 108 | 109 | /** 110 | * The maximum value $this->idlePeriod has reached. 111 | * 112 | * @var integer 113 | */ 114 | protected $maxIdlePeriod=0; 115 | 116 | /** 117 | * Time spent on each call to enqueueStatus() (i.e. average time spent, in milliseconds, 118 | * spent processing received tweet). 119 | * 120 | * Simply: enqueueSpent divided by statusCount 121 | * Note: by default, calculated fresh for past 60 seconds, every 60 seconds. 122 | * 123 | * @var float 124 | */ 125 | protected $enqueueTimeMS=0; 126 | 127 | /** 128 | * Like $enqueueTimeMS but for the checkFilterPredicates() function. 129 | * @var float 130 | */ 131 | protected $filterCheckTimeMS=0; 132 | 133 | /** 134 | * Seconds since the last call to statusUpdate() 135 | * 136 | * Reset to zero after each call to statusUpdate() 137 | * Highest value it should ever reach is $this->avgPeriod 138 | * 139 | * @var integer 140 | */ 141 | protected $avgElapsed=0; 142 | 143 | // Config type vars - override in subclass if desired 144 | protected $connectFailuresMax = 20; 145 | protected $connectTimeout = 5; 146 | protected $readTimeout = 5; 147 | protected $idleReconnectTimeout = 90; 148 | protected $avgPeriod = 60; 149 | protected $status_length_base = 10; 150 | protected $userAgent = 'Phirehose/1.0RC +https://github.com/fennb/phirehose'; 151 | protected $filterCheckMin = 5; 152 | protected $filterUpdMin = 120; 153 | protected $tcpBackoff = 1; 154 | protected $tcpBackoffMax = 16; 155 | protected $httpBackoff = 10; 156 | protected $httpBackoffMax = 240; 157 | 158 | /** 159 | * Create a new Phirehose object attached to the appropriate twitter stream method. 160 | * Methods are: METHOD_FIREHOSE, METHOD_RETWEET, METHOD_SAMPLE, METHOD_FILTER, METHOD_LINKS, METHOD_USER, METHOD_SITE. Note: the method might cause the use of a different endpoint URL. 161 | * Formats are: FORMAT_JSON, FORMAT_XML 162 | * @see Phirehose::METHOD_SAMPLE 163 | * @see Phirehose::FORMAT_JSON 164 | * 165 | * @param string $username Any twitter username. When using oAuth, this is the 'oauth_token'. 166 | * @param string $password Any twitter password. When using oAuth this is you oAuth secret. 167 | * @param string $method 168 | * @param string $format 169 | * 170 | * @todo I've kept the "/2/" at the end of the URL for user streams, as that is what 171 | * was there before AND it works for me! But the official docs say to use /1.1/ 172 | * so that is what I have used for site. 173 | * https://dev.twitter.com/docs/api/1.1/get/user 174 | * 175 | * @todo Shouldn't really hard-code URL strings in this function. 176 | */ 177 | public function __construct($username, $password, $method = Phirehose::METHOD_SAMPLE, $format = self::FORMAT_JSON, $lang = FALSE) 178 | { 179 | $this->username = $username; 180 | $this->password = $password; 181 | $this->method = $method; 182 | $this->format = $format; 183 | $this->lang = $lang; 184 | switch($method){ 185 | case self::METHOD_USER:$this->URL_BASE = 'https://userstream.twitter.com/2/';break; 186 | case self::METHOD_SITE:$this->URL_BASE = 'https://sitestream.twitter.com/1.1/';break; 187 | default:break; //Stick to the default 188 | } 189 | } 190 | 191 | /** 192 | * Returns public statuses from or in reply to a set of users. Mentions ("Hello @user!") and implicit replies 193 | * ("@user Hello!" created without pressing the reply button) are not matched. It is up to you to find the integer 194 | * IDs of each twitter user. 195 | * Applies to: METHOD_FILTER 196 | * 197 | * @param array $userIds Array of Twitter integer userIDs 198 | */ 199 | public function setFollow($userIds) 200 | { 201 | $userIds = ($userIds === NULL) ? array() : $userIds; 202 | sort($userIds); // Non-optimal but necessary 203 | if ($this->followIds != $userIds) { 204 | $this->filterChanged = TRUE; 205 | } 206 | $this->followIds = $userIds; 207 | } 208 | 209 | /** 210 | * Returns an array of followed Twitter userIds (integers) 211 | * 212 | * @return array 213 | */ 214 | public function getFollow() 215 | { 216 | return $this->followIds; 217 | } 218 | 219 | /** 220 | * Specifies keywords to track. Track keywords are case-insensitive logical ORs. Terms are exact-matched, ignoring 221 | * punctuation. Phrases, keywords with spaces, are not supported. Queries are subject to Track Limitations. 222 | * Applies to: METHOD_FILTER 223 | * 224 | * See: http://apiwiki.twitter.com/Streaming-API-Documentation#TrackLimiting 225 | * 226 | * @param array $trackWords 227 | */ 228 | public function setTrack(array $trackWords) 229 | { 230 | $trackWords = ($trackWords === NULL) ? array() : $trackWords; 231 | sort($trackWords); // Non-optimal, but necessary 232 | if ($this->trackWords != $trackWords) { 233 | $this->filterChanged = TRUE; 234 | } 235 | $this->trackWords = $trackWords; 236 | } 237 | 238 | /** 239 | * Returns an array of keywords being tracked 240 | * 241 | * @return array 242 | */ 243 | public function getTrack() 244 | { 245 | return $this->trackWords; 246 | } 247 | 248 | /** 249 | * Specifies a set of bounding boxes to track as an array of 4 element lon/lat pairs denoting , 250 | * . Only tweets that are both created using the Geotagging API and are placed from within a tracked 251 | * bounding box will be included in the stream. The user's location field is not used to filter tweets. Bounding boxes 252 | * are logical ORs and must be less than or equal to 1 degree per side. A locations parameter may be combined with 253 | * track parameters, but note that all terms are logically ORd. 254 | * 255 | * NOTE: The argument order is Longitude/Latitude (to match the Twitter API and GeoJSON specifications). 256 | * 257 | * Applies to: METHOD_FILTER 258 | * 259 | * See: http://apiwiki.twitter.com/Streaming-API-Documentation#locations 260 | * 261 | * Eg: 262 | * setLocations(array( 263 | * array(-122.75, 36.8, -121.75, 37.8), // San Francisco 264 | * array(-74, 40, -73, 41), // New York 265 | * )); 266 | * 267 | * @param array $boundingBoxes 268 | */ 269 | public function setLocations($boundingBoxes) 270 | { 271 | $boundingBoxes = ($boundingBoxes === NULL) ? array() : $boundingBoxes; 272 | sort($boundingBoxes); // Non-optimal, but necessary 273 | // Flatten to single dimensional array 274 | $locationBoxes = array(); 275 | foreach ($boundingBoxes as $boundingBox) { 276 | // Sanity check 277 | if (count($boundingBox) != 4) { 278 | // Invalid - Not much we can do here but log error 279 | $this->log('Invalid location bounding box: [' . implode(', ', $boundingBox) . ']','error'); 280 | return FALSE; 281 | } 282 | // Append this lat/lon pairs to flattened array 283 | $locationBoxes = array_merge($locationBoxes, $boundingBox); 284 | } 285 | // If it's changed, make note 286 | if ($this->locationBoxes != $locationBoxes) { 287 | $this->filterChanged = TRUE; 288 | } 289 | // Set flattened value 290 | $this->locationBoxes = $locationBoxes; 291 | } 292 | 293 | /** 294 | * Returns an array of 4 element arrays that denote the monitored location bounding boxes for tweets using the 295 | * Geotagging API. 296 | * 297 | * @see setLocations() 298 | * @return array 299 | */ 300 | public function getLocations() { 301 | if ($this->locationBoxes == NULL) { 302 | return NULL; 303 | } 304 | $locationBoxes = $this->locationBoxes; // Copy array 305 | $ret = array(); 306 | while (count($locationBoxes) >= 4) { 307 | $ret[] = array_splice($locationBoxes, 0, 4); // Append to ret array in blocks of 4 308 | } 309 | return $ret; 310 | } 311 | 312 | /** 313 | * Convenience method that sets location bounding boxes by an array of lon/lat/radius sets, rather than manually 314 | * specified bounding boxes. Each array element should contain 3 element subarray containing a latitude, longitude and 315 | * radius. Radius is specified in kilometers and is approximate (as boxes are square). 316 | * 317 | * NOTE: The argument order is Longitude/Latitude (to match the Twitter API and GeoJSON specifications). 318 | * 319 | * Eg: 320 | * setLocationsByCircle(array( 321 | * array(144.9631, -37.8142, 30), // Melbourne, 3km radius 322 | * array(-0.1262, 51.5001, 25), // London 10km radius 323 | * )); 324 | * 325 | * 326 | * @see setLocations() 327 | * @param array 328 | */ 329 | public function setLocationsByCircle($locations) { 330 | $boundingBoxes = array(); 331 | foreach ($locations as $locTriplet) { 332 | // Sanity check 333 | if (count($locTriplet) != 3) { 334 | // Invalid - Not much we can do here but log error 335 | $this->log('Invalid location triplet for ' . __METHOD__ . ': [' . implode(', ', $locTriplet) . ']','error'); 336 | return FALSE; 337 | } 338 | list($lon, $lat, $radius) = $locTriplet; 339 | 340 | // Calc bounding boxes 341 | $maxLat = round($lat + rad2deg($radius / self::EARTH_RADIUS_KM), 2); 342 | $minLat = round($lat - rad2deg($radius / self::EARTH_RADIUS_KM), 2); 343 | // Compensate for degrees longitude getting smaller with increasing latitude 344 | $maxLon = round($lon + rad2deg($radius / self::EARTH_RADIUS_KM / cos(deg2rad($lat))), 2); 345 | $minLon = round($lon - rad2deg($radius / self::EARTH_RADIUS_KM / cos(deg2rad($lat))), 2); 346 | // Add to bounding box array 347 | $boundingBoxes[] = array($minLon, $minLat, $maxLon, $maxLat); 348 | // Debugging is handy 349 | $this->log('Resolved location circle [' . $lon . ', ' . $lat . ', r: ' . $radius . '] -> bbox: [' . $minLon . 350 | ', ' . $minLat . ', ' . $maxLon . ', ' . $maxLat . ']'); 351 | } 352 | // Set by bounding boxes 353 | $this->setLocations($boundingBoxes); 354 | } 355 | 356 | /** 357 | * Sets the number of previous statuses to stream before transitioning to the live stream. Applies only to firehose 358 | * and filter + track methods. This is generally used internally and should not be needed by client applications. 359 | * Applies to: METHOD_FILTER, METHOD_FIREHOSE, METHOD_LINKS 360 | * 361 | * @param integer $count 362 | */ 363 | public function setCount($count) 364 | { 365 | $this->count = $count; 366 | } 367 | 368 | /** 369 | * Restricts tweets to the given language, given by an ISO 639-1 code (http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 370 | * 371 | * @param string $lang 372 | */ 373 | public function setLang($lang) 374 | { 375 | $this->lang = $lang; 376 | } 377 | 378 | /** 379 | * Returns the ISO 639-1 code formatted language string of the current setting. (http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 380 | * 381 | * @param string $lang 382 | */ 383 | public function getLang() 384 | { 385 | return $this->lang; 386 | } 387 | 388 | /** 389 | * Connects to the stream API and consumes the stream. Each status update in the stream will cause a call to the 390 | * handleStatus() method. 391 | * 392 | * Note: in normal use this function does not return. 393 | * If you pass $reconnect as false, it will still not return in normal use: it will only return 394 | * if the remote side (Twitter) close the socket. (Or the socket dies for some other external reason.) 395 | * 396 | * @see handleStatus() 397 | * @param boolean $reconnect Reconnects as per recommended 398 | * @throws ErrorException 399 | */ 400 | public function consume($reconnect = TRUE) 401 | { 402 | // Persist connection? 403 | $this->reconnect = $reconnect; 404 | 405 | // Loop indefinitely based on reconnect 406 | do { 407 | 408 | // (Re)connect 409 | $this->reconnect(); 410 | 411 | // Init state 412 | $lastAverage = $lastFilterCheck = $lastFilterUpd = $lastStreamActivity = time(); 413 | $fdw = $fde = NULL; // Placeholder write/error file descriptors for stream_select 414 | 415 | // We use a blocking-select with timeout, to allow us to continue processing on idle streams 416 | //TODO: there is a bug lurking here. If $this->conn is fine, but $numChanged returns zero, because readTimeout was 417 | // reached, then we should consider we still need to call statusUpdate() every 60 seconds, etc. 418 | // ($this->readTimeout is 5 seconds.) This can be quite annoying. E.g. Been getting data regularly for 55 seconds, 419 | // then it goes quiet for just 10 or so seconds. It is now 65 seconds since last call to statusUpdate() has been 420 | // called, which might mean a monitoring system kills the script assuming it has died. 421 | while ($this->conn !== NULL && !feof($this->conn) && 422 | ($numChanged = stream_select($this->fdrPool, $fdw, $fde, $this->readTimeout)) !== FALSE) { 423 | /* Unfortunately, we need to do a safety check for dead twitter streams - This seems to be able to happen where 424 | * you end up with a valid connection, but NO tweets coming along the wire (or keep alives). The below guards 425 | * against this. 426 | */ 427 | if ((time() - $lastStreamActivity) > $this->idleReconnectTimeout) { 428 | $this->log('Idle timeout: No stream activity for > ' . $this->idleReconnectTimeout . ' seconds. ' . 429 | ' Reconnecting.','info'); 430 | $this->reconnect(); 431 | $lastStreamActivity = time(); 432 | continue; 433 | } 434 | // Process stream/buffer 435 | $this->fdrPool = array($this->conn); // Must reassign for stream_select() 436 | 437 | //Get a full HTTP chunk. 438 | //NB. This is a tight loop, not using stream_select. 439 | //NB. If that causes problems, then perhaps put something to give up after say trying for 10 seconds? (but 440 | // the stream will be all messed up, so will need to do a reconnect). 441 | $chunk_info=trim(fgets($this->conn)); //First line is hex digits giving us the length 442 | if($chunk_info=='')continue; //Usually indicates a time-out. If we wanted to be sure, 443 | //then stream_get_meta_data($this->conn)['timed_out']==1. (We could instead 444 | // look at the 'eof' member, which appears to be boolean false if just a time-out.) 445 | //TODO: need to consider calling statusUpdate() every 60 seconds, etc. 446 | 447 | // Track maximum idle period 448 | // (We got start of an HTTP chunk, this is stream activity) 449 | $this->idlePeriod = (time() - $lastStreamActivity); 450 | $this->maxIdlePeriod = ($this->idlePeriod > $this->maxIdlePeriod) ? $this->idlePeriod : $this->maxIdlePeriod; 451 | $lastStreamActivity = time(); 452 | 453 | //Append one HTTP chunk to $this->buff 454 | $len=hexdec($chunk_info); //$len includes the \r\n at the end of the chunk (despite what wikipedia says) 455 | //TODO: could do a check for data corruption here. E.g. if($len>100000){...} 456 | $s=''; 457 | $len+=2; //For the \r\n at the end of the chunk 458 | while(!feof($this->conn)){ 459 | $s.=fread($this->conn,$len-strlen($s)); 460 | if(strlen($s)>=$len)break; //TODO: Can never be >$len, only ==$len?? 461 | } 462 | $this->buff.=substr($s,0,-2); //This is our HTTP chunk 463 | 464 | //Process each full tweet inside $this->buff 465 | while(1){ 466 | $eol = strpos($this->buff,"\r\n"); //Find next line ending 467 | if($eol===0) { // if 0, then buffer starts with "\r\n", so trim it and loop again 468 | $this->buff = substr($this->buff,$eol+2); // remove the "\r\n" from line start 469 | continue; // loop again 470 | } 471 | if($eol===false)break; //Time to get more data 472 | $enqueueStart = microtime(TRUE); 473 | $this->enqueueStatus(substr($this->buff,0,$eol)); 474 | $this->enqueueSpent += (microtime(TRUE) - $enqueueStart); 475 | $this->statusCount++; 476 | $this->buff = substr($this->buff,$eol+2); //+2 to allow for the \r\n 477 | } 478 | 479 | //NOTE: if $this->buff is not empty, it is tempting to go round and get the next HTTP chunk, as 480 | // we know there is data on the incoming stream. However, this could mean the below functions (heartbeat 481 | // and statusUpdate) *never* get called, which would be bad. 482 | 483 | // Calc counter averages 484 | $this->avgElapsed = time() - $lastAverage; 485 | if ($this->avgElapsed >= $this->avgPeriod) { 486 | $this->statusRate = round($this->statusCount / $this->avgElapsed, 0); // Calc tweets-per-second 487 | // Calc time spent per enqueue in ms 488 | $this->enqueueTimeMS = ($this->statusCount > 0) ? 489 | round($this->enqueueSpent / $this->statusCount * 1000, 2) : 0; 490 | // Calc time spent total in filter predicate checking 491 | $this->filterCheckTimeMS = ($this->filterCheckCount > 0) ? 492 | round($this->filterCheckSpent / $this->filterCheckCount * 1000, 2) : 0; 493 | 494 | $this->heartbeat(); 495 | $this->statusUpdate(); 496 | $lastAverage = time(); 497 | } 498 | // Check if we're ready to check filter predicates 499 | if ($this->method == self::METHOD_FILTER && (time() - $lastFilterCheck) >= $this->filterCheckMin) { 500 | $this->filterCheckCount++; 501 | $lastFilterCheck = time(); 502 | $filterCheckStart = microtime(TRUE); 503 | $this->checkFilterPredicates(); // This should be implemented in subclass if required 504 | $this->filterCheckSpent += (microtime(TRUE) - $filterCheckStart); 505 | } 506 | // Check if filter is ready + allowed to be updated (reconnect) 507 | if ($this->filterChanged == TRUE && (time() - $lastFilterUpd) >= $this->filterUpdMin) { 508 | $this->log('Reconnecting due to changed filter predicates.','info'); 509 | $this->reconnect(); 510 | $lastFilterUpd = time(); 511 | } 512 | 513 | } // End while-stream-activity 514 | 515 | if (function_exists('pcntl_signal_dispatch')) { 516 | pcntl_signal_dispatch(); 517 | } 518 | 519 | // Some sort of socket error has occured 520 | $this->lastErrorNo = is_resource($this->conn) ? @socket_last_error($this->conn) : NULL; 521 | $this->lastErrorMsg = ($this->lastErrorNo > 0) ? @socket_strerror($this->lastErrorNo) : 'Socket disconnected'; 522 | $this->log('Phirehose connection error occured: ' . $this->lastErrorMsg,'error'); 523 | 524 | // Reconnect 525 | } while ($this->reconnect); 526 | 527 | // Exit 528 | $this->log('Exiting.'); 529 | 530 | } 531 | 532 | 533 | /** 534 | * Called every $this->avgPeriod (default=60) seconds, and this default implementation 535 | * calculates some rates, logs them, and resets the counters. 536 | */ 537 | protected function statusUpdate() 538 | { 539 | $this->log('Consume rate: ' . $this->statusRate . ' status/sec (' . $this->statusCount . ' total), avg ' . 540 | 'enqueueStatus(): ' . $this->enqueueTimeMS . 'ms, avg checkFilterPredicates(): ' . $this->filterCheckTimeMS . 'ms (' . 541 | $this->filterCheckCount . ' total) over ' . $this->avgElapsed . ' seconds, max stream idle period: ' . 542 | $this->maxIdlePeriod . ' seconds.'); 543 | // Reset 544 | $this->statusCount = $this->filterCheckCount = $this->enqueueSpent = 0; 545 | $this->filterCheckSpent = $this->idlePeriod = $this->maxIdlePeriod = 0; 546 | } 547 | 548 | /** 549 | * Returns the last error message (TCP or HTTP) that occured with the streaming API or client. State is cleared upon 550 | * successful reconnect 551 | * @return string 552 | */ 553 | public function getLastErrorMsg() 554 | { 555 | return $this->lastErrorMsg; 556 | } 557 | 558 | /** 559 | * Returns the last error number that occured with the streaming API or client. Numbers correspond to either the 560 | * fsockopen() error states (in the case of TCP errors) or HTTP error codes from Twitter (in the case of HTTP errors). 561 | * 562 | * State is cleared upon successful reconnect. 563 | * 564 | * @return string 565 | */ 566 | public function getLastErrorNo() 567 | { 568 | return $this->lastErrorNo; 569 | } 570 | 571 | 572 | /** 573 | * Connects to the stream URL using the configured method. 574 | * @throws ErrorException 575 | */ 576 | protected function connect() 577 | { 578 | 579 | // Init state 580 | $connectFailures = 0; 581 | $tcpRetry = $this->tcpBackoff / 2; 582 | $httpRetry = $this->httpBackoff / 2; 583 | 584 | // Keep trying until connected (or max connect failures exceeded) 585 | do { 586 | 587 | // Check filter predicates for every connect (for filter method) 588 | if ($this->method == self::METHOD_FILTER) { 589 | $this->checkFilterPredicates(); 590 | } 591 | 592 | // Construct URL/HTTP bits 593 | $url = $this->URL_BASE . $this->method . '.' . $this->format; 594 | $urlParts = parse_url($url); 595 | 596 | // Setup params appropriately 597 | $requestParams=array(); 598 | 599 | //$requestParams['delimited'] = 'length'; //No, we don't want this any more 600 | 601 | // Setup the language of the stream 602 | if($this->lang) { 603 | $requestParams['language'] = $this->lang; 604 | } 605 | 606 | // Filter takes additional parameters 607 | if ($this->method == self::METHOD_FILTER && count($this->trackWords) > 0) { 608 | $requestParams['track'] = implode(',', $this->trackWords); 609 | } 610 | if ( ($this->method == self::METHOD_FILTER || $this->method == self::METHOD_SITE) 611 | && count($this->followIds) > 0) { 612 | $requestParams['follow'] = implode(',', $this->followIds); 613 | } 614 | if ($this->method == self::METHOD_FILTER && count($this->locationBoxes) > 0) { 615 | $requestParams['locations'] = implode(',', $this->locationBoxes); 616 | } 617 | if ($this->count <> 0) { 618 | $requestParams['count'] = $this->count; 619 | } 620 | 621 | // Debugging is useful 622 | $this->log('Connecting to twitter stream: ' . $url . ' with params: ' . str_replace("\n", '', 623 | var_export($requestParams, TRUE))); 624 | 625 | /** 626 | * Open socket connection to make POST request. It'd be nice to use stream_context_create with the native 627 | * HTTP transport but it hides/abstracts too many required bits (like HTTP error responses). 628 | */ 629 | $errNo = $errStr = NULL; 630 | $scheme = ($urlParts['scheme'] == 'https') ? 'ssl://' : 'tcp://'; 631 | $port = ($urlParts['scheme'] == 'https') ? 443 : 80; 632 | 633 | /** 634 | * We must perform manual host resolution here as Twitter's IP regularly rotates (ie: DNS TTL of 60 seconds) and 635 | * PHP appears to cache it the result if in a long running process (as per Phirehose). 636 | */ 637 | $streamIPs = gethostbynamel($urlParts['host']); 638 | if(empty($streamIPs)) { 639 | throw new PhirehoseNetworkException("Unable to resolve hostname: '" . $urlParts['host'] . '"'); 640 | } 641 | 642 | // Choose one randomly (if more than one) 643 | $this->log('Resolved host ' . $urlParts['host'] . ' to ' . implode(', ', $streamIPs)); 644 | $streamIP = $streamIPs[rand(0, (count($streamIPs) - 1))]; 645 | $this->log("Connecting to {$scheme}{$streamIP}, port={$port}, connectTimeout={$this->connectTimeout}"); 646 | 647 | @$this->conn = fsockopen($scheme . $streamIP, $port, $errNo, $errStr, $this->connectTimeout); 648 | 649 | // No go - handle errors/backoff 650 | if (!$this->conn || !is_resource($this->conn)) { 651 | $this->lastErrorMsg = $errStr; 652 | $this->lastErrorNo = $errNo; 653 | $connectFailures++; 654 | if ($connectFailures > $this->connectFailuresMax) { 655 | $msg = 'TCP failure limit exceeded with ' . $connectFailures . ' failures. Last error: ' . $errStr; 656 | $this->log($msg,'error'); 657 | throw new PhirehoseConnectLimitExceeded($msg, $errNo); // Throw an exception for other code to handle 658 | } 659 | // Increase retry/backoff up to max 660 | $tcpRetry = ($tcpRetry < $this->tcpBackoffMax) ? $tcpRetry * 2 : $this->tcpBackoffMax; 661 | $this->log('TCP failure ' . $connectFailures . ' of ' . $this->connectFailuresMax . ' connecting to stream: ' . 662 | $errStr . ' (' . $errNo . '). Sleeping for ' . $tcpRetry . ' seconds.','info'); 663 | sleep($tcpRetry); 664 | continue; 665 | } 666 | 667 | // TCP connect OK, clear last error (if present) 668 | $this->log('Connection established to ' . $streamIP); 669 | $this->lastErrorMsg = NULL; 670 | $this->lastErrorNo = NULL; 671 | 672 | // If we have a socket connection, we can attempt a HTTP request - Ensure blocking read for the moment 673 | stream_set_blocking($this->conn, 1); 674 | 675 | // Encode request data 676 | $postData = http_build_query($requestParams, NULL, '&'); 677 | $postData = str_replace('+','%20',$postData); //Change it from RFC1738 to RFC3986 (see 678 | //enc_type parameter in http://php.net/http_build_query and note that enc_type is 679 | //not available as of php 5.3) 680 | $authCredentials = $this->getAuthorizationHeader($url,$requestParams); 681 | 682 | // Do it 683 | $s = "POST " . $urlParts['path'] . " HTTP/1.1\r\n"; 684 | $s.= "Host: " . $urlParts['host'] . ':' . $port . "\r\n"; 685 | $s .= "Connection: Close\r\n"; 686 | $s.= "Content-type: application/x-www-form-urlencoded\r\n"; 687 | $s.= "Content-length: " . strlen($postData) . "\r\n"; 688 | $s.= "Accept: */*\r\n"; 689 | $s.= 'Authorization: ' . $authCredentials . "\r\n"; 690 | $s.= 'User-Agent: ' . $this->userAgent . "\r\n"; 691 | $s.= "\r\n"; 692 | $s.= $postData . "\r\n"; 693 | $s.= "\r\n"; 694 | 695 | fwrite($this->conn, $s); 696 | $this->log($s); 697 | 698 | // First line is response 699 | list($httpVer, $httpCode, $httpMessage) = preg_split('/\s+/', trim(fgets($this->conn, 1024)), 3); 700 | 701 | // Response buffers 702 | $respHeaders = $respBody = ''; 703 | $isChunking = false; 704 | 705 | // Consume each header response line until we get to body 706 | while ($hLine = trim(fgets($this->conn, 4096))) { 707 | $respHeaders .= $hLine."\n"; 708 | if($hLine=='Transfer-Encoding: chunked')$isChunking=true; 709 | } 710 | 711 | // If we got a non-200 response, we need to backoff and retry 712 | if ($httpCode != 200) { 713 | $connectFailures++; 714 | 715 | // Twitter will disconnect on error, but we want to consume the rest of the response body (which is useful) 716 | //TODO: this might be chunked too? In which case this contains some bad characters?? 717 | while ($bLine = trim(fgets($this->conn, 4096))) { 718 | $respBody .= $bLine; 719 | } 720 | 721 | // Construct error 722 | $errStr = 'HTTP ERROR ' . $httpCode . ': ' . $httpMessage . ' (' . $respBody . ')'; 723 | 724 | // Set last error state 725 | $this->lastErrorMsg = $errStr; 726 | $this->lastErrorNo = $httpCode; 727 | 728 | // Have we exceeded maximum failures? 729 | if ($connectFailures > $this->connectFailuresMax) { 730 | $msg = 'Connection failure limit exceeded with ' . $connectFailures . ' failures. Last error: ' . $errStr; 731 | $this->log($msg,'error'); 732 | throw new PhirehoseConnectLimitExceeded($msg, $httpCode); // We eventually throw an exception for other code to handle 733 | } 734 | // Increase retry/backoff up to max 735 | $httpRetry = ($httpRetry < $this->httpBackoffMax) ? $httpRetry * 2 : $this->httpBackoffMax; 736 | $this->log('HTTP failure ' . $connectFailures . ' of ' . $this->connectFailuresMax . ' connecting to stream: ' . 737 | $errStr . '. Sleeping for ' . $httpRetry . ' seconds.','info'); 738 | sleep($httpRetry); 739 | continue; 740 | 741 | } // End if not http 200 742 | else{ 743 | if(!$isChunking)throw new Exception("Twitter did not send a chunking header. Is this really HTTP/1.1? Here are headers:\n$respHeaders"); //TODO: rather crude! 744 | } 745 | 746 | // Loop until connected OK 747 | } while (!is_resource($this->conn) || $httpCode != 200); 748 | 749 | // Connected OK, reset connect failures 750 | $connectFailures = 0; 751 | $this->lastErrorMsg = NULL; 752 | $this->lastErrorNo = NULL; 753 | 754 | // Switch to non-blocking to consume the stream (important) 755 | stream_set_blocking($this->conn, 0); 756 | 757 | // Connect always causes the filterChanged status to be cleared 758 | $this->filterChanged = FALSE; 759 | 760 | // Flush stream buffer & (re)assign fdrPool (for reconnect) 761 | $this->fdrPool = array($this->conn); 762 | $this->buff = ''; 763 | 764 | } 765 | 766 | protected function getAuthorizationHeader($url,$requestParams) 767 | { 768 | throw new Exception("Basic auth no longer works with Twitter. You must derive from OauthPhirehose, not directly from the Phirehose class."); 769 | $authCredentials = base64_encode($this->username . ':' . $this->password); 770 | return "Basic: ".$authCredentials; 771 | } 772 | 773 | /** 774 | * Method called as frequently as practical (every 5+ seconds) that is responsible for checking if filter predicates 775 | * (ie: track words or follow IDs) have changed. If they have, they should be set using the setTrack() and setFollow() 776 | * methods respectively within the overridden implementation. 777 | * 778 | * Note that even if predicates are changed every 5 seconds, an actual reconnect will not happen more frequently than 779 | * every 2 minutes (as per Twitter Streaming API documentation). 780 | * 781 | * Note also that this method is called upon every connect attempt, so if your predicates are causing connection 782 | * errors, they should be checked here and corrected. 783 | * 784 | * This should be implemented/overridden in any subclass implementing the FILTER method. 785 | * 786 | * @see setTrack() 787 | * @see setFollow() 788 | * @see Phirehose::METHOD_FILTER 789 | */ 790 | protected function checkFilterPredicates() 791 | { 792 | // Override in subclass 793 | } 794 | 795 | /** 796 | * Basic log function that outputs logging to the standard error_log() handler. This should generally be overridden 797 | * to suit the application environment. 798 | * 799 | * @see error_log() 800 | * @param string $messages 801 | * @param String $level 'error', 'info', 'notice'. Defaults to 'notice', so you should set this 802 | * parameter on the more important error messages. 803 | * 'info' is used for problems that the class should be able to recover from automatically. 804 | * 'error' is for exceptional conditions that may need human intervention. (For instance, emailing 805 | * them to a system administrator may make sense.) 806 | */ 807 | protected function log($message,$level='notice') 808 | { 809 | @error_log('Phirehose: ' . $message, 0); 810 | } 811 | 812 | /** 813 | * Performs forcible disconnect from stream (if connected) and cleanup. 814 | */ 815 | protected function disconnect() 816 | { 817 | if (is_resource($this->conn)) { 818 | $this->log('Closing Phirehose connection.'); 819 | fclose($this->conn); 820 | } 821 | $this->conn = NULL; 822 | $this->reconnect = FALSE; 823 | } 824 | 825 | /** 826 | * Reconnects as quickly as possible. Should be called whenever a reconnect is required rather that connect/disconnect 827 | * to preserve streams reconnect state 828 | */ 829 | private function reconnect() 830 | { 831 | $reconnect = $this->reconnect; 832 | $this->disconnect(); // Implicitly sets reconnect to FALSE 833 | $this->reconnect = $reconnect; // Restore state to prev 834 | $this->connect(); 835 | } 836 | 837 | /** 838 | * This is the one and only method that must be implemented additionally. As per the streaming API documentation, 839 | * statuses should NOT be processed within the same process that is performing collection 840 | * 841 | * @param string $status 842 | */ 843 | abstract public function enqueueStatus($status); 844 | 845 | /** 846 | * Reports a periodic heartbeat. Keep execution time minimal. 847 | * 848 | * @return NULL 849 | */ 850 | public function heartbeat() {} 851 | 852 | } // End of class 853 | 854 | class PhirehoseException extends Exception {} 855 | class PhirehoseNetworkException extends PhirehoseException {} 856 | class PhirehoseConnectLimitExceeded extends PhirehoseException {} 857 | -------------------------------------------------------------------------------- /phirehose/UserstreamPhirehose.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | name 5 | active 6 | last_run 7 | next_run 8 | total_run_count 9 | 10 | MyAction 11 | 1 12 | 2011-04-22 12:30:00 13 | 2011-04-22 13:31:00 14 | 45 15 | 16 | 17 | OtherAction 18 | 0 19 | 2011-04-01 00:00:01 20 | 2011-04-02 12:34:56 21 | 1 22 | 23 |
24 | 25 | postid 26 | text 27 | user 28 | date_saved 29 | modeled 30 | 31 | 0 32 | Modeled post 33 | some1 34 | 2011-04-21 10:31:54 35 | 1 36 | 37 | 38 | 1 39 | This is a random-ass twitter post 40 | derpderp 41 | 2011-04-22 12:30:31 42 | 0 43 | 44 | 45 | 2 46 | @derpderp: sup mr random-ass bs 47 | orly 48 | 2011-04-22 12:45:12 49 | 0 50 | 51 | 52 | 3 53 | RT @orly this guy is a dumbass 54 | derpderp 55 | 2011-04-22 13:03:34 56 | 0 57 | 58 | 59 | 4 60 | @derpderp your bs posts are dumbass haha :) 61 | orly 62 | 2011-04-22 16:46:23 63 | 0 64 | 65 | 66 | 5 67 | @orly don't :) at me 68 | derpderp 69 | 2011-04-22 19:16:36 70 | 0 71 | 72 |
73 | 74 | eventid 75 | eventtime 76 | message 77 | 78 | 0 79 | 2011-04-22 13:51:36 80 | This is a test message. 81 | 82 |
83 |
-------------------------------------------------------------------------------- /phpunit/tests/DatabaseTest.php: -------------------------------------------------------------------------------- 1 | createXMLDataSet('..' . DIRECTORY_SEPARATOR . 'data' . 15 | DIRECTORY_SEPARATOR . 'testDelayedMarkovPostAction.xml'); 16 | } 17 | 18 | } 19 | 20 | ?> -------------------------------------------------------------------------------- /phpunit/tests/MarkovTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /run.php: -------------------------------------------------------------------------------- 1 | 2 || in_array('-h', $argv) || in_array('--help', $argv)) { 12 | die(printHelp()); 13 | } 14 | 15 | if (in_array('--stop', $argv)) { 16 | // stop has been set! try to kill it 17 | echo "\n" . '==SHUTTING DOWN TWITTERBOT==' . "\n"; 18 | halt(); 19 | exit; 20 | } 21 | 22 | if (in_array('--tests-only', $argv)) { 23 | test(); 24 | exit; 25 | } 26 | 27 | if (!in_array('--tests-skip', $argv)) { 28 | test(); 29 | } 30 | 31 | echo "\n" . '==POWERING UP TWITTERBOT==' . "\n"; 32 | 33 | // gain the lockfile 34 | $fp = @fopen(TWITTERBOT_LOCKFILE, "a"); 35 | if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) { 36 | die("Failed to acquire lock. Twitterbot may already be running.\n"); 37 | } 38 | 39 | // this bit guarantees that the process is 1) detached, and 2) independent 40 | echo 'Forking process into a daemon. Goodbye :)' . "\n"; 41 | if (pcntl_fork()) { 42 | exit; 43 | } 44 | posix_setsid(); 45 | if (pcntl_fork()) { 46 | exit; 47 | } 48 | 49 | // write the PIDs 50 | fwrite($fp, getmypid() . "\n"); 51 | fflush($fp); 52 | 53 | // start the loop 54 | $engine = new Twitterbot(); 55 | $engine->loop(); 56 | 57 | // reaching this point means an exit command has been issued 58 | flock($fp, LOCK_UN); 59 | fclose($fp); 60 | @unlink(TWITTERBOT_LOCKFILE); 61 | 62 | /** 63 | * Runs tests against the configuration of this bot, to make sure everything 64 | * is in working order. 65 | */ 66 | function test() { 67 | // test the database connection parameters 68 | echo "\n" . '==TESTING TWITTERBOT==' . "\n"; 69 | echo 'Testing MySQL connection...'; 70 | $db = @mysql_connect(DB_HOST, DB_USER, DB_PASS); 71 | if (!$db) { 72 | die('ERROR: Invalid MySQL connection parameters: ' . mysql_error() . "\n"); 73 | } 74 | echo 'passed!' . "\n"; 75 | mysql_close($db); 76 | 77 | // test the the curl libraries are installed 78 | echo 'Testing that you have curl installed...'; 79 | if (!function_exists('curl_init')) { 80 | die('ERROR: You do not seem to have a curl library for PHP. Please '. 81 | 'check your PHP configuration.'); 82 | } 83 | echo 'passed!' . "\n"; 84 | 85 | // test Twitter OAuth settings 86 | echo 'Testing OAuth credentials...'; 87 | $obj = new TwitterOAuth(TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, OAUTH_TOKEN, 88 | OAUTH_TOKEN_SECRET); 89 | $retval = $obj->get('account/verify_credentials'); 90 | if (!is_object($retval) || !isset($retval->name)) { 91 | die('ERROR: Unable to successfully establish an OAuth connection with' . 92 | ' Twitter.' . "\n"); 93 | } 94 | if ($retval->screen_name != BOT_ACCOUNT) { 95 | die('ERROR: The BOT_ACCOUNT indicated in configuration differs from' . 96 | ' what the Twitter API said.' . "\n"); 97 | } else { 98 | echo 'passed!' . "\n"; 99 | } 100 | 101 | // test the actions listed in the configuration file 102 | global $actions; 103 | foreach ($actions as $action) { 104 | echo 'Found action "' . $action['name'] . '", checking required fields...'; 105 | if (isset($action['name']) && isset($action['file']) && 106 | isset($action['class']) && isset($action['active'])) { 107 | echo "passed!\n"; 108 | } else { 109 | die('ERROR: One or more required fields are missing in your ' . 110 | 'config.php for an action.' . "\n"); 111 | } 112 | echo 'Checking instantiability of ' . $action['class'] . '...'; 113 | if (!file_exists(ACTIONS . $action['file'])) { 114 | die('ERROR: Unable to find class file for the custom Action.' . "\n"); 115 | } 116 | include_once(ACTIONS . $action['file']); 117 | $class = new ReflectionClass($action['class']); 118 | if (!$class->isInstantiable()) { 119 | die('ERROR: Unable to instantiate class ' . $action['class'] . ".\n"); 120 | } 121 | echo 'passed!' . "\n"; 122 | } 123 | 124 | // finally, test a few PHP dependencies 125 | echo 'Looking for pcntl_fork()...'; 126 | if (!function_exists('pcntl_fork')) { 127 | die('ERROR: pcntl_fork() is undefined. Please check your PHP ' . 128 | 'configuration.' . "\n"); 129 | } 130 | echo 'passed!' . "\n"; 131 | } 132 | 133 | /** 134 | * Constructs a string with the help information for running this program. 135 | * @return The string 136 | */ 137 | function printHelp() { 138 | $retval = "Twitterbot, v2.0\n\n" . 139 | "php run.php [--start | --stop | --tests-only | --tests-skip " . 140 | "| --help ]\n\n" . 141 | "--start\t\t\tStart the twitterbot daemon\n" . 142 | "--stop\t\t\tStop the twitterbot daemon\n" . 143 | "--tests-only\t\tExecute the pre-process tests and exit\n" . 144 | "--tests-skip\t\tDon't run any tests before launching the daemon\n" . 145 | "--help or -h\t\tPrints this help\n"; 146 | return $retval; 147 | } 148 | 149 | /** 150 | * This function attempts to gracefully shut down the program. 151 | */ 152 | function halt() { 153 | // first, read the PID from the lockfile 154 | echo 'Reading the lockfile...' . "\n"; 155 | $contents = @file(TWITTERBOT_LOCKFILE); 156 | if (!$contents) { 157 | die('ERROR: Failed to acquire lock. Twitterbot may not be running.' . "\n"); 158 | } 159 | $pid = intval($contents[0]); 160 | echo 'Found process with id ' . $pid . ', killing...' . "\n"; 161 | // next, send a kill process; the handlers will take care of it from there 162 | posix_kill($pid, SIGTERM); 163 | echo 'Shutdown complete!' . "\n"; 164 | } 165 | 166 | ?> 167 | -------------------------------------------------------------------------------- /storage.php: -------------------------------------------------------------------------------- 1 | db = @mysql_connect($host, $user, $pass); 38 | if (!$this->db) { 39 | die('ERROR: Unable to access the database. ' . mysql_error() . "\n"); 40 | } 41 | mysql_select_db($name); 42 | $this->query = 'SHOW TABLES'; 43 | } 44 | 45 | /** 46 | * Destructor 47 | */ 48 | public function __destruct() { 49 | mysql_close($this->db); 50 | } 51 | 52 | /** 53 | * Sets the current query string. 54 | * @param string $query 55 | */ 56 | public function setQuery($query) { 57 | $this->query = $query; 58 | } 59 | 60 | /** 61 | * Executes the set query string and returns any pertinant results. 62 | * @return An array of the results, or number of updated tables. 63 | */ 64 | public function query() { 65 | // first, perform the query 66 | $result = mysql_query($this->query); 67 | 68 | // what was the return value? 69 | if ($result === false) { 70 | // error! 71 | die('ERROR: MySQL query failed. ' . mysql_error() . "\n---\n" . 72 | $this->query . "\n"); 73 | } else if ($result === true) { 74 | // update, insert, delete, etc statement, return the number of 75 | // affected rows 76 | return mysql_affected_rows(); 77 | } else { 78 | // select statement, return the results 79 | $retval = array(); 80 | while ($row = mysql_fetch_array($result)) { 81 | $retval[] = $row; 82 | } 83 | return $retval; 84 | } 85 | } 86 | 87 | /** 88 | * Retrieves a specific number of twitter posts stored in the table. A nice 89 | * utility function, given this will probably make up most of the database 90 | * accesses. 91 | * 92 | * @param boolean $unmodeled If true, this returns the $number most recent 93 | * posts marked as unmodeled. If false, this returns the $number most 94 | * recent posts (regardless of their modeled/unmodeled state). 95 | * @param int $number The number of posts to retrieve. If this is not 96 | * specified (or set to be <= 0), all posts are retrieved (with respect 97 | * to the $unmodeled parameter). 98 | * @return array List of twitter posts. 99 | */ 100 | public function getPosts($unmodeled, $number = 0) { 101 | $query = 'SELECT `text`, `date_saved` FROM `' . DB_NAME . '`.`' . 102 | POST_TABLE . '`' . (isset($unmodeled) && $unmodeled ? ' WHERE ' . 103 | '`modeled` = 0' : '') . ' ORDER BY `date_saved` DESC' . ($number > 0 ? 104 | ' LIMIT ' . $number : ''); 105 | $this->setQuery($query); 106 | return $this->query(); 107 | } 108 | 109 | /** 110 | * Another utility method. This saves a post to the database. 111 | * @param string $status 112 | * @param string $user 113 | */ 114 | public function savePost($status, $user) { 115 | $sql = 'INSERT INTO `' . DB_NAME . '`.`' . POST_TABLE . 116 | '` (text, user, date_saved, modeled) ' . 'VALUES ("' . 117 | mysql_real_escape_string(urldecode($status)) . '", "' . 118 | mysql_real_escape_string($user) . '", "' . date('Y-m-d H:i:s') . '", 0)'; 119 | $this->setQuery($sql); 120 | $this->query(); 121 | } 122 | 123 | /** 124 | * Utility method for marking a large range of saved posts as "modeled". 125 | * This works inclusively: the range of posts marked as "modeled" includes 126 | * those with the timestamps. 127 | * 128 | * @param string $old_date The oldest date post to mark as modeled. 129 | * @param string $recent_date The most recent date post. 130 | */ 131 | public function markPostsModeled($old_date, $recent_date) { 132 | $sql = 'UPDATE `' . DB_NAME . '`.`' . POST_TABLE . 133 | '` SET modeled = 1 WHERE date_saved BETWEEN "' . $old_date . 134 | '" AND "' . $recent_date . '"'; 135 | $this->setQuery($sql); 136 | $this->query(); 137 | } 138 | 139 | /** 140 | * Utility method for saving a log entry to the database. 141 | * @param string $sender Author of the message. 142 | * @param string $message Message itself. 143 | */ 144 | public function log($sender, $message) { 145 | $sql = 'INSERT INTO `' . DB_NAME . '`.`' . LOG_TABLE . 146 | '` (eventtime, message) ' . 'VALUES ("' . date('Y-m-d H:i:s') . 147 | '", "' . mysql_real_escape_string($sender . ': ' . $message) . '")'; 148 | $this->setQuery($sql); 149 | $this->query(); 150 | } 151 | } 152 | 153 | ?> 154 | -------------------------------------------------------------------------------- /twitterbot.php: -------------------------------------------------------------------------------- 1 | isInstantiable()) { 35 | $item = $class->newInstance($action['name'], $action['active'], 36 | (isset($action['args']) ? $action['args'] : array())); 37 | if ($item->isActive()) { 38 | $this->actions[] = $item; 39 | } 40 | } else { 41 | die('Twitterbot: ERROR: ' . $action['name'] . ' is not instantiable!'); 42 | } 43 | } 44 | $this->aggregator = new DataAggregator(OAUTH_TOKEN, OAUTH_TOKEN_SECRET, 45 | Phirehose::METHOD_SAMPLE); 46 | $this->aggregator->setLang('en'); // Set language to filter specific tweets. 47 | } 48 | 49 | /** 50 | * Sorts the actions based on which is to execute next. 51 | * @param Action $a 52 | * @param Action $b 53 | * @return -1 if $a < $b, 1 if $a > $b, 0 if $a == $b 54 | */ 55 | private function nextAttemptSort($a, $b) { 56 | if ($a->getNextAttempt() == $b->getNextAttempt()) { 57 | return 0; 58 | } 59 | return ($a->getNextAttempt() < $b->getNextAttempt() ? -1 : 1); 60 | } 61 | 62 | /** 63 | * Returns the next action to fire. 64 | * @return Action The next action to fire. 65 | */ 66 | private function next() { 67 | usort($this->actions, array($this, 'nextAttemptSort')); 68 | return (count($this->actions) > 0 ? $this->actions[0] : null); 69 | } 70 | 71 | /** 72 | * This is the main function of this class. This registers any needed 73 | * signal handlers, starts an infinite loop, and fires any events 74 | * as they need to be fired. 75 | */ 76 | public function loop() { 77 | declare(ticks = 1); 78 | 79 | // spin off the aggregator 80 | if (($pid = pcntl_fork())) { 81 | $this->current[$pid] = $this->aggregator; 82 | } else { 83 | exit($this->aggregator->consume()); 84 | } 85 | 86 | // set up signal handlers 87 | pcntl_signal(SIGCHLD, array($this, "sig_child")); 88 | pcntl_signal(SIGTERM, array($this, "sig_kill")); 89 | 90 | // now start all the other actions 91 | while (1) { 92 | // do we exit? 93 | if ($this->exit) { 94 | return; 95 | } 96 | 97 | // determine the next action that should fire 98 | $now = time(); 99 | $action = $this->next(); 100 | if ($action == null) { 101 | // in this case, just the aggregator is running, 102 | // so we can in fact safely quit! 103 | $this->exit = true; 104 | continue; 105 | } 106 | if ($now < $action->getNextAttempt()) { 107 | // sleep until the next action has to fire 108 | sleep($action->getNextAttempt() - $now); 109 | continue; 110 | } 111 | $action->setNextAttempt(); 112 | if ($pid = pcntl_fork()) { 113 | // parent process 114 | $this->current[$pid] = $action; 115 | } else { 116 | // child process 117 | pcntl_alarm($action->getTimeout()); 118 | exit($action->run()); 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * Signal handler for child processes that have exited via SIGCHLD. 125 | * @param int $signal 126 | */ 127 | private function sig_child($signal) { 128 | $status = Action::FAILURE; 129 | while (($pid = pcntl_wait($status, WNOHANG)) > 0) { 130 | $action = $this->current[$pid]; 131 | unset($this->current[$pid]); 132 | if (pcntl_wifexited($status) && 133 | pcntl_wexitstatus($status) == Action::SUCCESS) { 134 | $status = Action::SUCCESS; 135 | } 136 | if ($action != $this->aggregator) { 137 | $action->post_run($status); 138 | } else { 139 | // the aggregator failed! this is a problem 140 | $db = Storage::getDatabase(); 141 | $db->log('Twitterbot', 'Aggregator crashed! Exiting.'); 142 | unset($db); 143 | exit; 144 | } 145 | } 146 | } 147 | 148 | /** 149 | * Signal handler for SIGTERM and SIGINT, conducts a graceful shutdown if 150 | * the user forcefully quits the parent process for the twitterbot. 151 | * @param int $signal 152 | */ 153 | private function sig_kill($signal) { 154 | // send a kill signal to all the processes still running 155 | foreach ($this->current as $pid => $action) { 156 | // send the SIGTERM signal to the child 157 | posix_kill($pid, SIGTERM); 158 | } 159 | // set the flag to kill the parent process 160 | $this->exit = true; 161 | } 162 | } 163 | 164 | ?> 165 | -------------------------------------------------------------------------------- /twitteroauth/OAuth.php: -------------------------------------------------------------------------------- 1 | key = $key; 18 | $this->secret = $secret; 19 | $this->callback_url = $callback_url; 20 | } 21 | 22 | function __toString() { 23 | return "OAuthConsumer[key=$this->key,secret=$this->secret]"; 24 | } 25 | } 26 | 27 | class OAuthToken { 28 | // access tokens and request tokens 29 | public $key; 30 | public $secret; 31 | 32 | /** 33 | * key = the token 34 | * secret = the token secret 35 | */ 36 | function __construct($key, $secret) { 37 | $this->key = $key; 38 | $this->secret = $secret; 39 | } 40 | 41 | /** 42 | * generates the basic string serialization of a token that a server 43 | * would respond to request_token and access_token calls with 44 | */ 45 | function to_string() { 46 | return "oauth_token=" . 47 | OAuthUtil::urlencode_rfc3986($this->key) . 48 | "&oauth_token_secret=" . 49 | OAuthUtil::urlencode_rfc3986($this->secret); 50 | } 51 | 52 | function __toString() { 53 | return $this->to_string(); 54 | } 55 | } 56 | 57 | /** 58 | * A class for implementing a Signature Method 59 | * See section 9 ("Signing Requests") in the spec 60 | */ 61 | abstract class OAuthSignatureMethod { 62 | /** 63 | * Needs to return the name of the Signature Method (ie HMAC-SHA1) 64 | * @return string 65 | */ 66 | abstract public function get_name(); 67 | 68 | /** 69 | * Build up the signature 70 | * NOTE: The output of this function MUST NOT be urlencoded. 71 | * the encoding is handled in OAuthRequest when the final 72 | * request is serialized 73 | * @param OAuthRequest $request 74 | * @param OAuthConsumer $consumer 75 | * @param OAuthToken $token 76 | * @return string 77 | */ 78 | abstract public function build_signature($request, $consumer, $token); 79 | 80 | /** 81 | * Verifies that a given signature is correct 82 | * @param OAuthRequest $request 83 | * @param OAuthConsumer $consumer 84 | * @param OAuthToken $token 85 | * @param string $signature 86 | * @return bool 87 | */ 88 | public function check_signature($request, $consumer, $token, $signature) { 89 | $built = $this->build_signature($request, $consumer, $token); 90 | return $built == $signature; 91 | } 92 | } 93 | 94 | /** 95 | * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] 96 | * where the Signature Base String is the text and the key is the concatenated values (each first 97 | * encoded per Parameter Encoding) of the Consumer Secret and Token Secret, separated by an '&' 98 | * character (ASCII code 38) even if empty. 99 | * - Chapter 9.2 ("HMAC-SHA1") 100 | */ 101 | class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod { 102 | function get_name() { 103 | return "HMAC-SHA1"; 104 | } 105 | 106 | public function build_signature($request, $consumer, $token) { 107 | $base_string = $request->get_signature_base_string(); 108 | $request->base_string = $base_string; 109 | 110 | $key_parts = array( 111 | $consumer->secret, 112 | ($token) ? $token->secret : "" 113 | ); 114 | 115 | $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); 116 | $key = implode('&', $key_parts); 117 | 118 | return base64_encode(hash_hmac('sha1', $base_string, $key, true)); 119 | } 120 | } 121 | 122 | /** 123 | * The PLAINTEXT method does not provide any security protection and SHOULD only be used 124 | * over a secure channel such as HTTPS. It does not use the Signature Base String. 125 | * - Chapter 9.4 ("PLAINTEXT") 126 | */ 127 | class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod { 128 | public function get_name() { 129 | return "PLAINTEXT"; 130 | } 131 | 132 | /** 133 | * oauth_signature is set to the concatenated encoded values of the Consumer Secret and 134 | * Token Secret, separated by a '&' character (ASCII code 38), even if either secret is 135 | * empty. The result MUST be encoded again. 136 | * - Chapter 9.4.1 ("Generating Signatures") 137 | * 138 | * Please note that the second encoding MUST NOT happen in the SignatureMethod, as 139 | * OAuthRequest handles this! 140 | */ 141 | public function build_signature($request, $consumer, $token) { 142 | $key_parts = array( 143 | $consumer->secret, 144 | ($token) ? $token->secret : "" 145 | ); 146 | 147 | $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); 148 | $key = implode('&', $key_parts); 149 | $request->base_string = $key; 150 | 151 | return $key; 152 | } 153 | } 154 | 155 | /** 156 | * The RSA-SHA1 signature method uses the RSASSA-PKCS1-v1_5 signature algorithm as defined in 157 | * [RFC3447] section 8.2 (more simply known as PKCS#1), using SHA-1 as the hash function for 158 | * EMSA-PKCS1-v1_5. It is assumed that the Consumer has provided its RSA public key in a 159 | * verified way to the Service Provider, in a manner which is beyond the scope of this 160 | * specification. 161 | * - Chapter 9.3 ("RSA-SHA1") 162 | */ 163 | abstract class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { 164 | public function get_name() { 165 | return "RSA-SHA1"; 166 | } 167 | 168 | // Up to the SP to implement this lookup of keys. Possible ideas are: 169 | // (1) do a lookup in a table of trusted certs keyed off of consumer 170 | // (2) fetch via http using a url provided by the requester 171 | // (3) some sort of specific discovery code based on request 172 | // 173 | // Either way should return a string representation of the certificate 174 | protected abstract function fetch_public_cert(&$request); 175 | 176 | // Up to the SP to implement this lookup of keys. Possible ideas are: 177 | // (1) do a lookup in a table of trusted certs keyed off of consumer 178 | // 179 | // Either way should return a string representation of the certificate 180 | protected abstract function fetch_private_cert(&$request); 181 | 182 | public function build_signature($request, $consumer, $token) { 183 | $base_string = $request->get_signature_base_string(); 184 | $request->base_string = $base_string; 185 | 186 | // Fetch the private key cert based on the request 187 | $cert = $this->fetch_private_cert($request); 188 | 189 | // Pull the private key ID from the certificate 190 | $privatekeyid = openssl_get_privatekey($cert); 191 | 192 | // Sign using the key 193 | $ok = openssl_sign($base_string, $signature, $privatekeyid); 194 | 195 | // Release the key resource 196 | openssl_free_key($privatekeyid); 197 | 198 | return base64_encode($signature); 199 | } 200 | 201 | public function check_signature($request, $consumer, $token, $signature) { 202 | $decoded_sig = base64_decode($signature); 203 | 204 | $base_string = $request->get_signature_base_string(); 205 | 206 | // Fetch the public key cert based on the request 207 | $cert = $this->fetch_public_cert($request); 208 | 209 | // Pull the public key ID from the certificate 210 | $publickeyid = openssl_get_publickey($cert); 211 | 212 | // Check the computed signature against the one passed in the query 213 | $ok = openssl_verify($base_string, $decoded_sig, $publickeyid); 214 | 215 | // Release the key resource 216 | openssl_free_key($publickeyid); 217 | 218 | return $ok == 1; 219 | } 220 | } 221 | 222 | class OAuthRequest { 223 | private $parameters; 224 | private $http_method; 225 | private $http_url; 226 | // for debug purposes 227 | public $base_string; 228 | public static $version = '1.0'; 229 | public static $POST_INPUT = 'php://input'; 230 | 231 | function __construct($http_method, $http_url, $parameters=NULL) { 232 | @$parameters or $parameters = array(); 233 | $parameters = array_merge( OAuthUtil::parse_parameters(parse_url($http_url, PHP_URL_QUERY)), $parameters); 234 | $this->parameters = $parameters; 235 | $this->http_method = $http_method; 236 | $this->http_url = $http_url; 237 | } 238 | 239 | 240 | /** 241 | * attempt to build up a request from what was passed to the server 242 | */ 243 | public static function from_request($http_method=NULL, $http_url=NULL, $parameters=NULL) { 244 | $scheme = (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] != "on") 245 | ? 'http' 246 | : 'https'; 247 | @$http_url or $http_url = $scheme . 248 | '://' . $_SERVER['HTTP_HOST'] . 249 | ':' . 250 | $_SERVER['SERVER_PORT'] . 251 | $_SERVER['REQUEST_URI']; 252 | @$http_method or $http_method = $_SERVER['REQUEST_METHOD']; 253 | 254 | // We weren't handed any parameters, so let's find the ones relevant to 255 | // this request. 256 | // If you run XML-RPC or similar you should use this to provide your own 257 | // parsed parameter-list 258 | if (!$parameters) { 259 | // Find request headers 260 | $request_headers = OAuthUtil::get_headers(); 261 | 262 | // Parse the query-string to find GET parameters 263 | $parameters = OAuthUtil::parse_parameters($_SERVER['QUERY_STRING']); 264 | 265 | // It's a POST request of the proper content-type, so parse POST 266 | // parameters and add those overriding any duplicates from GET 267 | if ($http_method == "POST" 268 | && @strstr($request_headers["Content-Type"], 269 | "application/x-www-form-urlencoded") 270 | ) { 271 | $post_data = OAuthUtil::parse_parameters( 272 | file_get_contents(self::$POST_INPUT) 273 | ); 274 | $parameters = array_merge($parameters, $post_data); 275 | } 276 | 277 | // We have a Authorization-header with OAuth data. Parse the header 278 | // and add those overriding any duplicates from GET or POST 279 | if (@substr($request_headers['Authorization'], 0, 6) == "OAuth ") { 280 | $header_parameters = OAuthUtil::split_header( 281 | $request_headers['Authorization'] 282 | ); 283 | $parameters = array_merge($parameters, $header_parameters); 284 | } 285 | 286 | } 287 | 288 | return new OAuthRequest($http_method, $http_url, $parameters); 289 | } 290 | 291 | /** 292 | * pretty much a helper function to set up the request 293 | */ 294 | public static function from_consumer_and_token($consumer, $token, $http_method, $http_url, $parameters=NULL) { 295 | @$parameters or $parameters = array(); 296 | $defaults = array("oauth_version" => OAuthRequest::$version, 297 | "oauth_nonce" => OAuthRequest::generate_nonce(), 298 | "oauth_timestamp" => OAuthRequest::generate_timestamp(), 299 | "oauth_consumer_key" => $consumer->key); 300 | if ($token) 301 | $defaults['oauth_token'] = $token->key; 302 | 303 | $parameters = array_merge($defaults, $parameters); 304 | 305 | return new OAuthRequest($http_method, $http_url, $parameters); 306 | } 307 | 308 | public function set_parameter($name, $value, $allow_duplicates = true) { 309 | if ($allow_duplicates && isset($this->parameters[$name])) { 310 | // We have already added parameter(s) with this name, so add to the list 311 | if (is_scalar($this->parameters[$name])) { 312 | // This is the first duplicate, so transform scalar (string) 313 | // into an array so we can add the duplicates 314 | $this->parameters[$name] = array($this->parameters[$name]); 315 | } 316 | 317 | $this->parameters[$name][] = $value; 318 | } else { 319 | $this->parameters[$name] = $value; 320 | } 321 | } 322 | 323 | public function get_parameter($name) { 324 | return isset($this->parameters[$name]) ? $this->parameters[$name] : null; 325 | } 326 | 327 | public function get_parameters() { 328 | return $this->parameters; 329 | } 330 | 331 | public function unset_parameter($name) { 332 | unset($this->parameters[$name]); 333 | } 334 | 335 | /** 336 | * The request parameters, sorted and concatenated into a normalized string. 337 | * @return string 338 | */ 339 | public function get_signable_parameters() { 340 | // Grab all parameters 341 | $params = $this->parameters; 342 | 343 | // Remove oauth_signature if present 344 | // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.") 345 | if (isset($params['oauth_signature'])) { 346 | unset($params['oauth_signature']); 347 | } 348 | 349 | return OAuthUtil::build_http_query($params); 350 | } 351 | 352 | /** 353 | * Returns the base string of this request 354 | * 355 | * The base string defined as the method, the url 356 | * and the parameters (normalized), each urlencoded 357 | * and the concated with &. 358 | */ 359 | public function get_signature_base_string() { 360 | $parts = array( 361 | $this->get_normalized_http_method(), 362 | $this->get_normalized_http_url(), 363 | $this->get_signable_parameters() 364 | ); 365 | 366 | $parts = OAuthUtil::urlencode_rfc3986($parts); 367 | 368 | return implode('&', $parts); 369 | } 370 | 371 | /** 372 | * just uppercases the http method 373 | */ 374 | public function get_normalized_http_method() { 375 | return strtoupper($this->http_method); 376 | } 377 | 378 | /** 379 | * parses the url and rebuilds it to be 380 | * scheme://host/path 381 | */ 382 | public function get_normalized_http_url() { 383 | $parts = parse_url($this->http_url); 384 | 385 | $port = @$parts['port']; 386 | $scheme = $parts['scheme']; 387 | $host = $parts['host']; 388 | $path = @$parts['path']; 389 | 390 | $port or $port = ($scheme == 'https') ? '443' : '80'; 391 | 392 | if (($scheme == 'https' && $port != '443') 393 | || ($scheme == 'http' && $port != '80')) { 394 | $host = "$host:$port"; 395 | } 396 | return "$scheme://$host$path"; 397 | } 398 | 399 | /** 400 | * builds a url usable for a GET request 401 | */ 402 | public function to_url() { 403 | $post_data = $this->to_postdata(); 404 | $out = $this->get_normalized_http_url(); 405 | if ($post_data) { 406 | $out .= '?'.$post_data; 407 | } 408 | return $out; 409 | } 410 | 411 | /** 412 | * builds the data one would send in a POST request 413 | */ 414 | public function to_postdata() { 415 | return OAuthUtil::build_http_query($this->parameters); 416 | } 417 | 418 | /** 419 | * builds the Authorization: header 420 | */ 421 | public function to_header($realm=null) { 422 | $first = true; 423 | if($realm) { 424 | $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986($realm) . '"'; 425 | $first = false; 426 | } else 427 | $out = 'Authorization: OAuth'; 428 | 429 | $total = array(); 430 | foreach ($this->parameters as $k => $v) { 431 | if (substr($k, 0, 5) != "oauth") continue; 432 | if (is_array($v)) { 433 | throw new OAuthException('Arrays not supported in headers'); 434 | } 435 | $out .= ($first) ? ' ' : ','; 436 | $out .= OAuthUtil::urlencode_rfc3986($k) . 437 | '="' . 438 | OAuthUtil::urlencode_rfc3986($v) . 439 | '"'; 440 | $first = false; 441 | } 442 | return $out; 443 | } 444 | 445 | public function __toString() { 446 | return $this->to_url(); 447 | } 448 | 449 | 450 | public function sign_request($signature_method, $consumer, $token) { 451 | $this->set_parameter( 452 | "oauth_signature_method", 453 | $signature_method->get_name(), 454 | false 455 | ); 456 | $signature = $this->build_signature($signature_method, $consumer, $token); 457 | $this->set_parameter("oauth_signature", $signature, false); 458 | } 459 | 460 | public function build_signature($signature_method, $consumer, $token) { 461 | $signature = $signature_method->build_signature($this, $consumer, $token); 462 | return $signature; 463 | } 464 | 465 | /** 466 | * util function: current timestamp 467 | */ 468 | private static function generate_timestamp() { 469 | return time(); 470 | } 471 | 472 | /** 473 | * util function: current nonce 474 | */ 475 | private static function generate_nonce() { 476 | $mt = microtime(); 477 | $rand = mt_rand(); 478 | 479 | return md5($mt . $rand); // md5s look nicer than numbers 480 | } 481 | } 482 | 483 | class OAuthServer { 484 | protected $timestamp_threshold = 300; // in seconds, five minutes 485 | protected $version = '1.0'; // hi blaine 486 | protected $signature_methods = array(); 487 | 488 | protected $data_store; 489 | 490 | function __construct($data_store) { 491 | $this->data_store = $data_store; 492 | } 493 | 494 | public function add_signature_method($signature_method) { 495 | $this->signature_methods[$signature_method->get_name()] = 496 | $signature_method; 497 | } 498 | 499 | // high level functions 500 | 501 | /** 502 | * process a request_token request 503 | * returns the request token on success 504 | */ 505 | public function fetch_request_token(&$request) { 506 | $this->get_version($request); 507 | 508 | $consumer = $this->get_consumer($request); 509 | 510 | // no token required for the initial token request 511 | $token = NULL; 512 | 513 | $this->check_signature($request, $consumer, $token); 514 | 515 | // Rev A change 516 | $callback = $request->get_parameter('oauth_callback'); 517 | $new_token = $this->data_store->new_request_token($consumer, $callback); 518 | 519 | return $new_token; 520 | } 521 | 522 | /** 523 | * process an access_token request 524 | * returns the access token on success 525 | */ 526 | public function fetch_access_token(&$request) { 527 | $this->get_version($request); 528 | 529 | $consumer = $this->get_consumer($request); 530 | 531 | // requires authorized request token 532 | $token = $this->get_token($request, $consumer, "request"); 533 | 534 | $this->check_signature($request, $consumer, $token); 535 | 536 | // Rev A change 537 | $verifier = $request->get_parameter('oauth_verifier'); 538 | $new_token = $this->data_store->new_access_token($token, $consumer, $verifier); 539 | 540 | return $new_token; 541 | } 542 | 543 | /** 544 | * verify an api call, checks all the parameters 545 | */ 546 | public function verify_request(&$request) { 547 | $this->get_version($request); 548 | $consumer = $this->get_consumer($request); 549 | $token = $this->get_token($request, $consumer, "access"); 550 | $this->check_signature($request, $consumer, $token); 551 | return array($consumer, $token); 552 | } 553 | 554 | // Internals from here 555 | /** 556 | * version 1 557 | */ 558 | private function get_version(&$request) { 559 | $version = $request->get_parameter("oauth_version"); 560 | if (!$version) { 561 | // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. 562 | // Chapter 7.0 ("Accessing Protected Ressources") 563 | $version = '1.0'; 564 | } 565 | if ($version !== $this->version) { 566 | throw new OAuthException("OAuth version '$version' not supported"); 567 | } 568 | return $version; 569 | } 570 | 571 | /** 572 | * figure out the signature with some defaults 573 | */ 574 | private function get_signature_method(&$request) { 575 | $signature_method = 576 | @$request->get_parameter("oauth_signature_method"); 577 | 578 | if (!$signature_method) { 579 | // According to chapter 7 ("Accessing Protected Ressources") the signature-method 580 | // parameter is required, and we can't just fallback to PLAINTEXT 581 | throw new OAuthException('No signature method parameter. This parameter is required'); 582 | } 583 | 584 | if (!in_array($signature_method, 585 | array_keys($this->signature_methods))) { 586 | throw new OAuthException( 587 | "Signature method '$signature_method' not supported " . 588 | "try one of the following: " . 589 | implode(", ", array_keys($this->signature_methods)) 590 | ); 591 | } 592 | return $this->signature_methods[$signature_method]; 593 | } 594 | 595 | /** 596 | * try to find the consumer for the provided request's consumer key 597 | */ 598 | private function get_consumer(&$request) { 599 | $consumer_key = @$request->get_parameter("oauth_consumer_key"); 600 | if (!$consumer_key) { 601 | throw new OAuthException("Invalid consumer key"); 602 | } 603 | 604 | $consumer = $this->data_store->lookup_consumer($consumer_key); 605 | if (!$consumer) { 606 | throw new OAuthException("Invalid consumer"); 607 | } 608 | 609 | return $consumer; 610 | } 611 | 612 | /** 613 | * try to find the token for the provided request's token key 614 | */ 615 | private function get_token(&$request, $consumer, $token_type="access") { 616 | $token_field = @$request->get_parameter('oauth_token'); 617 | $token = $this->data_store->lookup_token( 618 | $consumer, $token_type, $token_field 619 | ); 620 | if (!$token) { 621 | throw new OAuthException("Invalid $token_type token: $token_field"); 622 | } 623 | return $token; 624 | } 625 | 626 | /** 627 | * all-in-one function to check the signature on a request 628 | * should guess the signature method appropriately 629 | */ 630 | private function check_signature(&$request, $consumer, $token) { 631 | // this should probably be in a different method 632 | $timestamp = @$request->get_parameter('oauth_timestamp'); 633 | $nonce = @$request->get_parameter('oauth_nonce'); 634 | 635 | $this->check_timestamp($timestamp); 636 | $this->check_nonce($consumer, $token, $nonce, $timestamp); 637 | 638 | $signature_method = $this->get_signature_method($request); 639 | 640 | $signature = $request->get_parameter('oauth_signature'); 641 | $valid_sig = $signature_method->check_signature( 642 | $request, 643 | $consumer, 644 | $token, 645 | $signature 646 | ); 647 | 648 | if (!$valid_sig) { 649 | throw new OAuthException("Invalid signature"); 650 | } 651 | } 652 | 653 | /** 654 | * check that the timestamp is new enough 655 | */ 656 | private function check_timestamp($timestamp) { 657 | if( ! $timestamp ) 658 | throw new OAuthException( 659 | 'Missing timestamp parameter. The parameter is required' 660 | ); 661 | 662 | // verify that timestamp is recentish 663 | $now = time(); 664 | if (abs($now - $timestamp) > $this->timestamp_threshold) { 665 | throw new OAuthException( 666 | "Expired timestamp, yours $timestamp, ours $now" 667 | ); 668 | } 669 | } 670 | 671 | /** 672 | * check that the nonce is not repeated 673 | */ 674 | private function check_nonce($consumer, $token, $nonce, $timestamp) { 675 | if( ! $nonce ) 676 | throw new OAuthException( 677 | 'Missing nonce parameter. The parameter is required' 678 | ); 679 | 680 | // verify that the nonce is uniqueish 681 | $found = $this->data_store->lookup_nonce( 682 | $consumer, 683 | $token, 684 | $nonce, 685 | $timestamp 686 | ); 687 | if ($found) { 688 | throw new OAuthException("Nonce already used: $nonce"); 689 | } 690 | } 691 | 692 | } 693 | 694 | class OAuthDataStore { 695 | function lookup_consumer($consumer_key) { 696 | // implement me 697 | } 698 | 699 | function lookup_token($consumer, $token_type, $token) { 700 | // implement me 701 | } 702 | 703 | function lookup_nonce($consumer, $token, $nonce, $timestamp) { 704 | // implement me 705 | } 706 | 707 | function new_request_token($consumer, $callback = null) { 708 | // return a new token attached to this consumer 709 | } 710 | 711 | function new_access_token($token, $consumer, $verifier = null) { 712 | // return a new access token attached to this consumer 713 | // for the user associated with this token if the request token 714 | // is authorized 715 | // should also invalidate the request token 716 | } 717 | 718 | } 719 | 720 | class OAuthUtil { 721 | public static function urlencode_rfc3986($input) { 722 | if (is_array($input)) { 723 | return array_map(array('OAuthUtil', 'urlencode_rfc3986'), $input); 724 | } else if (is_scalar($input)) { 725 | return str_replace( 726 | '+', 727 | ' ', 728 | str_replace('%7E', '~', rawurlencode($input)) 729 | ); 730 | } else { 731 | return ''; 732 | } 733 | } 734 | 735 | 736 | // This decode function isn't taking into consideration the above 737 | // modifications to the encoding process. However, this method doesn't 738 | // seem to be used anywhere so leaving it as is. 739 | public static function urldecode_rfc3986($string) { 740 | return urldecode($string); 741 | } 742 | 743 | // Utility function for turning the Authorization: header into 744 | // parameters, has to do some unescaping 745 | // Can filter out any non-oauth parameters if needed (default behaviour) 746 | public static function split_header($header, $only_allow_oauth_parameters = true) { 747 | $pattern = '/(([-_a-z]*)=("([^"]*)"|([^,]*)),?)/'; 748 | $offset = 0; 749 | $params = array(); 750 | while (preg_match($pattern, $header, $matches, PREG_OFFSET_CAPTURE, $offset) > 0) { 751 | $match = $matches[0]; 752 | $header_name = $matches[2][0]; 753 | $header_content = (isset($matches[5])) ? $matches[5][0] : $matches[4][0]; 754 | if (preg_match('/^oauth_/', $header_name) || !$only_allow_oauth_parameters) { 755 | $params[$header_name] = OAuthUtil::urldecode_rfc3986($header_content); 756 | } 757 | $offset = $match[1] + strlen($match[0]); 758 | } 759 | 760 | if (isset($params['realm'])) { 761 | unset($params['realm']); 762 | } 763 | 764 | return $params; 765 | } 766 | 767 | // helper to try to sort out headers for people who aren't running apache 768 | public static function get_headers() { 769 | if (function_exists('apache_request_headers')) { 770 | // we need this to get the actual Authorization: header 771 | // because apache tends to tell us it doesn't exist 772 | $headers = apache_request_headers(); 773 | 774 | // sanitize the output of apache_request_headers because 775 | // we always want the keys to be Cased-Like-This and arh() 776 | // returns the headers in the same case as they are in the 777 | // request 778 | $out = array(); 779 | foreach( $headers AS $key => $value ) { 780 | $key = str_replace( 781 | " ", 782 | "-", 783 | ucwords(strtolower(str_replace("-", " ", $key))) 784 | ); 785 | $out[$key] = $value; 786 | } 787 | } else { 788 | // otherwise we don't have apache and are just going to have to hope 789 | // that $_SERVER actually contains what we need 790 | $out = array(); 791 | if( isset($_SERVER['CONTENT_TYPE']) ) 792 | $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; 793 | if( isset($_ENV['CONTENT_TYPE']) ) 794 | $out['Content-Type'] = $_ENV['CONTENT_TYPE']; 795 | 796 | foreach ($_SERVER as $key => $value) { 797 | if (substr($key, 0, 5) == "HTTP_") { 798 | // this is chaos, basically it is just there to capitalize the first 799 | // letter of every word that is not an initial HTTP and strip HTTP 800 | // code from przemek 801 | $key = str_replace( 802 | " ", 803 | "-", 804 | ucwords(strtolower(str_replace("_", " ", substr($key, 5)))) 805 | ); 806 | $out[$key] = $value; 807 | } 808 | } 809 | } 810 | return $out; 811 | } 812 | 813 | // This function takes a input like a=b&a=c&d=e and returns the parsed 814 | // parameters like this 815 | // array('a' => array('b','c'), 'd' => 'e') 816 | public static function parse_parameters( $input ) { 817 | if (!isset($input) || !$input) return array(); 818 | 819 | $pairs = explode('&', $input); 820 | 821 | $parsed_parameters = array(); 822 | foreach ($pairs as $pair) { 823 | $split = explode('=', $pair, 2); 824 | $parameter = OAuthUtil::urldecode_rfc3986($split[0]); 825 | $value = isset($split[1]) ? OAuthUtil::urldecode_rfc3986($split[1]) : ''; 826 | 827 | if (isset($parsed_parameters[$parameter])) { 828 | // We have already recieved parameter(s) with this name, so add to the list 829 | // of parameters with this name 830 | 831 | if (is_scalar($parsed_parameters[$parameter])) { 832 | // This is the first duplicate, so transform scalar (string) into an array 833 | // so we can add the duplicates 834 | $parsed_parameters[$parameter] = array($parsed_parameters[$parameter]); 835 | } 836 | 837 | $parsed_parameters[$parameter][] = $value; 838 | } else { 839 | $parsed_parameters[$parameter] = $value; 840 | } 841 | } 842 | return $parsed_parameters; 843 | } 844 | 845 | public static function build_http_query($params) { 846 | if (!$params) return ''; 847 | 848 | // Urlencode both keys and values 849 | $keys = OAuthUtil::urlencode_rfc3986(array_keys($params)); 850 | $values = OAuthUtil::urlencode_rfc3986(array_values($params)); 851 | $params = array_combine($keys, $values); 852 | 853 | // Parameters are sorted by name, using lexicographical byte value ordering. 854 | // Ref: Spec: 9.1.1 (1) 855 | uksort($params, 'strcmp'); 856 | 857 | $pairs = array(); 858 | foreach ($params as $parameter => $value) { 859 | if (is_array($value)) { 860 | // If two or more parameters share the same name, they are sorted by their value 861 | // Ref: Spec: 9.1.1 (1) 862 | natsort($value); 863 | foreach ($value as $duplicate_value) { 864 | $pairs[] = $parameter . '=' . $duplicate_value; 865 | } 866 | } else { 867 | $pairs[] = $parameter . '=' . $value; 868 | } 869 | } 870 | // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61) 871 | // Each name-value pair is separated by an '&' character (ASCII code 38) 872 | return implode('&', $pairs); 873 | } 874 | } 875 | -------------------------------------------------------------------------------- /twitteroauth/twitteroauth.php: -------------------------------------------------------------------------------- 1 | http_status; } 54 | function lastAPICall() { return $this->last_api_call; } 55 | 56 | /** 57 | * construct TwitterOAuth object 58 | */ 59 | function __construct($consumer_key, $consumer_secret, $oauth_token = NULL, $oauth_token_secret = NULL) { 60 | $this->sha1_method = new OAuthSignatureMethod_HMAC_SHA1(); 61 | $this->consumer = new OAuthConsumer($consumer_key, $consumer_secret); 62 | if (!empty($oauth_token) && !empty($oauth_token_secret)) { 63 | $this->token = new OAuthConsumer($oauth_token, $oauth_token_secret); 64 | } else { 65 | $this->token = NULL; 66 | } 67 | } 68 | 69 | 70 | /** 71 | * Get a request_token from Twitter 72 | * 73 | * @returns a key/value array containing oauth_token and oauth_token_secret 74 | */ 75 | function getRequestToken($oauth_callback) { 76 | $parameters = array(); 77 | $parameters['oauth_callback'] = $oauth_callback; 78 | $request = $this->oAuthRequest($this->requestTokenURL(), 'GET', $parameters); 79 | $token = OAuthUtil::parse_parameters($request); 80 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 81 | return $token; 82 | } 83 | 84 | /** 85 | * Get the authorize URL 86 | * 87 | * @returns a string 88 | */ 89 | function getAuthorizeURL($token, $sign_in_with_twitter = TRUE) { 90 | if (is_array($token)) { 91 | $token = $token['oauth_token']; 92 | } 93 | if (empty($sign_in_with_twitter)) { 94 | return $this->authorizeURL() . "?oauth_token={$token}"; 95 | } else { 96 | return $this->authenticateURL() . "?oauth_token={$token}"; 97 | } 98 | } 99 | 100 | /** 101 | * Exchange request token and secret for an access token and 102 | * secret, to sign API calls. 103 | * 104 | * @returns array("oauth_token" => "the-access-token", 105 | * "oauth_token_secret" => "the-access-secret", 106 | * "user_id" => "9436992", 107 | * "screen_name" => "abraham") 108 | */ 109 | function getAccessToken($oauth_verifier) { 110 | $parameters = array(); 111 | $parameters['oauth_verifier'] = $oauth_verifier; 112 | $request = $this->oAuthRequest($this->accessTokenURL(), 'GET', $parameters); 113 | $token = OAuthUtil::parse_parameters($request); 114 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 115 | return $token; 116 | } 117 | 118 | /** 119 | * One time exchange of username and password for access token and secret. 120 | * 121 | * @returns array("oauth_token" => "the-access-token", 122 | * "oauth_token_secret" => "the-access-secret", 123 | * "user_id" => "9436992", 124 | * "screen_name" => "abraham", 125 | * "x_auth_expires" => "0") 126 | */ 127 | function getXAuthToken($username, $password) { 128 | $parameters = array(); 129 | $parameters['x_auth_username'] = $username; 130 | $parameters['x_auth_password'] = $password; 131 | $parameters['x_auth_mode'] = 'client_auth'; 132 | $request = $this->oAuthRequest($this->accessTokenURL(), 'POST', $parameters); 133 | $token = OAuthUtil::parse_parameters($request); 134 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 135 | return $token; 136 | } 137 | 138 | /** 139 | * GET wrapper for oAuthRequest. 140 | */ 141 | function get($url, $parameters = array()) { 142 | $response = $this->oAuthRequest($url, 'GET', $parameters); 143 | if ($this->format === 'json' && $this->decode_json) { 144 | return json_decode($response); 145 | } 146 | return $response; 147 | } 148 | 149 | /** 150 | * POST wrapper for oAuthRequest. 151 | */ 152 | function post($url, $parameters = array()) { 153 | $response = $this->oAuthRequest($url, 'POST', $parameters); 154 | if ($this->format === 'json' && $this->decode_json) { 155 | return json_decode($response); 156 | } 157 | return $response; 158 | } 159 | 160 | /** 161 | * DELETE wrapper for oAuthReqeust. 162 | */ 163 | function delete($url, $parameters = array()) { 164 | $response = $this->oAuthRequest($url, 'DELETE', $parameters); 165 | if ($this->format === 'json' && $this->decode_json) { 166 | return json_decode($response); 167 | } 168 | return $response; 169 | } 170 | 171 | /** 172 | * Format and sign an OAuth / API request 173 | */ 174 | function oAuthRequest($url, $method, $parameters) { 175 | if (strrpos($url, 'https://') !== 0 && strrpos($url, 'http://') !== 0) { 176 | $url = "{$this->host}{$url}.{$this->format}"; 177 | } 178 | $request = OAuthRequest::from_consumer_and_token($this->consumer, $this->token, $method, $url, $parameters); 179 | $request->sign_request($this->sha1_method, $this->consumer, $this->token); 180 | switch ($method) { 181 | case 'GET': 182 | return $this->http($request->to_url(), 'GET'); 183 | default: 184 | return $this->http($request->get_normalized_http_url(), $method, $request->to_postdata()); 185 | } 186 | } 187 | 188 | /** 189 | * Make an HTTP request 190 | * 191 | * @return API results 192 | */ 193 | function http($url, $method, $postfields = NULL) { 194 | $this->http_info = array(); 195 | $ci = curl_init(); 196 | /* Curl settings */ 197 | curl_setopt($ci, CURLOPT_USERAGENT, $this->useragent); 198 | curl_setopt($ci, CURLOPT_CONNECTTIMEOUT, $this->connecttimeout); 199 | curl_setopt($ci, CURLOPT_TIMEOUT, $this->timeout); 200 | curl_setopt($ci, CURLOPT_RETURNTRANSFER, TRUE); 201 | curl_setopt($ci, CURLOPT_HTTPHEADER, array('Expect:')); 202 | curl_setopt($ci, CURLOPT_SSL_VERIFYPEER, $this->ssl_verifypeer); 203 | curl_setopt($ci, CURLOPT_HEADERFUNCTION, array($this, 'getHeader')); 204 | curl_setopt($ci, CURLOPT_HEADER, FALSE); 205 | 206 | switch ($method) { 207 | case 'POST': 208 | curl_setopt($ci, CURLOPT_POST, TRUE); 209 | if (!empty($postfields)) { 210 | curl_setopt($ci, CURLOPT_POSTFIELDS, $postfields); 211 | } 212 | break; 213 | case 'DELETE': 214 | curl_setopt($ci, CURLOPT_CUSTOMREQUEST, 'DELETE'); 215 | if (!empty($postfields)) { 216 | $url = "{$url}?{$postfields}"; 217 | } 218 | } 219 | 220 | curl_setopt($ci, CURLOPT_URL, $url); 221 | $response = curl_exec($ci); 222 | $this->http_code = curl_getinfo($ci, CURLINFO_HTTP_CODE); 223 | $this->http_info = array_merge($this->http_info, curl_getinfo($ci)); 224 | $this->url = $url; 225 | curl_close ($ci); 226 | return $response; 227 | } 228 | 229 | /** 230 | * Get the header info to store. 231 | */ 232 | function getHeader($ch, $header) { 233 | $i = strpos($header, ':'); 234 | if (!empty($i)) { 235 | $key = str_replace('-', '_', strtolower(substr($header, 0, $i))); 236 | $value = trim(substr($header, $i + 2)); 237 | $this->http_header[$key] = $value; 238 | } 239 | return strlen($header); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /util/TwitterAPI.php: -------------------------------------------------------------------------------- 1 | user = BOT_ACCOUNT; 36 | $this->oauth = new TwitterOAuth(TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, OAUTH_TOKEN, 37 | OAUTH_TOKEN_SECRET); 38 | } 39 | 40 | /** 41 | * Private helper method for performing the given action with OAuth. 42 | * @param string $method The HTTP method. 43 | * @param string $call The Twitter API method call. 44 | * @param array $args Array of arguments for the API call. 45 | * @return Raw JSON-encoded response from the Twitter servers. 46 | */ 47 | private function doAction($method, $call, $args) { 48 | switch (strtoupper($method)) { 49 | case 'GET': 50 | return $this->oauth->get($call, $args); 51 | case 'POST': 52 | return $this->oauth->post($call, $args); 53 | case 'DELETE': 54 | return $this->oauth->delete($call, $args); 55 | } 56 | } 57 | 58 | /** 59 | * Do the public_timeline action. 60 | * See http://dev.twitter.com/doc/get/statuses/public_timeline 61 | * @param array $args 62 | * @return JSON-decoded array. 63 | */ 64 | public function public_timeline($args = array()) { 65 | return $this->doAction('get', 'statuses/public_timeline', $args); 66 | } 67 | 68 | /** 69 | * Do the update action. Posts a new tweet. 70 | * See http://dev.twitter.com/doc/post/statuses/update 71 | * @param string $post 72 | * @param array $args 73 | * @return JSON-decoded array 74 | */ 75 | public function update($post, $args = array()) { 76 | return $this->doAction('post', 'statuses/update', 77 | array_merge(array('status' => $post),$args)); 78 | } 79 | 80 | /** 81 | * Performs a search through Twitter's public archives for posts with 82 | * the given search terms. 83 | * See http://dev.twitter.com/doc/get/search 84 | * 85 | * NOTE: Due to current separations in Twitter's API, this method is 86 | * implemented separately from the rest of the methods, specifically 87 | * it does not use OAuth in order to perform its function. 88 | * 89 | * @param string $searchstring 90 | * @param array $args 91 | */ 92 | public function search($searchstring, $args = array()) { 93 | $url = 'http://search.twitter.com/search.json?q=' . 94 | urlencode($searchstring); 95 | foreach ($args as $key => $value) { 96 | $url .= '&' . $key . '=' . urlencode($value); 97 | } 98 | // set up the curl session 99 | $ch = curl_init(); 100 | $options = array(CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true); 101 | curl_setopt_array($ch, $options); 102 | $retval = json_decode(curl_exec($ch)); 103 | curl_close($ch); 104 | return $retval; 105 | } 106 | } 107 | 108 | ?> 109 | -------------------------------------------------------------------------------- /util/translate.php: -------------------------------------------------------------------------------- 1 | 33 | 'https://www.googleapis.com/language/translate/v2?key=' . 34 | $key . '&target=' . $targetlanguage . '&q=' . urlencode($text), 35 | CURLOPT_RETURNTRANSFER => true); 36 | curl_setopt_array($ch, $options); 37 | $retval = json_decode(curl_exec($ch)); 38 | curl_close($ch); 39 | if (isset($retval->error) || 40 | $retval->data->translations[0]->detectedSourceLanguage == 'en') { 41 | return $text; 42 | } else { 43 | // do a little clean-up on the @ tags 44 | $matches = preg_match_all('/@([A-Za-z0-9_]+)/', $text, $usernames); 45 | $translated = $retval->data->translations[0]->translatedText; 46 | if ($matches == 0 || $matches === false) { 47 | return $translated; 48 | } 49 | foreach ($usernames[1] as $index => $username) { 50 | $pos = strpos($translated, $username); 51 | $translated = substr_replace($translated, '@', $pos, 0); 52 | } 53 | return str_replace('@ ', '', $translated); 54 | } 55 | } 56 | } 57 | 58 | ?> 59 | --------------------------------------------------------------------------------