Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Jeremy Simpson
authored and
Jeremy Simpson
committed
Jan 20, 2015
0 parents
commit 94ddc77
Showing
22 changed files
with
1,247 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# My stuff | ||
.DS_Store | ||
.idea/ | ||
local_settings.py | ||
|
||
# Byte-compiled / optimized / DLL files | ||
__pycache__/ | ||
*.py[cod] | ||
|
||
# C extensions | ||
*.so | ||
|
||
# Distribution / packaging | ||
.Python | ||
env/ | ||
build/ | ||
develop-eggs/ | ||
dist/ | ||
downloads/ | ||
eggs/ | ||
lib/ | ||
lib64/ | ||
parts/ | ||
sdist/ | ||
var/ | ||
*.egg-info/ | ||
.installed.cfg | ||
*.egg | ||
|
||
# Installer logs | ||
pip-log.txt | ||
pip-delete-this-directory.txt |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
.PHONY: install | ||
|
||
install: | ||
make clean | ||
python setup.py install | ||
make clean | ||
|
||
develop: | ||
make clean | ||
python setup.py develop | ||
rm -rf build/ dist/ | ||
|
||
clean: | ||
rm -rf build/ dist/ *.egg-info | ||
find . -name '*.pyc' -delete |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
gevent>=1.0.1 | ||
praw>=2.1.19 | ||
pylru>=1.0.6 | ||
requests>=2.5.1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
__import__("pkg_resources").declare_namespace(__name__) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
def patch_all(): | ||
print 'Monkey patching...' | ||
from gevent import monkey | ||
monkey.patch_all() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
import time | ||
import logging | ||
|
||
import gevent | ||
import praw | ||
import pylru | ||
import requests | ||
import requests.auth | ||
|
||
logger = logging.getLogger(__name__) | ||
logger.setLevel(logging.INFO) | ||
|
||
OAUTH_ACCESS_TOKEN_URL = 'https://www.reddit.com/api/v1/access_token' | ||
OAUTH_SCOPES = {'edit', 'identity', 'modconfig', 'modflair', 'modlog', 'modposts', | ||
'mysubreddits', 'privatemessages', 'read', 'submit', 'subscribe', 'vote'} | ||
|
||
|
||
class MultiBotHandler(object): | ||
def __init__(self, handlers): | ||
self.handlers = handlers | ||
|
||
def run(self): | ||
greenlets = [] | ||
for handler in self.handlers: | ||
greenlets.append(gevent.spawn(handler.run)) | ||
gevent.joinall(greenlets) | ||
|
||
|
||
class BotHandler(object): | ||
def __init__(self, user_agent, auth, delay, fetch_limit, cache_size=0): | ||
self.user_agent = user_agent | ||
self.auth = auth | ||
self.delay = delay | ||
self.fetch_limit = fetch_limit | ||
self.cache_size = cache_size | ||
self.cache = pylru.lrucache(self.cache_size) if self.cache_size > 0 else None | ||
self.api_request_delay = 1.0 if self.__is_oauth() else 2.0 | ||
self.r = praw.Reddit(self.user_agent, cache_timeout=0, api_request_delay=self.api_request_delay) | ||
self.expires = -1 | ||
self.__auth() | ||
|
||
def _get_content(self): | ||
raise NotImplementedError() | ||
|
||
def _check(self, obj): | ||
raise NotImplementedError() | ||
|
||
def _do(self, obj): | ||
raise NotImplementedError() | ||
|
||
def __is_oauth(self): | ||
return 'client_id' in self.auth and 'secret' in self.auth | ||
|
||
def __update_access_credentials(self): | ||
# Fetch access token | ||
client_auth = requests.auth.HTTPBasicAuth(self.auth['client_id'], self.auth['secret']) | ||
response = requests.post(OAUTH_ACCESS_TOKEN_URL, auth=client_auth, data={ | ||
'grant_type': 'password', | ||
'username': self.auth['username'], | ||
'password': self.auth['password'] | ||
}, headers={ | ||
'User-Agent': self.user_agent | ||
}) | ||
|
||
# Check response | ||
if response.ok: | ||
response = response.json() | ||
else: | ||
logger.error('Could not retrieve access creds: Status {status}'.format(status=response.status_code)) | ||
return | ||
|
||
# Update | ||
if 'error' in response: | ||
logger.error('Could not retrieve access creds: Json error: {status}'.format(status=response['error'])) | ||
else: | ||
self.r.set_access_credentials(scope=OAUTH_SCOPES, access_token=response['access_token']) | ||
self.expires = time.time() + int(response['expires_in']) * 0.9 | ||
|
||
def __auth(self): | ||
if 'username' not in self.auth or 'password' not in self.auth: | ||
raise Exception("Must provide username and password in auth") | ||
|
||
if self.__is_oauth(): | ||
self.r.set_oauth_app_info(client_id='a', client_secret='a', redirect_uri='a') | ||
self.__update_access_credentials() | ||
else: | ||
self.r.login(self.auth['username'], self.auth['password']) | ||
|
||
def __main(self): | ||
# Check if we need to update access token | ||
if time.time() > self.expires > 0: | ||
self.__update_access_credentials() | ||
|
||
# Get the content | ||
content = self._get_content() | ||
if not content: | ||
logger.warn('Bad content object: skipping...') | ||
return | ||
|
||
hits = 0 | ||
misses = 0 | ||
|
||
# Process all content | ||
for obj in content: | ||
# Check if it's in the cache | ||
if self.cache is not None: | ||
if obj.id in self.cache: | ||
hits += 1 | ||
continue | ||
misses += 1 | ||
self.cache[obj.id] = 0 | ||
|
||
# Process the object, sandbox exceptions | ||
try: | ||
if not self._check(obj): | ||
continue | ||
logger.info('Found valid object: {id} by {name}.'.format(id=obj.id, | ||
name=obj.author.name if obj.author else '[deleted]')) | ||
if not self._do(obj): | ||
logger.info('Failed to process object {id}.'.format(id=obj.id)) | ||
except Exception as e: | ||
logger.exception('Exception while processing object {id}'.format(id=obj.id)) | ||
|
||
if self.cache is not None: | ||
logger.info('Cache hits/misses/total: {hits} / {misses} / {total}'.format(hits=hits, misses=misses, | ||
total=hits + misses)) | ||
|
||
def run(self): | ||
logger.info('Bot started!') | ||
|
||
while True: | ||
start_time = time.time() | ||
|
||
try: | ||
self.__main() | ||
except Exception as e: | ||
logger.exception('Exception while processing content generator') | ||
|
||
# Sleep at least self.delay per cycle | ||
time_delta = time.time() - start_time | ||
sleep_time = self.delay - time_delta | ||
logger.info('Processing/Sleeping for: {p:.2f}s / {s:.2f}s'.format(p=time_delta, s=max(0, sleep_time))) | ||
logger.info('Finished processing round for {name}'.format(name=self.user_agent)) | ||
if sleep_time > 0: | ||
time.sleep(sleep_time) | ||
|
||
logger.info('Bot finished! Exiting gracefully.') | ||
|
||
|
||
class UserCommentsVoteTriggeredBot(BotHandler): | ||
def __init__(self, *args, **kwargs): | ||
self.monitored_user = kwargs.pop('monitored_user') | ||
self.score_threshold_max = kwargs.pop('score_threshold_max', None) | ||
self.score_threshold_min = kwargs.pop('score_threshold_min', None) | ||
if self.score_threshold_max is None and self.score_threshold_min is None: | ||
raise Exception("score_threshold_max or score_threshold_min should be set") | ||
|
||
super(UserCommentsVoteTriggeredBot, self).__init__(*args, **kwargs) | ||
|
||
def _get_content(self): | ||
return self.r.get_redditor(self.monitored_user).get_comments(limit=self.fetch_limit) | ||
|
||
def _check(self, comment): | ||
# Check vote score min | ||
if self.score_threshold_min is not None and comment.score < self.score_threshold_min: | ||
return True | ||
|
||
# Check vote score max | ||
if self.score_threshold_max is not None and comment.score > self.score_threshold_max: | ||
return True | ||
|
||
return False | ||
|
||
|
||
class MailTriggeredBot(BotHandler): | ||
def __init__(self, *args, **kwargs): | ||
super(MailTriggeredBot, self).__init__(*args, **kwargs) | ||
|
||
def _get_content(self): | ||
return self.r.get_unread(limit=self.fetch_limit) | ||
|
||
def is_private_message(self, message): | ||
return not message.was_comment | ||
|
||
def is_comment_reply(self, message): | ||
return message.was_comment and message.subject == 'comment reply' | ||
|
||
def is_post_reply(self, message): | ||
return message.was_comment and message.subject == 'post reply' | ||
|
||
def is_username_mention(self, message): | ||
return message.was_comment and message.subject == 'username mention' | ||
|
||
|
||
class SubredditCommentTriggeredBot(BotHandler): | ||
def __init__(self, *args, **kwargs): | ||
self.subreddit = kwargs.pop('subreddit') | ||
super(SubredditCommentTriggeredBot, self).__init__(*args, **kwargs) | ||
|
||
def _get_content(self): | ||
return self.r.get_comments(self.subreddit, limit=self.fetch_limit) | ||
|
||
|
||
class SubredditSubmissionTriggeredBot(BotHandler): | ||
def __init__(self, *args, **kwargs): | ||
self.subreddit = kwargs.pop('subreddit') | ||
super(SubredditSubmissionTriggeredBot, self).__init__(*args, **kwargs) | ||
|
||
def _get_content(self): | ||
return self.r.get_subreddit(self.subreddit).get_new(limit=self.fetch_limit) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import logging | ||
|
||
import praw | ||
|
||
logger = logging.getLogger(__name__) | ||
logger.setLevel(logging.INFO) | ||
|
||
|
||
def has_replied(praw_object, username): | ||
""" | ||
Returns True if the specified user has a comment in the top level replies of the given submission/comment/message, | ||
and False otherwise. | ||
For comments, submissions, messages ONLY. | ||
""" | ||
if type(praw_object) == praw.objects.Message: | ||
# TODO: Fix this to actually check properly | ||
# If it's not the first message in the PM thread, we replied previously. | ||
# This is not the best method, and it is a bit flakey, | ||
# but good enough for most cases | ||
if praw_object.first_message is not None: | ||
return True | ||
return False | ||
elif type(praw_object) == praw.objects.Submission: | ||
praw_object.replace_more_comments(limit=None) | ||
replies = praw_object.comments | ||
elif type(praw_object) == praw.objects.Comment: | ||
replies = praw_object.replies | ||
else: | ||
raise Exception("Object must be an instance of praw.objects.Comment/Submission/Message") | ||
|
||
if not replies: | ||
return False | ||
|
||
# Check each reply if the username matches | ||
username = username.lower() | ||
for reply in replies: | ||
if reply.author and reply.author.name.lower() == username: | ||
return True | ||
|
||
return False | ||
|
||
|
||
def is_comment_owner(praw_comment, username): | ||
""" | ||
Returns True if the specified comment belongs to the user, | ||
otherwise False. | ||
""" | ||
return praw_comment.author and praw_comment.author.name.lower() == username.lower() | ||
|
||
|
||
def send_reply(praw_object, reply_msg): | ||
""" | ||
Returns the reply object if the message was sent successfully, otherwise None. | ||
For comments, submissions, messages ONLY. | ||
""" | ||
try: | ||
if type(praw_object) == praw.objects.Submission: | ||
reply_obj = praw_object.add_comment(reply_msg) | ||
else: | ||
reply_obj = praw_object.reply(reply_msg) | ||
except Exception as e: | ||
logger.exception('Exception while replying') | ||
return None | ||
|
||
logger.info(' => Reply Sent!') | ||
return reply_obj | ||
|
||
|
||
def edit_reply(praw_comment, reply_msg): | ||
""" | ||
Returns True if the comment was edited successfully, and False otherwise. | ||
For comments ONLY. | ||
""" | ||
try: | ||
praw_comment.edit(reply_msg) | ||
except Exception as e: | ||
logger.exception('Exception while editing') | ||
return False | ||
|
||
logger.info(' => Edit was made!') | ||
return True | ||
|
||
|
||
def has_chain(praw_r, praw_comment, username): | ||
""" | ||
Returns True if the parent was made by username. | ||
Returns False otherwise. | ||
""" | ||
if not hasattr(praw_comment, 'parent_id'): | ||
return False | ||
parent = praw_r.get_info(thing_id=praw_comment.parent_id) | ||
if not parent or type(parent) != praw.objects.Comment: | ||
return False | ||
return is_comment_owner(parent, username) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import setuptools | ||
import pip | ||
|
||
if pip.main(['install', '-r', 'install/requirements.txt']) != 0: | ||
raise Exception('Pip install requirements failed') | ||
|
||
setuptools.setup( | ||
name='redditbot.base', | ||
version='1.0.0', | ||
author='Jeremy Simpson', | ||
description='Redditbot framework', | ||
license='MIT', | ||
classifiers=[ | ||
'Development Status :: 3 - Alpha', | ||
'Intended Audience :: Developers', | ||
'License :: OSI Approved :: MIT License', | ||
'Programming Language :: Python :: 2.7' | ||
], | ||
packages=setuptools.find_packages(), | ||
namespace_packages=['redditbot'], | ||
) |
Oops, something went wrong.