├── .gitignore ├── .travis.yml ├── DESCRIPTION.rst ├── LICENSE ├── README.md ├── redditreplier ├── __init__.py └── redditreplier.py ├── requirements.txt ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | secrets.py 4 | BLACKLIST.txt 5 | *.swp 6 | bin 7 | lib 8 | *egg* 9 | dist 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.2' 4 | - '3.3' 5 | - '3.4' 6 | install: pip install -r requirements.txt 7 | script: python tests.py -------------------------------------------------------------------------------- /DESCRIPTION.rst: -------------------------------------------------------------------------------- 1 | redditreplier 2 | ============= 3 | 4 | A simple Python module that simplifies creating Reddit bots that reply to comments based on criteria you specify. 5 | 6 | All you need to do is write a parser method that is passed into the Replier class that will parse a Reddit message that returns whether or not you should reply to said message as well as the text to reply with, and redditreplier will take care of the rest. It will communicate with the Reddit API using praw, continually watching whatever subreddits you specific. 7 | 8 | Please checkout the GitHub repository for more info. (https://github.com/naiyt/reddit-replier) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Nate Collings 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOTE: This repository has not been updated in 7+ years, and is currently *archived*! 2 | 3 | It was something I messed around with ages ago, but I haven't looked at it or played with it since. I wouldn't recommend trying to use it. 4 | 5 | reddit-replier 6 | ============== 7 | 8 | [![Build Status](https://travis-ci.org/naiyt/reddit-replier.svg?branch=master)](https://travis-ci.org/naiyt/reddit-replier) 9 | 10 | *A simple Python module that simplifies creating Reddit bots that reply to comments based on specified criteria.* 11 | 12 | The [Python Reddit API Wrapper](http://praw.readthedocs.org/en/v2.1.16/) (praw) greatly simplifies working with the Reddit API. Writing bots, a common use for praw, is still rather tricky. The purpose of this framework is to take care of the automation parts of writing a Reddit bot, letting you focus on the logic of what it should say and when it should say it. 13 | 14 | To accomplish this, you write a `Parser` class that contains a `parse` method. An instance of your class will be used when instatiating a `Replier` object. The `Replier` will watch the subreddits that you specify and pass all the messages to your `parse` method. Your `parse` will determine if a message should be replied to; if so, it will also return the message to be posted. This may sound more difficult than it actually is. Taking a look at the `Examples` section should help. 15 | 16 | This is still in beta, and there may be bugs. 17 | 18 | Installation 19 | ------------ 20 | 21 | pip install redditreplier --pre 22 | 23 | This is still a "pre-release" package, so you need to make sure you include `--pre` when installing via pip. (Alternatively you could just clone this repo.) 24 | 25 | Compatability 26 | ------------ 27 | 28 | Currently tested w/Python 3.2, 3.3 and 3.4. Python 2 support may be added in the future. (Pull requests welcome!) 29 | 30 | Replier Parameters 31 | ================== 32 | ```python 33 | class Replier(parser, user_name, user_pass, subreddits='all', user_agent='redditreplier v0.01 by /u/naiyt', limit=1000, debug=False) 34 | ``` 35 | 36 | Arguments 37 | --------- 38 | 39 | parser 40 | 41 | The `parser` should be an object that takes no parameters. It must have a `parse` method that takes one argument (message) and returns 2 values. The first value should be `True` or `False` based on whether redditreplier should reply to that comment. The second value should be the text that you want redditreplier to reply with. (Feel free to leave this as an empty string if your first value is `False`) 42 | 43 | user_name 44 | 45 | Your bot's Reddit username. 46 | 47 | user_pass 48 | 49 | Your bot's Reddit password 50 | 51 | subreddits 52 | 53 | The subreddits you want your bot to watch. (For multiple subreddits, use the format `sub1+sub2+sub3`) By default it follows /r/all. 54 | 55 | user_agent 56 | 57 | The default user_agent should be okay, but I would prefer if you defined your own. 58 | 59 | limit 60 | 61 | The limit of posts to request at once. The Reddit API restricts you to at most 1000 per request. Less should be just fine if you are watching smaller subreddits. praw ensures that you are staying with Reddit API limits, so you don't need to worry about that. (Unless you are running multiple bots on the same machine.) 62 | 63 | debug 64 | 65 | Debug mode makes redditreplier print the message to `stdout` rather than post it to Reddit. 66 | 67 | Examples 68 | ======== 69 | 70 | For a full fledged example, see [AutoGitHubBot](https://github.com/naiyt/autogithub). 71 | 72 | Simple example: 73 | 74 | Say I want to respond and thank anybody who says 'redditreplier is awesome!' on /r/redditreplier. First, I would write a `parser` class: 75 | 76 | ```python 77 | class Parser: 78 | def parse(self, message): 79 | if 'redditreplier is awesome' in message.body.lower(): 80 | return True, 'Hey thanks! You are pretty cool yourself' 81 | else: 82 | return False, '' 83 | ``` 84 | 85 | Then create and run your Replier Bot: 86 | 87 | ```python 88 | from redditreplier import Replier 89 | bot = Replier( 90 | Parser(), 91 | your_reddit_username, 92 | your_reddit_pass, 93 | 'redditreplier' # The subreddit, leave blank for /r/all 94 | user_agent='My cool bot by /u/username' 95 | ) 96 | bot.start() 97 | ``` 98 | 99 | And there you go! It will start watching your subreddits and replying when needed. You can run it with nohup or a detached screen/tmux session if you want it to be running continuously. 100 | 101 | Disclaimer: this is a bad example, and not something that you should create a bot to do. Try to come up with more interesting and useful bots than this. 102 | 103 | Running tests 104 | ------------- 105 | 106 | `python tests.py` 107 | 108 | 109 | Blacklist 110 | --------- 111 | 112 | If you have a Reddit user you never want to reply to, add their username to `BLACKLIST.TXT`. The bot being run will automatically be added to the blacklist (so that it won't get stuck in a loop with itself). A bot will never reply to itself or reply to a comment it has already replied to. Sometimes bots can get stuck in loops with other bots, so if you see that happen make sure you add the offending bot to `BLACKLIST.txt`. 113 | 114 | TODO 115 | ---- 116 | 117 | * Improve test coverage 118 | * Implement OAuth instead of plain text passwords? 119 | * Better logging and error handling 120 | * Python 2 support 121 | * Support for other types of bots 122 | -------------------------------------------------------------------------------- /redditreplier/__init__.py: -------------------------------------------------------------------------------- 1 | from .redditreplier import Replier -------------------------------------------------------------------------------- /redditreplier/redditreplier.py: -------------------------------------------------------------------------------- 1 | import praw 2 | import logging 3 | import os.path 4 | import traceback 5 | from time import sleep 6 | 7 | __version__ = '1.0.0a1' 8 | 9 | class Replier: 10 | def __init__(self, 11 | parser, 12 | user_name, 13 | user_pass, 14 | subreddits='all', 15 | user_agent='redditreplier v{} by /u/naiyt'.format(__version__), 16 | limit=1000, 17 | debug=False): 18 | print("Setting things up...") 19 | self.parser = parser 20 | self.user_agent = user_agent 21 | self.subreddits = subreddits 22 | self.user_name = user_name 23 | self.user_pass = user_pass 24 | self.limit = limit 25 | self.debug = debug 26 | self.r = praw.Reddit(self.user_agent) 27 | self.blacklist = self._setup_blacklist('BLACKLIST.txt') 28 | self.rest_time = 3 29 | self.comments_replied_to = 0 30 | 31 | def start(self): 32 | print("Logging into Reddit...") 33 | self._login() 34 | print("Starting the comments stream...") 35 | comments = praw.helpers.comment_stream(self.r, self.subreddits, self.limit) 36 | return self._main_loop(comments) 37 | 38 | def _login(self): 39 | self.r.login(self.user_name, self.user_pass) 40 | 41 | def _main_loop(self, comments): 42 | while True: 43 | try: 44 | self._search_comments(comments) 45 | except Exception as e: 46 | self._handle_exception(e) 47 | 48 | def _search_comments(self, comments): 49 | for comment in comments: 50 | should_reply, text = self.parser.parse(comment) 51 | if should_reply and text: 52 | if self._should_reply(comment): 53 | self._make_comment(comment, text) 54 | self.comments_replied_to += 1 55 | 56 | def _make_comment(self, comment, text): 57 | if self.debug: 58 | print(text) 59 | else: 60 | comment.reply(text) 61 | print("Replied to {}'s comment at {}".format(comment.author.name, comment.permalink)) 62 | 63 | def _should_reply(self, comment): 64 | if comment.author.name.lower() in self.blacklist: 65 | return False 66 | replies = [x.author.name.lower() for x in comment.replies] 67 | if self.user_name.lower() in replies: 68 | return False 69 | return True 70 | 71 | def _setup_blacklist(self, f): 72 | basepath = os.path.dirname(__file__) 73 | filepath = os.path.abspath(os.path.join(basepath, f)) 74 | try: 75 | f = open(filepath) 76 | blacklist = [x.lower() for x in f.read().splitlines()] 77 | f.close() 78 | except (OSError, IOError) as e: 79 | blacklist = [] 80 | blacklist.append(self.user_name.lower()) 81 | return blacklist 82 | 83 | def _handle_exception(self, e): 84 | traceback.print_exc() 85 | logging.warning("Something bad happened! I'm going to try to keep going, though. Error: {}".format(e)) 86 | sleep(self.rest_time) 87 | self.start() 88 | exit() 89 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | praw 2 | mock -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | import sys 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | with open(path.join(here, 'DESCRIPTION.rst'), encoding='utf-8') as f: 9 | long_description = f.read() 10 | 11 | required = ['praw'] 12 | if sys.version_info <= (3,2): 13 | required.append('mock') 14 | 15 | setup( 16 | name='redditreplier', 17 | version='1.0.1rc1', 18 | description='Create Reddit Bots', 19 | long_description=long_description, 20 | url='https://github.com/naiyt/reddit-replier', 21 | author='Nate Collings', 22 | author_email='nate@natecollings.com', 23 | license='MIT', 24 | classifiers=[ 25 | 'Development Status :: 3 - Alpha', 26 | 'Intended Audience :: Developers', 27 | 'Topic :: Software Development :: Build Tools', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 3.2', 31 | 'Programming Language :: Python :: 3.3', 32 | 'Programming Language :: Python :: 3.4', 33 | ], 34 | keywords='reddit bots automation praw', 35 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 36 | install_requires=required 37 | ) 38 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from redditreplier import Replier 3 | import praw 4 | 5 | try: 6 | from unittest import mock 7 | except ImportError: 8 | '''Python 3.2 support''' 9 | import mock 10 | 11 | 12 | TEST_USER = 'test_user' 13 | TEST_PASS = 'test_pass' 14 | TEST_USER_AGENT = 'My Reddit Replier Test' 15 | TEST_SUB = 'programming' 16 | TEST_LIMIT = 100 17 | 18 | 19 | class TestParser: 20 | def __init__(self): 21 | pass 22 | 23 | def parse(self, comment): 24 | if 'Reply to me' in comment.body: 25 | return True, 'Praise the Sun!' 26 | else: 27 | return False, None 28 | 29 | 30 | class AuthorMock: 31 | def __init__(self, name): 32 | self.name = name 33 | 34 | 35 | class CommentMock: 36 | def __init__(self, author_name, replies=[], body='Reply to me', permalink='http://reddit.com/coolcomment'): 37 | self.author = AuthorMock(author_name) 38 | self.replies = [CommentMock(x, []) for x in replies] 39 | self.body = body 40 | self.permalink = permalink 41 | 42 | def reply(self, message): 43 | return message 44 | 45 | 46 | class TestInit(unittest.TestCase): 47 | def setUp(self): 48 | self.replier = Replier(TestParser(), 49 | TEST_USER, TEST_PASS, 50 | TEST_SUB, TEST_USER_AGENT, 51 | TEST_LIMIT) 52 | 53 | def test_init(self): 54 | self.assertEqual(self.replier.user_name, TEST_USER) 55 | self.assertEqual(self.replier.user_pass, TEST_PASS) 56 | self.assertEqual(self.replier.subreddits, TEST_SUB) 57 | self.assertEqual(self.replier.limit, TEST_LIMIT) 58 | 59 | 60 | class TestStart(unittest.TestCase): 61 | def setUp(self): 62 | self.replier = Replier(TestParser(), 63 | TEST_USER, TEST_PASS, 64 | TEST_SUB, TEST_USER_AGENT, 65 | TEST_LIMIT) 66 | 67 | @mock.patch.object(Replier, '_login') 68 | @mock.patch.object(praw.helpers, 'comment_stream') 69 | @mock.patch.object(Replier, '_main_loop') 70 | def test_start(self, mock_loop, mock_stream, mock_login): 71 | self.replier.start() 72 | mock_loop.assert_called_with(mock_stream.return_value) 73 | mock_stream.assert_called_with(self.replier.r, self.replier.subreddits, self.replier.limit) 74 | mock_login.assert_called_with() 75 | 76 | 77 | 78 | class TestMainLoop(unittest.TestCase): 79 | '''How does one test an infinite loop?''' 80 | def setUp(self): 81 | pass 82 | 83 | 84 | class TestSearchComments(unittest.TestCase): 85 | def setUp(self): 86 | self.replier = Replier(TestParser(), 87 | TEST_USER, TEST_PASS, 88 | TEST_SUB, TEST_USER_AGENT, 89 | TEST_LIMIT) 90 | self.comments = [ 91 | CommentMock('Laurentius'), 92 | CommentMock('Tarkus', [], "TAAAAARRRRKUUUUSSS!!"), 93 | CommentMock('Logan', [], 'Big Hats are Best Hats'), 94 | CommentMock('Giant Dad', [], 'Git Gud'), 95 | CommentMock('Sif'), 96 | CommentMock(TEST_USER) 97 | ] 98 | 99 | def test_searching(self): 100 | ''' 101 | Basic test for comments being replied to. Keep in mind 102 | that it's your responsibility to test your own replier 103 | ''' 104 | self.replier._search_comments(self.comments) 105 | self.assertEqual(self.replier.comments_replied_to, 2) 106 | 107 | 108 | class TestMakeComment(unittest.TestCase): 109 | def setUp(self): 110 | self.replier = Replier(TestParser(), 111 | TEST_USER, TEST_PASS, 112 | TEST_SUB, TEST_USER_AGENT, 113 | TEST_LIMIT) 114 | self.comment = CommentMock('Ornstein', [], 'Pikachu') 115 | 116 | @mock.patch.object(CommentMock, 'reply') 117 | def test_non_debug_reply(self, reply_mock): 118 | self.replier._make_comment(self.comment, 'Pikachu') 119 | reply_mock.assert_called_with('Pikachu') 120 | 121 | @mock.patch.object(CommentMock, 'reply') 122 | def test_debug_reply(self, reply_mock): 123 | self.replier.debug = True 124 | self.replier._make_comment(self.comment, 'Pikachu') 125 | self.assertFalse(reply_mock.called) 126 | 127 | class TestShouldReply(unittest.TestCase): 128 | def setUp(self): 129 | self.replier = Replier(TestParser(), 130 | TEST_USER, TEST_PASS, 131 | TEST_SUB, TEST_USER_AGENT, 132 | TEST_LIMIT) 133 | self.bots_comment = CommentMock(TEST_USER, []) 134 | self.other_comment = CommentMock('Siegmeyer', ['Gwyn, Gwyndolin, Gwynevere']) 135 | self.blacklist_comment = CommentMock('test_user', []) 136 | self.already_replied = CommentMock('Solaire', [TEST_USER, 'Crestfallen', 'Andre']) 137 | 138 | def test_should_not_reply_to_self(self): 139 | self.replier._should_reply(self.bots_comment) 140 | self.assertFalse(self.replier._should_reply(self.bots_comment)) 141 | 142 | def test_should_not_reply_to_blacklist(self): 143 | self.assertFalse(self.replier._should_reply(self.blacklist_comment)) 144 | 145 | def test_should_not_reply_to_same_comment_twice(self): 146 | self.assertFalse(self.replier._should_reply(self.already_replied)) 147 | 148 | def test_should_reply_to_others(self): 149 | self.assertTrue(self.replier._should_reply(self.other_comment)) 150 | 151 | 152 | class TestSetupBlacklist(unittest.TestCase): 153 | def setUp(self): 154 | self.replier = Replier(TestParser(), 155 | TEST_USER, TEST_PASS, 156 | TEST_SUB, TEST_USER_AGENT, 157 | TEST_LIMIT) 158 | 159 | def test_blacklist_contains_current_user(self): 160 | self.assertIn(TEST_USER.lower(), self.replier._setup_blacklist('BLACKLIST.txt')) 161 | 162 | def test_blacklist_only_contains_user_if_no_file(self): 163 | self.assertEqual([TEST_USER.lower()], self.replier._setup_blacklist('blah')) 164 | 165 | 166 | class TestHandleException(unittest.TestCase): 167 | def setUp(self): 168 | pass 169 | 170 | 171 | if __name__ == "__main__" and __package__ is None: 172 | unittest.main() --------------------------------------------------------------------------------