├── pybot ├── __init__.py ├── storage.py ├── template.py ├── bootstrap.py └── pybot.py ├── .gitignore ├── LICENSE.txt ├── examples ├── cpcb.py ├── miner.py ├── echobot.py ├── artbot.py └── trigrambot.py ├── README.md └── sbin └── create_pybot.py /pybot/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | __author__ = 'magsol' 3 | 4 | from pybot.storage import PickleStorage 5 | from pybot.pybot import PyBot 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib64 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Unit test / coverage reports 24 | .coverage 25 | .tox 26 | nosetests.xml 27 | 28 | # Translations 29 | *.mo 30 | 31 | # Mr Developer 32 | .mr.developer.cfg 33 | .project 34 | .pydevproject 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2016 Shannon Quinn 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /pybot/storage.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import logging 16 | import os 17 | import pickle 18 | 19 | class PickleStorage(object): 20 | """ 21 | Base storage class. Uses Python's pickle library. 22 | 23 | Create a new storage class and override "read(f)" and "write(f)" 24 | to use a different adapter, e.g. text files, databases, etc. 25 | """ 26 | 27 | def read(self, f): 28 | """ 29 | Read method. 30 | 31 | Parameters 32 | ---------- 33 | f : string 34 | Path to the .pkl file on disk. 35 | 36 | Returns 37 | ------- 38 | PyBot state dictionary if the file exists; None otherwise. 39 | """ 40 | if not os.path.exists(f): 41 | logging.info("%s does not exist." % f) 42 | return None 43 | 44 | logging.info("Retrieving state from %s." % f) 45 | fp = open(f, "rb") 46 | state = pickle.load(fp) 47 | fp.close() 48 | return state 49 | 50 | def write(self, f, s): 51 | """ 52 | Write method. 53 | 54 | Parameters 55 | ---------- 56 | f : string 57 | Path to the state file. 58 | s : dictionary 59 | Dict containing this PyBot's current state. 60 | """ 61 | if os.path.exists(f): 62 | logging.info("Overwriting %s." % f) 63 | else: 64 | logging.info("Creating %s." % f) 65 | fp = open(f, "wb") 66 | pickle.dump(s, fp) 67 | fp.close() 68 | -------------------------------------------------------------------------------- /examples/cpcb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import logging 16 | 17 | from pybot import PyBot 18 | 19 | class CPCB(PyBot): 20 | 21 | def bot_init(self): 22 | """ 23 | Custom initialization. Specify any configuration options you want to 24 | override, as in particular your OAuth credentials. 25 | """ 26 | 27 | ############################# 28 | # # 29 | # Twitter OAuth Credentials # 30 | # # 31 | # FILL THESE IN! # 32 | # # 33 | ############################# 34 | 35 | self.config['api_key'] = '' 36 | self.config['api_secret'] = '' 37 | self.config['access_key'] = '' 38 | self.config['access_secret'] = '' 39 | 40 | ############################# 41 | # # 42 | # Other config options # 43 | # # 44 | # Fill these in if you want # 45 | # or otherwise need to. # 46 | # # 47 | ############################# 48 | 49 | # Look for replies every 60 minutes. 50 | self.config['mention_interval'] = 60 * 60 51 | 52 | # List of authorized users. 53 | self.config['authorized_accounts'] = ['twitter'] 54 | 55 | def on_tweet(self): 56 | pass 57 | 58 | def on_mention(self, tweet, prefix): 59 | """ 60 | Handler for responding to mentions at the bot. 61 | 62 | When calling `self.update_status`, make sure you set the `reply_to` 63 | parameter to point to the tweet object, or Twitter will not recognize 64 | this tweet as a reply to the original. 65 | 66 | Parameters 67 | ---------- 68 | tweet : tweepy.Status object 69 | Contains the status update pertaining to the mention. The fields in 70 | this object mimic Twitter's Tweet object: 71 | https://dev.twitter.com/overview/api/tweets 72 | prefix : string 73 | String containing all the mentions from the original tweet, excluding 74 | your bot's screen name, any users in the blacklist, and any users 75 | you do not follow IF self.config['reply_followers_only'] is True. 76 | """ 77 | # Is the author of the tweet in the authorized users' list? 78 | if tweet.author.screen_name.lower() in self.config['authorized_accounts']: 79 | # Retweet the status. 80 | self.api.retweet(tweet.id) 81 | logging.info("Retweeted status %s" % self._tweet_url(tweet)) 82 | 83 | def on_timeline(self, tweet, prefix): 84 | pass 85 | 86 | def on_search(self, tweet): 87 | pass 88 | 89 | def on_follow(self, friend): 90 | pass 91 | 92 | if __name__ == "__main__": 93 | bot = CPCB() 94 | bot.run() 95 | -------------------------------------------------------------------------------- /examples/miner.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import json 16 | import re 17 | 18 | from pybot import PyBot 19 | 20 | class Miner(PyBot): 21 | 22 | def bot_init(self): 23 | """ 24 | Custom initialization. Specify any configuration options you want to 25 | override, as in particular your OAuth credentials. 26 | """ 27 | 28 | ############################# 29 | # # 30 | # Twitter OAuth Credentials # 31 | # # 32 | # FILL THESE IN! # 33 | # # 34 | ############################# 35 | 36 | self.config['api_key'] = '' 37 | self.config['api_secret'] = '' 38 | self.config['access_key'] = '' 39 | self.config['access_secret'] = '' 40 | 41 | ############################# 42 | # # 43 | # Other config options # 44 | # # 45 | # Fill these in if you want # 46 | # or otherwise need to. # 47 | # # 48 | ############################# 49 | 50 | # Listens to the streaming timeline and filters on keywords 51 | self.config['bot_name'] = 'Miner' 52 | self.config['search_keywords'] = [] 53 | self.config['search_interval'] = 10 54 | 55 | ############################# 56 | # # 57 | # Customize your bot's # 58 | # behavior here. # 59 | # # 60 | ############################# 61 | 62 | self.state['buffer'] = [] 63 | 64 | def on_search(self, tweet): 65 | """ 66 | Handler for responding to public tweets that contain certain keywords, 67 | as specified in self.config['search_keywords']. 68 | 69 | Parameters 70 | ---------- 71 | tweet : tweepy.Status object 72 | Contains the status update pertaining to the mention. The fields in 73 | this object mimic Twitter's Tweet object: 74 | https://dev.twitter.com/overview/api/tweets 75 | """ 76 | if 'RT @' in tweet.text: 77 | return None 78 | 79 | # Some filtering and preprocessing. 80 | text = tweet.text.replace("\n", " ").lower() 81 | text = re.sub(r"http\S+", "", text) 82 | d = { 83 | 'user_name': tweet.user.screen_name, 84 | 'user_created': str(tweet.user.created_at), 85 | 'user_profile': tweet.user.description, 86 | 'user_followers': tweet.user.followers_count, 87 | 'user_friends': tweet.user.friends_count, 88 | 'user_id': tweet.user.id_str, 89 | 'user_tweets': tweet.user.statuses_count, 90 | 'tweet_text': text, 91 | 'tweet_coords': tweet.coordinates['coordinates'] if tweet.coordinates is not None else '', 92 | 'tweet_created': str(tweet.created_at), 93 | 'tweet_favorited': tweet.favorite_count, 94 | 'tweet_retweeted': tweet.retweet_count, 95 | 'tweet_id': tweet.id_str, 96 | 'in_reply_to_screen_name': tweet.in_reply_to_screen_name, 97 | } 98 | with open("tweets.json", "a") as f: 99 | f.write("{}\n".format(json.dumps(d))) 100 | 101 | def on_tweet(self): 102 | pass 103 | 104 | def on_mention(self, tweet, prefix): 105 | pass 106 | 107 | def on_timeline(self, tweet, prefix): 108 | pass 109 | 110 | def on_follow(self, friend): 111 | pass 112 | 113 | if __name__ == "__main__": 114 | bot = Miner() # In this case, a basic listener bot. 115 | bot.run() 116 | -------------------------------------------------------------------------------- /examples/echobot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from pybot import PyBot 16 | 17 | class EchoBot(PyBot): 18 | 19 | def bot_init(self): 20 | """ 21 | Custom initialization. Specify any configuration options you want to 22 | override, as in particular your OAuth credentials. 23 | """ 24 | 25 | ############################# 26 | # # 27 | # Twitter OAuth Credentials # 28 | # # 29 | # FILL THESE IN! # 30 | # # 31 | ############################# 32 | 33 | self.config['api_key'] = '' 34 | self.config['api_secret'] = '' 35 | self.config['access_key'] = '' 36 | self.config['access_secret'] = '' 37 | 38 | ############################# 39 | # # 40 | # Other config options # 41 | # # 42 | # Fill these in if you want # 43 | # or otherwise need to. # 44 | # # 45 | ############################# 46 | 47 | # Checks the list of replies every 30 minutes. 48 | self.config['reply_interval'] = 30 * 60 49 | 50 | ############################# 51 | # # 52 | # Customize your bot's # 53 | # behavior here. # 54 | # # 55 | ############################# 56 | 57 | # Custom state variables for your bot can be created here, using the 58 | # self.state dictionary. Note: any previous state loaded by the bot 59 | # will overwrite state values made here. In effect, these values 60 | # are initializations only. 61 | 62 | self.state['echo_counter'] = 0 63 | 64 | # User-provided functions to more fully customize bot behavior can 65 | # be registered here. By providing a function, a runtime interval, 66 | # and a boolean condition, you can effectively have your bot do 67 | # anything above and beyond the core actions provided. 68 | 69 | # If, in addition to echoing any replies this bot receives, you want 70 | # it to post the number of replies it's received so far every 24 hours, 71 | # you could register a function like this. 72 | 73 | # self.register_custom_callback(self.count_replies, 24 * 60 * 60) 74 | 75 | # count_replies() gives the current count of replies. 76 | # (yes this is something you could also do with `tweet_interval`) 77 | 78 | def on_tweet(self): 79 | """ 80 | Handler for posting a tweet to the bot's public timeline. 81 | """ 82 | pass 83 | 84 | def on_mention(self, tweet, prefix): 85 | """ 86 | Handler for responding to mentions at the bot. 87 | """ 88 | # Rebuild the tweet without any @-mentions, since we have those in prefix. 89 | text = " ".join([w for w in tweet.text.split(" ") if not w.startswith("@")]) 90 | 91 | # Echo the same status right back. 92 | self.update_status("%s %s" % (prefix, text), reply_to = tweet) 93 | 94 | # Increment the echo counter. 95 | self.state['echo_counter'] += 1 96 | 97 | def on_timeline(self, tweet, prefix): 98 | """ 99 | Handler for responding to tweets that appear in the bot's timeline. 100 | """ 101 | pass 102 | 103 | def on_search(self, tweet): 104 | """ 105 | Handler for responding to public tweets that contain certain keywords, 106 | as specified in self.config['search_keywords']. 107 | """ 108 | pass 109 | 110 | def on_follow(self, friend): 111 | """ 112 | Handler when a new follower is / new followers are detected. 113 | """ 114 | pass 115 | 116 | if __name__ == "__main__": 117 | bot = EchoBot() # In this case, a basic Echo bot. 118 | bot.run() 119 | -------------------------------------------------------------------------------- /examples/artbot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from pybot import PyBot 16 | 17 | import datetime 18 | 19 | from stravalib import Client 20 | from stravalib import unithelper 21 | 22 | class artbot(PyBot): 23 | 24 | def bot_init(self): 25 | """ 26 | Custom initialization. Specify any configuration options you want to 27 | override, as in particular your OAuth credentials. 28 | """ 29 | 30 | ############################# 31 | # # 32 | # Twitter OAuth Credentials # 33 | # # 34 | # FILL THESE IN! # 35 | # # 36 | ############################# 37 | 38 | self.config['api_key'] = 'your_api_key' 39 | self.config['api_secret'] = 'your_api_secret' 40 | self.config['access_key'] = 'your_access_key' 41 | self.config['access_secret'] = 'your_access_secret' 42 | 43 | ############################# 44 | # # 45 | # Other config options # 46 | # # 47 | # Fill these in if you want # 48 | # or otherwise need to. # 49 | # # 50 | ############################# 51 | 52 | self.config['bot_name'] = 'artbot' 53 | 54 | self.config['strava_access_token'] = 'your_strava_token' 55 | self.config['update_day'] = 0 56 | self.config['update_hour'] = 13 57 | self.config['update_minute'] = 13 58 | self.config['tweet_interval'] = self._compute_interval 59 | 60 | # Create the Strava client. 61 | self.client = Client(access_token = self.config['strava_access_token']) 62 | 63 | def on_tweet(self): 64 | # First, pull in the stats from Strava. 65 | current = datetime.datetime.now() 66 | last_week = current + datetime.timedelta(weeks = -1) 67 | after = datetime.datetime(last_week.year, last_week.month, last_week.day) 68 | activities = self.client.get_activities(after = after) 69 | 70 | # Second, filter by activity type and time frame. 71 | lf = [a for a in activities if a.start_date_local.day != current.day] 72 | num_activities = len(lf) 73 | l = [a.id for a in lf if a.type == 'Run'] 74 | 75 | # Third, tabulate up the stats for mileage and calories. 76 | mileage = 0.0 77 | calories = 0.0 78 | for activity_id in l: 79 | activity = self.client.get_activity(activity_id) 80 | distance = unithelper.miles(activity.distance) 81 | mileage += round(distance.num, 2) # Rounds to 2 sig figs. 82 | calories += activity.calories 83 | calories = int(calories) 84 | 85 | # Finally, use the stats to craft a tweet. This can be any format 86 | # you want, but I'll use the example one from the start of the post. 87 | tweet = "My training last week: {:d} workouts for {:.2f} miles and {:d} calories burned.".format(num_activities, mileage, calories) 88 | self.update_status(tweet) 89 | 90 | def _compute_interval(self): 91 | """ 92 | This is a little more sophisticated than the method in the original 93 | blog post. This is to provide for *exactly* specifying when we want 94 | a post to be made, down to the minute. 95 | """ 96 | now = datetime.datetime.now() 97 | target = datetime.datetime(year = now.year, month = now.month, day = now.day, 98 | hour = self.config['update_hour'], minute = self.config['update_minute']) 99 | days_ahead = self.config['update_day'] - now.weekday() 100 | if (days_ahead < 0) or (days_ahead == 0 and (target - now).days < 0): 101 | days_ahead += 7 102 | td = target + datetime.timedelta(days = days_ahead) 103 | interval = int((td - datetime.datetime.now()).total_seconds()) 104 | return interval 105 | 106 | if __name__ == "__main__": 107 | bot = artbot() 108 | bot.run() 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyBot, v0.3.0 2 | ============= 3 | 4 | This is a port of [my previous attempt at a Twitterbot](https://github.com/magsol/Twitterbot), the primary difference being this is in Python instead of PHP. Arguably an improvement all by itself :) 5 | 6 | PyBot is designed to be a modular and lightweight framework in which users can create and deploy autonomous bots that do all sorts of things on Twitter. PyBot helps you create the bots, but it's still largely up to you to implement how they work. 7 | 8 | Installation 9 | ------------ 10 | 11 | Download the source. Make sure the dependencies are satisfied. Yay! 12 | 13 | Dependencies 14 | ------------ 15 | 16 | - [Python 3.5+](http://www.python.org/) 17 | - [tweepy 3.3+](https://github.com/tweepy/tweepy) 18 | - Any other dependencies are bot-dependent 19 | 20 | Documentation 21 | ------------- 22 | 23 | **v0.3.0 is a significant overhaul of the source from v0.1.0.** Please read the following documentation carefully, especially if you are familiar with PyBot's previous architecture. 24 | 25 | **Creating new bots**: You can use the provided script in the `sbin` folder to create new bots: 26 | 27 | sbin/create_pybot.py 28 | 29 | This will give you a list of arguments you can provide. The only required argument is the bot's name; this can be anything you want, and it bears no intrinsic connection to the Twitter user you connect the bot to. The name you give this script is purely to distinguish between your PyBots. 30 | 31 | Optionally, you can provide your OAuth credentials if you have them already (`api_key`, `api_secret`, `access_key`, and `access_secret`). Otherwise, the script will take you through the process of registering an app on Twitter, generating the necessary credentials, and integrating them with your bot. 32 | 33 | **Implementing an action**: The core functionality of PyBot revolves around the concept of an *action*. Activities such as posting a tweet, reading a reply, "favorite-ing" a tweet, or searching for keywords all constitute different types of actions. 34 | 35 | There are two phases to an action: a *delay interval* and a *callback*. During the delay interval, or waiting time between handling time of actions, your bot essentially sleeps. Depending on the action, it may still be doing something behind the scenes (e.g. reading from Twitter's Streaming API), but for all practical purposes it is sleeping. 36 | 37 | Once the delay interval has elapsed, the callback phase kicks in. This is where the action is explicitly handled. 38 | 39 | As an example, let's say you want your bot to post the time every hour. Our `HourlyBot` will have a 1-hour tweet interval, set using this configuration option: 40 | 41 | def bot_init(self): 42 | 43 | self.config['tweet_interval'] = 60 * 60 44 | 45 | # ... 46 | # Other configuration options here 47 | # ... 48 | 49 | (the intervals are in seconds, so to get 60 minutes we need 3,600 seconds, or 60 * 60) 50 | 51 | By itself, this means `HourlyBot` will activate a *tweet* callback every 60 minutes. With the interval in place, now we have to implement the actual callback. This is done with the `on_tweet()` method. 52 | 53 | def on_tweet(self): 54 | from datetime import datetime 55 | self.update_status("It is %s." % datetime.strftime(datetime.now(), "%I:%M%p")) 56 | 57 | And that's it! The PyBot internals take care of logging, saving state, putting the bot to sleep between callbacks, and waking it up at the correct intervals. See the `examples/` folder for more examples. 58 | 59 | **Starting a bot**: Run the command 60 | 61 | python your_bot.py 62 | 63 | This will start the specified bot. The above script generates a bot that has a single action defined; you can specify more if you want. However, if you remove all actions, this will be detected and the bot will automatically terminate. Otherwise, it will simply run forever. 64 | 65 | **Stopping a bot**: A simple CTRL+C should do the trick! This will send a SIGTERM signal to your bot, which has a handler in place to catch the termination signal and gracefully shut down. 66 | 67 | Acknowledgements 68 | ---------------- 69 | 70 | The original inspiration for this bot came from [Rob Hall's postmaster9001](https://twitter.com/postmaster9001) in the late 2000s, and gave birth to the (now-deprecated) PHP version linked above. 71 | 72 | Architectural aspects of PyBot were inspired in part from [muffinista's chatterbot](https://github.com/muffinista/chatterbot/) and [thricedotted's twitterbot](https://github.com/thricedotted/twitterbot). In particular, the blacklist and DSL aspects come from muffinista, while the object-oriented design and functional callbacks are taken from thricedotted. 73 | 74 | If you are familiar with thricedotted's Python twitterbot, you will find many similarities in PyBot. I chose not to make PyBot a direct fork of twitterbot, as it is not backwards-compatible at all. Still, it retains enough architectural similarity to warrant mention. 75 | -------------------------------------------------------------------------------- /examples/trigrambot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import logging 16 | import numpy as np 17 | 18 | from pybot import PyBot 19 | 20 | class TrigramBot(PyBot): 21 | 22 | def bot_init(self): 23 | """ 24 | Custom initialization. Specify any configuration options you want to 25 | override, as in particular your OAuth credentials. 26 | """ 27 | 28 | ############################# 29 | # # 30 | # Twitter OAuth Credentials # 31 | # # 32 | # FILL THESE IN! # 33 | # # 34 | ############################# 35 | 36 | self.config['api_key'] = '' 37 | self.config['api_secret'] = '' 38 | self.config['access_key'] = '' 39 | self.config['access_secret'] = '' 40 | 41 | ############################# 42 | # # 43 | # Other config options # 44 | # # 45 | # Fill these in if you want # 46 | # or otherwise need to. # 47 | # # 48 | ############################# 49 | 50 | # Custom variables. 51 | self.config['trigram_s1'] = '_START1_' 52 | self.config['trigram_s2'] = '_START2_' 53 | self.config['trigram_end'] = '_STOP_' 54 | 55 | # Posts a tweet every 45-ish minutes. 56 | self.config['normal_mean'] = 45 57 | self.config['normal_std'] = 5 58 | self.config['tweet_interval'] = lambda: 60 * np.random.normal( 59 | loc = self.config['normal_mean'], scale = self.config['normal_std']) 60 | 61 | # Custom override--sampling the public timeline continuously. This is 62 | # admittedly a bit of a hack, as this callback will delete itself after 63 | # it runs only once. 64 | self.register_custom_callback(self.start_streaming, 1) 65 | 66 | def start_streaming(self): 67 | """ 68 | Custom helper to start the streaming process. 69 | """ 70 | # Start the streaming sample. 71 | self.stream.sample(languages = self.config['languages'], async = True) 72 | logging.info("Starting the streaming sample.") 73 | 74 | # Delete the custom callbacks, in case this was used to re-start 75 | # the streaming API. 76 | self.custom_callbacks = [] 77 | 78 | def on_tweet(self): 79 | """ 80 | Handler for posting a tweet to the bot's public timeline. 81 | 82 | Use the `self.update_status` method to post a tweet. 83 | 84 | Set `self.config['tweet_interval']` to something other than 0 to set 85 | the interval in which this method is called (or keep at 0 to disable). 86 | """ 87 | # Custom override--sampling the public timeline continuously. This is 88 | # admittedly a bit of a hack, but until I determine a more elegant way 89 | # of integrating streaming, this is how it must be. 90 | if not self.stream.running: 91 | self.register_custom_callback(self.start_streaming, 0) 92 | return # Need to wait for the tweet buffer to accumulate. 93 | 94 | # Check out the list of tweets from the buffer. 95 | tweets = list(reversed(self.buffer)) 96 | 97 | # Clear out the buffer. 98 | self.lock.acquire() 99 | self.buffer = [] 100 | self.lock.release() 101 | 102 | # Now let's process the tweets into a glorified 2nd-order Markov chain. 103 | model = {} 104 | for tweet in tweets: 105 | processed = '%s %s %s %s' % (self.config['trigram_s1'], 106 | self.config['trigram_s2'], tweet.text.strip(), 107 | self.config['trigram_end']) 108 | tokens = processed.split() 109 | triples = [[tokens[i], tokens[i + 1], tokens[i + 2]] for i in range(len(tokens) - 2)] 110 | for w1, w2, w3 in triples: 111 | key = (w1, w2) 112 | if key in model: 113 | model[key].append(w3) 114 | else: 115 | model[key] = [w3] 116 | 117 | # We have the model. Let's sample from it to build a post. 118 | post = "" 119 | k1 = self.config['trigram_s1'] 120 | k2 = self.config['trigram_s2'] 121 | key = (k1, k2) 122 | 123 | # Are there any tweets to process? This can happen if the bot was 124 | # stopped and restarted after a sufficiently long wait; the on_tweet 125 | # action will trigger immediately, but no tweets will be in the buffer. 126 | if key not in model: 127 | logging.warn("Model is devoid of tweets! If you didn't just restart your bot, make sure there isn't a problem.") 128 | return 129 | nextToken = model[key][np.random.randint(0, len(model[key]))] 130 | while nextToken != self.config['trigram_end'] and len('%s %s' % (post, nextToken)) < 140: 131 | post = '%s %s' % (post, nextToken) 132 | post = post.strip() 133 | k1 = k2 134 | k2 = nextToken 135 | key = (k1, k2) 136 | nextToken = model[key][np.random.randint(0, len(model[key]))] 137 | 138 | # Post the tweet! 139 | self.update_status(post) 140 | 141 | def on_mention(self, tweet, prefix): 142 | pass 143 | 144 | def on_timeline(self, tweet, prefix): 145 | pass 146 | 147 | def on_search(self, tweet): 148 | pass 149 | 150 | def on_follow(self, friend): 151 | pass 152 | 153 | if __name__ == "__main__": 154 | bot = TrigramBot() 155 | bot.run() 156 | -------------------------------------------------------------------------------- /sbin/create_pybot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | """ 15 | 16 | import argparse 17 | import os 18 | import os.path 19 | import tweepy 20 | import tweepy.error 21 | 22 | def valid_name(name): 23 | if not name.isalnum() or name.find(" ") > -1 or not name[0].isalpha() or name.lower() == "lib": 24 | raise argparse.ArgumentTypeError("""\"{}\" is an invalid bot name. It 25 | must not contain spaces, non-alphanumeric characters, or start with a number. 26 | """.format(name)) 27 | return name 28 | 29 | def _consumer_tokens(): 30 | """ 31 | Handles prompting the user with directions for obtaining and inputting 32 | the OAuth consumer tokens. 33 | """ 34 | print("""First, you'll need to create a Twitter app here: 35 | 36 | https://dev.twitter.com/apps/new 37 | 38 | This will provide you with "consumer key" and "consumer secret" tokens. When 39 | you have these tokens, make sure you're logged into Twitter with the account 40 | you want to use as your bot, and enter your tokens below. 41 | """) 42 | consumer_key = None 43 | consumer_secret = None 44 | check = "n" 45 | while check.lower() != "y": 46 | consumer_key = input("Consumer key: ") 47 | check = input("Was that correct? [y/n]: ") 48 | check = "n" 49 | while check.lower() != "y": 50 | consumer_secret = input("Consumer secret: ") 51 | check = input("Was that correct? [y/n]: ") 52 | return [consumer_key, consumer_secret] 53 | 54 | def _access_tokens(oauth): 55 | """ 56 | Handles prompting the user for creating and inputting the OAuth access tokens. 57 | """ 58 | print("""\nWe'll need to create access tokens specific to your bot. To 59 | do that, please visit the following URL: 60 | 61 | {} 62 | 63 | Once you have authorized the app with your bot account, you will receive a PIN. 64 | """.format(oauth.get_authorization_url())) 65 | 66 | check = "n" 67 | while check.lower() != "y": 68 | pin = input("Enter your PIN here: ") 69 | check = input("Was that correct? [y/n]: ") 70 | token = None 71 | try: 72 | token = oauth.get_access_token(verifier = pin) 73 | except tweepy.error.TweepError as e: 74 | print('Unable to authenticate! Check your OAuth credentials and run this script again.') 75 | quit(e.reason) 76 | 77 | # Everything worked! 78 | print("""Authentication successful! Wait just a minute while the rest of your 79 | bot's internals are set up... 80 | """) 81 | return token 82 | 83 | if __name__ == "__main__": 84 | parser = argparse.ArgumentParser(description = "Welcome to PyBot!", 85 | epilog = "lol tw33tz", add_help = "How to use", 86 | prog = "create_pybot.py") 87 | parser.add_argument("-n", "--name", required = True, type = valid_name, 88 | help = "Unique identifier for the new bot (REQUIRED).") 89 | parser.add_argument("--api_key", default = None, 90 | help = "OAuth Consumer Key. This can be used across multiple bots.") 91 | parser.add_argument("--api_secret", default = None, 92 | help = "OAuth Consumer Secret. This can be used across multiple bots.") 93 | parser.add_argument("--access_key", default = None, 94 | help = "Access token. Unique to a specific bot.") 95 | parser.add_argument("--access_secret", default = None, 96 | help = "Access token secret. Unique to a specific bot.") 97 | 98 | args = vars(parser.parse_args()) 99 | 100 | print(""" 101 | ********************* 102 | * Welcome to PyBot! * 103 | ********************* 104 | 105 | This script will help you set things up. 106 | 107 | """) 108 | botname = args['name'] 109 | consumer_key = args['api_key'] 110 | consumer_secret = args['api_secret'] 111 | access_token = args['access_key'] 112 | access_token_secret = args['access_secret'] 113 | 114 | if consumer_key is None or consumer_secret is None: 115 | # Case 1: Nothing is provided. 116 | consumer_key, consumer_secret = _consumer_tokens() 117 | auth = tweepy.OAuthHandler(consumer_key, consumer_secret) 118 | access_token, access_token_secret = _access_tokens(auth) 119 | elif access_token is None or access_token_secret is None: 120 | # Case 2: Consumer* is provided. 121 | auth = tweepy.OAuthHandler(consumer_key, consumer_secret) 122 | access_token, access_token_secret = _access_tokens(auth) 123 | 124 | # Create a couple of paths: 125 | # 1) To the root pybot directory, where this bot will reside 126 | # 2) To the pybot subdirectory, where the skeleton template is 127 | root = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..") 128 | tfile = os.path.join(os.path.join(root, "pybot"), "template.py") 129 | 130 | # Grab all the contents of the template. 131 | f = open(tfile, "r") 132 | template = f.read() 133 | f.close() 134 | 135 | # Replace all instances of "PyBotTemplate" with the bot's name. 136 | template = template.replace("PyBotTemplate", botname) 137 | 138 | # Also, add in all the OAuth stuff. 139 | ck_idx = template.find("''") 140 | template = template[:ck_idx + 1] + "%s" % consumer_key + template[ck_idx + 1:] 141 | cs_idx = template.find("''") 142 | template = template[:cs_idx + 1] + "%s" % consumer_secret + template[cs_idx + 1:] 143 | at_idx = template.find("''") 144 | template = template[:at_idx + 1] + "%s" % access_token + template[at_idx + 1:] 145 | as_idx = template.find("''") 146 | template = template[:as_idx + 1] + "%s" % access_token_secret + template[as_idx + 1:] 147 | 148 | # Write the botfile! 149 | f = open(os.path.join(root, "{}.py".format(botname.lower())), "w") 150 | f.write(template) 151 | f.close() 152 | 153 | print("""Your bot \"{}\" is ready to rock! Start it up with the following command: 154 | 155 | python {}.py 156 | """.format(botname, botname.lower())) 157 | -------------------------------------------------------------------------------- /pybot/template.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from pybot import PyBot 16 | 17 | class PyBotTemplate(PyBot): 18 | 19 | def bot_init(self): 20 | """ 21 | Custom initialization. Specify any configuration options you want to 22 | override, as in particular your OAuth credentials. 23 | """ 24 | 25 | ############################# 26 | # # 27 | # Twitter OAuth Credentials # 28 | # # 29 | # FILL THESE IN! # 30 | # # 31 | ############################# 32 | 33 | self.config['api_key'] = '' 34 | self.config['api_secret'] = '' 35 | self.config['access_key'] = '' 36 | self.config['access_secret'] = '' 37 | 38 | ############################# 39 | # # 40 | # Other config options # 41 | # # 42 | # Fill these in if you want # 43 | # or otherwise need to. # 44 | # # 45 | ############################# 46 | 47 | # Posts a tweet every 30 minutes. 48 | self.config['tweet_interval'] = 30 * 60 49 | self.config['bot_name'] = 'PyBotTemplate' 50 | 51 | ############################# 52 | # # 53 | # Customize your bot's # 54 | # behavior here. # 55 | # # 56 | ############################# 57 | 58 | # Custom state variables for your bot can be created here, using the 59 | # self.state dictionary. Note: any previous state loaded by the bot 60 | # will overwrite state values made here. In effect, these values 61 | # are initializations only. 62 | 63 | # self.state['echo_counter'] = 0 64 | 65 | # User-provided functions to more fully customize bot behavior can 66 | # be registered here. By providing a function, a runtime interval, 67 | # and a boolean condition, you can effectively have your bot do 68 | # anything above and beyond the core actions provided. 69 | 70 | # If, in addition to echoing any replies this bot receives, you want 71 | # it to post the number of replies it's received so far every 24 hours, 72 | # you could register a function like this. 73 | 74 | # self.register_custom_callback(self.count_replies, 24 * 60 * 60) 75 | 76 | # count_replies() gives the current count of replies. 77 | # (yes this is something you could also do with `tweet_interval`) 78 | 79 | def on_tweet(self): 80 | """ 81 | Handler for posting a tweet to the bot's public timeline. 82 | 83 | Use the `self.update_status` method to post a tweet. 84 | 85 | Set `self.config['tweet_interval']` to something other than 0 to set 86 | the interval in which this method is called (or keep at 0 to disable). 87 | """ 88 | pass 89 | 90 | def on_mention(self, tweet, prefix): 91 | """ 92 | Handler for responding to mentions at the bot. 93 | 94 | When calling `self.update_status`, make sure you set the `reply_to` 95 | parameter to point to the tweet object, or Twitter will not recognize 96 | this tweet as a reply to the original. 97 | 98 | Parameters 99 | ---------- 100 | tweet : tweepy.Status object 101 | Contains the status update pertaining to the mention. The fields in 102 | this object mimic Twitter's Tweet object: 103 | https://dev.twitter.com/overview/api/tweets 104 | prefix : string 105 | String containing all the mentions from the original tweet, excluding 106 | your bot's screen name, any users in the blacklist, and any users 107 | you do not follow IF self.config['reply_followers_only'] is True. 108 | """ 109 | pass 110 | 111 | def on_timeline(self, tweet, prefix): 112 | """ 113 | Handler for responding to tweets that appear in the bot's timeline. 114 | 115 | When calling `self.update_status`, make sure you set the `reply_to` 116 | parameter to point to the tweet object, or Twitter will not recognize 117 | this tweet as a reply to the original. 118 | 119 | Parameters 120 | ---------- 121 | tweet : tweepy.Status object 122 | Contains the status update pertaining to the mention. The fields in 123 | this object mimic Twitter's Tweet object: 124 | https://dev.twitter.com/overview/api/tweets 125 | prefix : string 126 | String containing all the mentions from the original tweet, excluding 127 | your bot's screen name, any users in the blacklist, and any users 128 | you do not follow IF self.config['reply_followers_only'] is True. 129 | """ 130 | pass 131 | 132 | def on_search(self, tweet): 133 | """ 134 | Handler for responding to public tweets that contain certain keywords, 135 | as specified in self.config['search_keywords']. 136 | 137 | Parameters 138 | ---------- 139 | tweet : tweepy.Status object 140 | Contains the status update pertaining to the mention. The fields in 141 | this object mimic Twitter's Tweet object: 142 | https://dev.twitter.com/overview/api/tweets 143 | """ 144 | pass 145 | 146 | def on_follow(self, friend): 147 | """ 148 | Handler when a new follower is / new followers are detected. 149 | 150 | For basic uses, the configuration options provided will suffice. If you 151 | also want the bot to do other things when a new follower is acquired, you 152 | will need to override this method (though you can avoid replicating the 153 | code that processes auto-follows by keeping super.on_follow()). 154 | 155 | Parameters 156 | ---------- 157 | friend : string 158 | Twitter user ID of the new follower. 159 | """ 160 | pass 161 | 162 | if __name__ == "__main__": 163 | bot = PyBotTemplate() # In this case, a basic Echo bot. 164 | bot.run() 165 | -------------------------------------------------------------------------------- /pybot/bootstrap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import ConfigParser 16 | import importlib 17 | import os 18 | import os.path 19 | import tweepy 20 | import tweepy.error 21 | 22 | def start(botname): 23 | module = _bot_exists(botname) 24 | instance = module.__getattribute__(botname.capitalize())() 25 | 26 | # Invokes the parent run() method first. This sets up the pidfile in case 27 | # the process is daemonized or set to run indefinitely. 28 | super(type(instance), instance).run() 29 | instance.run() 30 | 31 | def stop(botname): 32 | """ 33 | This method of stopping a bot is only required if you have backgrounded 34 | or otherwise daemonized its process, and it would run indefinitely otherwise. 35 | """ 36 | _bot_exists(botname) 37 | cfg = ConfigParser.SafeConfigParser() 38 | cfg.read('%s/settings.cfg' % botname) 39 | pidfile = cfg.get('bot', 'pidfile') 40 | if os.path.exists(pidfile): 41 | # Delete the pidfile. This will message the process 42 | pid = -1 43 | try: 44 | f = open(pidfile, "r") 45 | pid = int(f.readline().strip()) 46 | f.close() 47 | os.remove(pidfile) 48 | except IOError: 49 | print 'Unable to read PID file. Perhaps "%s" is not running?' % botname 50 | except OSError, err: 51 | err = str(err) 52 | print err 53 | print 'Sent process %s a halt signal.' % pid 54 | else: 55 | print 'PID file "%s" does not exist. Perhaps "%s" is not running?' % (pidfile, botname) 56 | 57 | def list(): 58 | bots = 0 59 | running = 0 60 | harddirs = ['lib', 'test'] 61 | for item in os.listdir("."): 62 | if os.path.isdir(item) and item.lower() not in harddirs and item[0] != ".": 63 | bots += 1 64 | _bot_exists(item) 65 | cfg = ConfigParser.SafeConfigParser() 66 | cfg.read('%s/settings.cfg' % item) 67 | pid = '' 68 | if os.path.exists(cfg.get("bot", "pidfile")): 69 | running += 1 70 | f = open(cfg.get("bot", "pidfile"), "r") 71 | pid = ' (running: %s)' % int(f.readline().strip()) 72 | f.close() 73 | print '[%s] %s%s' % (bots, cfg.get("bot", "name"), pid) 74 | print '\n%s bot%s found (%s running).' % (bots, 's' if bots != 1 else '', running) 75 | 76 | def create(botname, consumer_key, consumer_secret, access_token, access_token_secret): 77 | """ 78 | Creates a new bot and takes the user through the initialization and setup process. 79 | 80 | While potentially any combination of consumer* and access* can be None, 81 | the only cases supported here are: 82 | - all are provided 83 | - consumer* are provided 84 | - none are provided 85 | 86 | Parameters 87 | ---------- 88 | botname : string 89 | Name of the bot the user wishes to create. Must be unique. 90 | consumer_key : string or None 91 | consumer_secret : string or None 92 | Consumer key and secret for the app. The same pairing can be used across 93 | many bots. These need to be obtained from dev.twitter.com. If either of 94 | these is None, the user will be prompted to enter both. 95 | access_token : string or None 96 | access_token_secret : string or None 97 | Access token and secret. A unique pair must be generated for each bot. 98 | If either of these is None, a new pair will be generated automatically. 99 | """ 100 | 101 | print """********************* 102 | * Welcome to PyBot! * 103 | ********************* 104 | 105 | This script will help you set things up. 106 | 107 | """ 108 | if consumer_key is None or consumer_secret is None: 109 | # Case 1: Nothing is provided. 110 | consumer_key, consumer_secret = _consumer_tokens() 111 | auth = tweepy.OAuthHandler(consumer_key, consumer_secret) 112 | access_token, access_token_secret = _access_tokens(auth) 113 | elif access_token is None or access_token_secret is None: 114 | # Case 2: Consumer* is provided. 115 | auth = tweepy.OAuthHandler(consumer_key, consumer_secret) 116 | access_token, access_token_secret = _access_tokens(auth) 117 | 118 | # Create the directory, configuration file, and skeleton bot script.""" 119 | botdir = "%s/%s" % (os.path.abspath("."), botname) 120 | _write_bot(botname, botdir, consumer_key, consumer_secret, access_token, access_token_secret) 121 | 122 | print """Your bot \"%s\" is ready to rock! Start it up with the following command: 123 | 124 | python pybot.py start -n %s""" % (botname, botname) 125 | 126 | 127 | def _bot_exists(botname): 128 | """ 129 | Utility method to import a bot. 130 | """ 131 | module = None 132 | try: 133 | module = importlib.import_module('%s.%s' % (botname, botname)) 134 | except ImportError as e: 135 | quit('Unable to import bot "%s.%s": %s' % (botname, botname, str(e))) 136 | 137 | return module 138 | 139 | def _consumer_tokens(): 140 | """ 141 | Handles prompting the user with directions for obtaining and inputting 142 | the OAuth consumer tokens. 143 | """ 144 | print """First, you'll need to create a Twitter app here: 145 | 146 | https://dev.twitter.com/apps/new 147 | 148 | This will provide you with "consumer key" and "consumer secret" tokens. When 149 | you have these tokens, make sure you're logged into Twitter with the account 150 | you want to use as your bot, and enter your tokens below. 151 | """ 152 | consumer_key = None 153 | consumer_secret = None 154 | check = "n" 155 | while check.lower() != "y": 156 | consumer_key = raw_input("Consumer key: ") 157 | check = raw_input("Was that correct? [y/n]: ") 158 | check = "n" 159 | while check.lower() != "y": 160 | consumer_secret = raw_input("Consumer secret: ") 161 | check = raw_input("Was that correct? [y/n]: ") 162 | return [consumer_key, consumer_secret] 163 | 164 | def _access_tokens(oauth): 165 | """ 166 | Handles prompting the user for creating and inputting the OAuth access tokens. 167 | """ 168 | print """\nWe'll need to create access tokens specific to your bot. To 169 | do that, please visit the following URL: 170 | 171 | %s 172 | 173 | Once you have authorized the app with your bot account, you will receive a PIN. 174 | """ % oauth.get_authorization_url() 175 | check = "n" 176 | while check.lower() != "y": 177 | pin = raw_input("Enter your PIN here: ") 178 | check = raw_input("Was that correct? [y/n]: ") 179 | token = None 180 | try: 181 | token = oauth.get_access_token(verifier = pin) 182 | except tweepy.error.TweepError as e: 183 | print 'Unable to authenticate! Check your OAuth credentials and run this script again.' 184 | quit(e.reason) 185 | 186 | # Everything worked! 187 | print """Authentication successful! Wait just a minute while the rest of your 188 | bot's internals are set up... 189 | """ 190 | return token 191 | 192 | def _write_bot(botname, botdir, c_key, c_secret, a_token, a_secret): 193 | """ 194 | Handles creating the bot directory and writing all the components of the bot: 195 | configuration, models, and the main driver. 196 | """ 197 | os.mkdir(botdir) 198 | os.chdir(botdir) 199 | f = open("./settings.cfg", "w") 200 | c = """# Sets the configuration options of the bot itself. 201 | 202 | [bot] 203 | # Name of the bot. DO NOT CHANGE. 204 | name = %s 205 | 206 | # Path to the pid file for the process. DO NOT CHANGE. 207 | pidfile = %s/%s.pid 208 | 209 | # Debug level for your bot. To disable logging, set to -1. For a list of levels, see: http://docs.python.org/2/library/logging.html#levels 210 | debug = 10 211 | 212 | # Log file. Set this to whatever you like, but make sure you have write permissions. 213 | logfile = %s/%s.log 214 | 215 | # Path to the SQLite database. If you're not using a database, leave this blank. 216 | dbfile = sqlite:///%s/sqlite3.db 217 | 218 | # Comma-separated list of Twitter handles to avoid. This can be left blank. 219 | blacklist = 220 | 221 | # The consumer key and secret come from your Twitter app. The access tokens are 222 | # account-specific and need to be regenerated if you decide to deauthorize and 223 | # re-authorize the app to access your bot's twitter account. 224 | 225 | [oauth] 226 | consumer_key = %s 227 | consumer_secret = %s 228 | access_token = %s 229 | access_token_secret = %s""" % (botname, botdir, botname, botdir, botname, botdir, c_key, c_secret, a_token, a_secret) 230 | f.write(c) 231 | f.close() 232 | f = open('./%s.py' % botname, "w") 233 | script = """import tweepy 234 | import ConfigParser 235 | import sqlalchemy 236 | import sqlalchemy.orm 237 | 238 | from lib.bot import Bot 239 | import models 240 | 241 | 242 | class %s(Bot): 243 | 244 | def __init__(self): 245 | # Read the configuration file. 246 | cfg = ConfigParser.SafeConfigParser() 247 | cfg.read('%s/%s/settings.cfg') 248 | 249 | # Create an OAuth object and initialize the Tweepy API. 250 | auth = tweepy.OAuthHandler( 251 | cfg.get('oauth', 'consumer_key'), cfg.get('oauth', 'consumer_secret')) 252 | auth.set_access_token( 253 | cfg.get('oauth', 'access_token'), cfg.get('oauth', 'access_token_secret')) 254 | api = tweepy.API(auth) 255 | 256 | # Set up the database. 257 | dbfile = cfg.get('bot', 'dbfile') 258 | if len(dbfile) > 0: 259 | _engine = sqlalchemy.create_engine(dbfile, 260 | echo = True if cfg.getint('bot', 'debug') > 10 else False) 261 | self._session_factory = sqlalchemy.orm.sessionmaker(bind = _engine) 262 | models.Base.metadata.create_all(_engine) 263 | 264 | # Invoke the parent constructor. 265 | super(%s, self).__init__(cfg, api) 266 | 267 | def run(self): 268 | ### Implement this method! ### 269 | pass 270 | """ % (botname.capitalize(), os.path.dirname(os.path.abspath('.')), 271 | botname, botname.capitalize()) 272 | f.write(script) 273 | f.close() 274 | f = open("./models.py", "w") 275 | script = """import sqlalchemy 276 | from sqlalchemy.ext.declarative import declarative_base 277 | 278 | Base = declarative_base() 279 | 280 | # If you want to define any database constructs, you may do so here. Otherwise, 281 | # feel free to leave this file unedited. CHOOSE WISELY.""" 282 | f.write(script) 283 | f.close() 284 | open("./__init__.py", "w").close() 285 | -------------------------------------------------------------------------------- /pybot/pybot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import http.client 16 | import logging 17 | import multiprocessing as mp 18 | import re 19 | import signal 20 | import sys 21 | import time 22 | 23 | import tweepy 24 | 25 | from .storage import PickleStorage 26 | 27 | class PyBot(tweepy.StreamListener): 28 | 29 | def __init__(self): 30 | # Basic configuration and state variables for the bot. 31 | self.config = {} 32 | self.state = {} 33 | 34 | # List of custom callbacks that are user-defined. 35 | self.custom_callbacks = [] 36 | 37 | # # # # # # # # # # # # # # # # # # # # # # # 38 | # Configuration options and their defaults. # 39 | # # # # # # # # # # # # # # # # # # # # # # # 40 | 41 | # Action intervals. Set any of these to 0 to disable them entirely. 42 | # Otherwise, the integer indicates the number of seconds between 43 | # heartbeats. Alternatively, these can be callables that return 44 | # some number (integer) of seconds. 45 | self.actions = ['timeline', # Something shows up on the home timeline. 46 | 'mention', # This bot gets mentioned. 47 | 'search', # Something appears in a keyword search. 48 | 'follow', # This bot gets a new follower. 49 | 'tweet', # This bot posts a tweet. 50 | ] 51 | self.config = {'%s_interval' % action: 0 for action in self.actions} 52 | 53 | # Number of tweets allowed to be buffered in-memory from the streaming 54 | # API. Increasing this number gives you a larger sample of tweets, but 55 | # it can potentially crash your machine if the number is too high. 56 | # If an incoming status would overflow the buffer, the oldest status in 57 | # the buffer is discarded. 58 | self.config['streaming_buffer_length'] = 100000 59 | 60 | # List of keywords to search for and take action on when found. 61 | # NOTE: This is a completely separate list of keywords from the 62 | # `autofav_keywords` list below. 63 | self.config['search_keywords'] = [] 64 | 65 | # If True, this bot replies ONLY in response to direct mentions. 66 | self.config['reply_direct_mention_only'] = False 67 | 68 | # If True, this bot issues direct replies ONLY to accounts it follows. 69 | self.config['reply_followers_only'] = False 70 | 71 | # If True, automatically favorites all direct mentions. 72 | self.config['autofav_direct_mentions'] = False 73 | 74 | # If the bot detects tweets that contain the listed keywords, those 75 | # tweets are automatically favorite'd. 76 | self.config['autofav_keywords'] = [] 77 | 78 | # If True, this bot automatically follows any account that follows it. 79 | self.config['autofollow'] = False 80 | 81 | # If True, this bot discards any timeline tweets with mentions (to any user). 82 | self.config['ignore_timeline_mentions'] = False 83 | 84 | # Logging level. See https://docs.python.org/2/library/logging.html#logging-levels 85 | # for more details. 86 | self.config['logging_level'] = logging.DEBUG 87 | 88 | # Adapter for saving/loading this PyBot's state. 89 | self.config['storage'] = PickleStorage() 90 | 91 | # Denotes users the bot will never mention or respond to. 92 | self.config['blacklist'] = [] 93 | 94 | # Languages to filter on when conducting searches. 95 | self.config['languages'] = ['en'] 96 | 97 | # 98 | # End configuration options. 99 | # 100 | # Now we start initializing the bot. 101 | # 102 | 103 | # Required implementation by all subclasses. Produces an error if it 104 | # is not implemented. 105 | self.bot_init() 106 | 107 | # Set up a signal handler so a bot can gracefully exit. 108 | signal.signal(signal.SIGINT, self._handler) 109 | 110 | # Set up OAuth with Twitter and pull down some basic identities. 111 | auth = tweepy.OAuthHandler(self.config['api_key'], self.config['api_secret']) 112 | auth.set_access_token(self.config['access_key'], self.config['access_secret']) 113 | self.api = tweepy.API(auth) 114 | self.id = self.api.me().id 115 | self.screen_name = self.api.me().screen_name 116 | 117 | # Set up the streaming API. May or may not need this. 118 | self.stream = tweepy.Stream(auth, self) 119 | self.lock = mp.Lock() 120 | self.buffer = [] 121 | 122 | # Set up logging. 123 | logging.basicConfig(format = '%(asctime)s | %(levelname)s: %(message)s', 124 | datefmt = '%m/%d/%Y %I:%M:%S %p', 125 | filename = '{}.log'.format(self.config['bot_name']), 126 | level = self.config['logging_level']) 127 | 128 | # Try to load any previous state. 129 | logging.info("---STARTUP---") 130 | logging.info("Setting bot state...") 131 | s = self.config['storage'].read('{}_state.pkl'.format(self.config['bot_name'])) 132 | if s is None: 133 | # No previous state to load? Initialize everything. 134 | curr_t = time.time() 135 | 136 | # Timeline configuration options. Set timeline_interval to 0 to 137 | # disable checking the bot's timeline. 138 | self.state['last_timeline_id'] = 1 139 | self.state['last_timeline_time'] = curr_t 140 | self.state['next_timeline_time'] = self._increment(curr_t, self.config['timeline_interval']) 141 | 142 | # Mention configuration options. Set mention_interval to 0 to 143 | # disable checking the bot's mentions. 144 | self.state['last_mention_id'] = 1 145 | self.state['last_mention_time'] = curr_t 146 | self.state['next_mention_time'] = self._increment(curr_t, self.config['mention_interval']) 147 | 148 | # Keyword search configuration options. Set search_interval to 0 to 149 | # disable searching for keywords in the public timeline. 150 | self.state['last_search_id'] = 1 151 | self.state['last_search_time'] = curr_t 152 | self.state['next_search_time'] = self._increment(curr_t, self.config['search_interval']) 153 | self.state['search_hits'] = [] 154 | 155 | # Active tweeting configuration. Set tweet_interval to 0 to 156 | # disable posting otherwise-unprovoked tweets. 157 | self.state['last_tweet_id'] = 1 158 | self.state['last_tweet_time'] = curr_t 159 | self.state['next_tweet_time'] = self._increment(curr_t, self.config['tweet_interval']) 160 | 161 | # List of user IDs you follow. 162 | self.state['friends'] = self.api.friends_ids(self.id) 163 | 164 | # List of user IDs that follow you. 165 | self.state['followers'] = self.api.followers_ids(self.id) 166 | 167 | # List of new followers since the last check (internal) timestamp. 168 | self.state['new_followers'] = [] 169 | self.state['last_follow_time'] = curr_t 170 | self.state['next_follow_time'] = self._increment(curr_t, self.config['follow_interval']) 171 | else: 172 | # Use loaded state. 173 | self.state = s 174 | 175 | logging.info("Bot state set.") 176 | 177 | # # # # # # # # # # # # # # # # # # # # # # # 178 | # Available should the user wish. # 179 | # # # # # # # # # # # # # # # # # # # # # # # 180 | 181 | def register_custom_callback(self, action, interval): 182 | """ 183 | Registers a user-defined callback action. Performs the action after 184 | the specified interval. 185 | 186 | Parameters 187 | ---------- 188 | action : function 189 | Function which specifies the custom action to take. 190 | interval : integer or callable 191 | Number of seconds to wait before execution, or a 192 | callable that returns the number of seconds to wait. 193 | """ 194 | callback = { 195 | 'action': action, 196 | 'interval': interval, 197 | 'last_run': 0, 198 | 'next_run': 0, 199 | } 200 | self.custom_callbacks.append(callback) 201 | 202 | # # # # # # # # # # # # # # # # # # # # # # # 203 | # Methods that MUST be implemented. # 204 | # # # # # # # # # # # # # # # # # # # # # # # 205 | 206 | def on_tweet(self): 207 | raise NotImplementedError("Need to implement (or pass) 'on_tweet'.") 208 | 209 | def on_mention(self, tweet, prefix): 210 | raise NotImplementedError("Need to implement (or pass) 'on_mention'.") 211 | 212 | def on_timeline(self, tweet, prefix): 213 | raise NotImplementedError("Need to implement (or pass) 'on_timeline'.") 214 | 215 | def on_follow(self, friend): 216 | raise NotImplementedError("Need to implement (or pass) 'on_follow'.") 217 | 218 | def on_search(self, tweet): 219 | raise NotImplementedError("Need to implement (or pass) 'on_search'.") 220 | 221 | def bot_init(self): 222 | raise NotImplementedError("D'oh, you didn't implement the 'bot_init()' method.") 223 | 224 | # # # # # # # # # # # # # # # # # # # # # # # 225 | # Call this to start your bot. # 226 | # # # # # # # # # # # # # # # # # # # # # # # 227 | 228 | def run(self): 229 | """ 230 | PyBot's main run method. This activates ALL the things. 231 | """ 232 | self.running = True 233 | while self.running: 234 | intervals = [] 235 | current_time = time.time() 236 | 237 | # Check all the built-in actions. 238 | for action in self.actions: 239 | if self.config['%s_interval' % action] != 0 and \ 240 | current_time > self.state['next_%s_time' % action]: 241 | 242 | # Do something. 243 | getattr(self, '_handle_%s' % action)() 244 | 245 | # Update state. 246 | self.state['last_%s_time' % action] = current_time 247 | self.state['next_%s_time' % action] = self._increment(current_time, self.config['%s_interval' % action]) 248 | if self.config['%s_interval' % action] != 0: 249 | intervals.append(self.state['next_%s_time' % action]) 250 | 251 | # Check custom handlers. 252 | for callback in self.custom_callbacks: 253 | if current_time > callback['next_run']: 254 | # Invoke the action! 255 | callback['action']() 256 | 257 | # Update the interval for the next run. 258 | callback['last_run'] = current_time 259 | callback['next_run'] = self._increment(current_time, callback['interval']) 260 | intervals.append(callback['next_run']) 261 | 262 | # Are there any more actions? 263 | if len(intervals) == 0: 264 | logging.warn("No actions are set! Switching bot OFF.") 265 | self.running = False 266 | else: 267 | # Save the current state. 268 | self._save_state() 269 | 270 | # Find the next timestamp. 271 | next_action = min(intervals) 272 | if current_time < next_action: 273 | logging.info("Sleeping for %.4f seconds." % (next_action - current_time)) 274 | time.sleep(next_action - current_time) 275 | 276 | # If the loop breaks, someone hit CTRL+C. 277 | logging.info("---SHUTDOWN---") 278 | 279 | # # # # # # # # # # # # # # # # # # # # # # # 280 | # Twitter DSL methods. Use these often. # 281 | # # # # # # # # # # # # # # # # # # # # # # # 282 | 283 | def update_status(self, status, reply_to = None, lat = None, lon = None): 284 | """ 285 | Basic DSL method for posting a status update to Twitter. 286 | 287 | Parameters 288 | ---------- 289 | status : string 290 | Text of the update. Truncated to 140 characters, so make sure 291 | it's the right length. 292 | reply_to : tweepy.Status or None 293 | Implements the threaded tweets on Twitter, marking this as a reply. 294 | lat, lon : float or None 295 | Latitude and longitude of the tweet location. 296 | 297 | Returns 298 | ------- 299 | True on success, False on failure. 300 | """ 301 | status = status.format('utf8', 'ignore') 302 | kwargs = {'status': status} 303 | 304 | try: 305 | logging.info("Tweeting: '%s'" % status) 306 | if reply_to is not None: 307 | logging.info("--Response to %s" % self._tweet_url(reply_to)) 308 | kwargs['in_reply_to_status_id'] = reply_to.id 309 | 310 | # Push out the tweet. 311 | tweet = self.api.update_status(**kwargs) 312 | 313 | # Log the URL. 314 | logging.info("Tweet posted at %s" % self._tweet_url(tweet)) 315 | return True 316 | 317 | except tweepy.TweepError as e: 318 | logging.error("Unable to post tweet: %s" % e[0][0]['message']) 319 | return False 320 | 321 | def create_favorite(self, tweet): 322 | """ 323 | Basic DSL for favorite-ing a tweet. 324 | 325 | Parameters 326 | ---------- 327 | tweet : tweepy.Status 328 | tweepy Status object. 329 | 330 | Returns 331 | ------- 332 | True on success, False on failure. 333 | """ 334 | try: 335 | logging.info("Favoriting %s" % self._tweet_url(tweet)) 336 | self.api.create_favorite(tweet.id) 337 | return True 338 | except tweepy.TweepError as e: 339 | logging.error("Unable to favorite tweet: %s" % e[0][0]['message']) 340 | return False 341 | 342 | def create_friendship(self, friend): 343 | """ 344 | Basic DSL for following a twitter user. 345 | 346 | Parameters 347 | ---------- 348 | friend : integer 349 | Twitter ID of the user to follow. 350 | 351 | Returns 352 | ------- 353 | True on success, False on failure. 354 | """ 355 | try: 356 | logging.info("Following user %s" % friend) 357 | self.api.create_friendship(friend, follow = True) 358 | self.state['friends'].append(friend) 359 | return True 360 | except tweepy.TweepError as e: 361 | logging.error("Unable to follow user '%s': %s" % (friend, e[0][0]['message'])) 362 | return False 363 | 364 | # # # # # # # # # # # # # # # # # # # # # # # 365 | # Helper methods. Leave these alone. # 366 | # # # # # # # # # # # # # # # # # # # # # # # 367 | 368 | def _handle_tweet(self): 369 | """ 370 | Processes posting a tweet. 371 | """ 372 | logging.info("Preparing for posting a new tweet...") 373 | self.on_tweet() 374 | logging.info("Tweet completed") 375 | 376 | def _handle_timeline(self): 377 | """ 378 | Processes the home timeline for the bot (excludes mentions). 379 | """ 380 | logging.info("Reading current timeline...") 381 | try: 382 | # Retrieve the last 500 posts on the timeline. 383 | timeline = self.api.home_timeline( 384 | since_id = self.state['last_timeline_id'], count = 500) 385 | 386 | # Delete tweets this bot posted, tweets that mention this bot, and blacklisted users. 387 | current_timeline = [] 388 | for t in timeline: 389 | if t.author.screen_name.lower() != self.screen_name.lower() and \ 390 | t.author.screen_name.lower() not in self.blacklist and \ 391 | not re.search('@%s' % self.screen_name, t.text, flags = re.IGNORECASE): 392 | current_timeline.append(t) 393 | 394 | # Do we ignore ALL mentions (tweets that mention OTHER users, NOT the bot)? 395 | if self.config['ignore_timeline_mentions']: 396 | current_timeline = [t for t in current_timeline if '@' not in t.text] 397 | 398 | # Process what's left over. 399 | if len(current_timeline) > 0: 400 | self.state['last_timeline_id'] = current_timeline[0].id 401 | for tweet in list(reversed(current_timeline)): 402 | # Run the tweet through the timeline callback. 403 | prefix = self._mention_prefix(tweet) 404 | self.on_timeline(tweet, prefix) 405 | 406 | # Check the tokens in the tweet for keywords. 407 | words = tweet.text.lower().split() 408 | if any(w in words for w in self.config['autofav_keywords']): 409 | self.create_favorite(tweet) 410 | 411 | except tweepy.TweepError as e: 412 | logging.error("Unable to retrieve timeline: %s" % e[0][0]['message']) 413 | except http.client.IncompleteRead as e: 414 | logging.error("IncompleteRead error, aborting timeline update.") 415 | 416 | logging.info("Finished processing timeline.") 417 | 418 | def _handle_mention(self): 419 | """ 420 | Processes the list of mentions for the bot. 421 | """ 422 | logging.info("Checking for new mentions...") 423 | try: 424 | # Snag the last 100 mentions. 425 | mentions = self.api.mentions_timeline( 426 | since_id = self.state['last_mention_id'], count = 100) 427 | 428 | # Do we only look at direct mentions? 429 | if self.config['reply_direct_mention_only']: 430 | mentions = [t for t in mentions if re.split('[^@\w]', t.text)[0] == '@%s' % self.screen_name] 431 | 432 | # Process remaining mentions. 433 | if len(mentions) > 0: 434 | self.state['last_mention_id'] = mentions[0].id 435 | for mention in list(reversed(mentions)): 436 | 437 | # Send the tweet to the callback. 438 | prefix = self._mention_prefix(mention) 439 | self.on_mention(mention, prefix) 440 | 441 | # Do we autofav? 442 | if self.config['autofav_direct_mentions']: 443 | self.create_favorite(mention) 444 | 445 | except tweepy.TweepError as e: 446 | logging.error("Unable to retrieve mentions: %s" % e[0][0]['message']) 447 | except http.client.IncompleteRead as e: 448 | logging.error("IncompleteRead error, aborting mentions.") 449 | 450 | logging.info("Finished processing mentions.") 451 | 452 | def _handle_search(self): 453 | """ 454 | Conducts a keyword search. 455 | """ 456 | logging.info("Searching for keywords...") 457 | 458 | # Is the streamer even running? 459 | if not self.stream.running: 460 | # Are there any keywords we should be filtering on? 461 | if len(self.config['search_keywords']) > 0: 462 | # Yes, do a filter on the keywords. 463 | logging.info("Starting the streaming filter.") 464 | self.stream.filter(languages = self.config['languages'], 465 | track = self.config['search_keywords'], 466 | is_async = True) 467 | else: 468 | # Nope, just do a sample. 469 | self.stream.sample(languages = self.config['languages'], is_async = True) 470 | logging.info("Starting the streaming sample.") 471 | else: 472 | # Grab the static snapshot of the current buffer. 473 | tweets = list(reversed(self.buffer)) 474 | 475 | # Clear out the buffer. 476 | self.lock.acquire() 477 | self.buffer = [] 478 | self.lock.release() 479 | 480 | # Process the tweets. 481 | logging.info("Received %s tweets from the streaming API, now processing." % len(tweets)) 482 | for tweet in tweets: 483 | if tweet.author.screen_name in self.config['blacklist']: continue 484 | self.on_search(tweet) 485 | 486 | # Test for autofav keywords. 487 | # Check the tokens in the tweet for keywords. 488 | words = tweet.text.lower().split() 489 | if any(w in words for w in self.config['autofav_keywords']): 490 | self.create_favorite(tweet) 491 | 492 | logging.info("Search complete.") 493 | 494 | def _handle_followers(self): 495 | """ 496 | Processes new followers and invokes the appropriate callback. 497 | """ 498 | logging.info("Checking for new followers...") 499 | 500 | # Grab the list of new followers. 501 | try: 502 | self.state['new_followers'] = [fid for fid in self.api.followers_ids(self.id) if fid not in self.state['followers'] and self.api.get_user(fid).screen_name not in self.blacklist] 503 | except tweepy.TweepError as e: 504 | logging.error("Unable to update followers: %s (%s)" % (e[0]['message'], e[0]['code'])) 505 | 506 | # Invoke the callback. 507 | for f in self.state['new_followers']: 508 | self.state['followers'].append(f) 509 | 510 | # Do we automatically follow back? 511 | if self.config['autofollow']: 512 | self.create_friendship(f) 513 | 514 | # Callback. 515 | self.on_follow(f) 516 | 517 | # Update the timestamps. 518 | logging.info("Followers updated") 519 | if len(self.state['new_followers']) > 0: 520 | logging.info("--%s new followers processed" % len(self.state['new_followers'])) 521 | self.state['new_followers'] = [] 522 | 523 | def _mention_prefix(self, tweet): 524 | """ 525 | Helper method to get the list of mentions in a tweet for responding. 526 | """ 527 | respond = ['@%s' % tweet.author.screen_name] 528 | respond += [s for s in re.split('[^@\w]', tweet.text) if len(s) > 2 and s[0] == '@' and s[1:].lower() != self.screen_name.lower() and s[1:].lower() not in self.blacklist] 529 | 530 | if self.config['reply_followers_only']: 531 | # Delete any users who aren't a follower. 532 | respond = [s for s in respond if s[1:].lower() in self.state['followers'] or s == '@%s' % tweet.author.screen_name] 533 | 534 | # Return the list as a string. 535 | return ' '.join(respond) 536 | 537 | def _tweet_url(self, tweet): 538 | """ 539 | Helper method for constructing a URL to a specific tweet. 540 | """ 541 | return "https://twitter.com/%s/status/%s" % (tweet.author.screen_name, str(tweet.id)) 542 | 543 | def _increment(self, previous, interval): 544 | """ 545 | Helper method for dealing with callable time intervals. 546 | """ 547 | update = previous 548 | if hasattr(interval, '__call__'): 549 | update += interval() 550 | else: 551 | update += interval 552 | return update 553 | 554 | def _handler(self, signum, frame): 555 | """ 556 | Signal handler. Gracefully exits. 557 | """ 558 | logging.info("SIGINT caught, shutting down.") 559 | self.running = False 560 | if self.stream.running: 561 | self.stream.disconnect() 562 | self._save_state() 563 | sys.exit() 564 | 565 | def _save_state(self): 566 | """ 567 | Serializes the current bot's state in case we halt. 568 | """ 569 | self.config['storage'].write('{}_state.pkl'.format(self.config['bot_name']), self.state) 570 | logging.info("Bot state saved.") 571 | 572 | # # # # # # # # # # # # # # # # # # # # # # # 573 | # Streaming methods. Leave these alone. # 574 | # # # # # # # # # # # # # # # # # # # # # # # 575 | 576 | def on_status(self, status): 577 | """ 578 | Invoked whenever a new status arrives through the streaming listener, 579 | whether from sample() or filter(). The status is appended to the buffer; 580 | if the buffer length exceeds its limit, the oldest status is dropped. 581 | """ 582 | # Acquire the lock. 583 | self.lock.acquire() 584 | 585 | # Would adding this tweet to the buffer overflow it? 586 | while len(self.buffer) >= self.config['streaming_buffer_length']: 587 | self.buffer.pop(0) 588 | 589 | # Append the new status 590 | self.buffer.append(status) 591 | 592 | # Release the lock. 593 | self.lock.release() 594 | 595 | def on_error(self, status_code): 596 | pass 597 | 598 | def on_exception(self, exception): 599 | pass 600 | --------------------------------------------------------------------------------