├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── bottr ├── __init__.py ├── bot.py ├── tests │ ├── __init__.py │ └── test_bot.py └── util.py ├── docs ├── Makefile ├── _static │ └── imgs │ │ ├── create_app.png │ │ └── dev_apps.png ├── bots.rst ├── bots │ ├── comment.rst │ ├── message.rst │ └── submission.rst ├── conf.py ├── index.rst ├── setup.rst └── util.rst ├── publish.sh ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule.* 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | .idea 108 | 109 | # End of https://www.gitignore.io/api/python 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Steven Lang 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Bottr 3 | ===== 4 | 5 | Bottr makes writing bots for reddit easy. It currently provides three predefined bots: 6 | 7 | :CommentBot: Listens to new comments in a list of subreddits 8 | :SubmissionBot: Listens to new submission in a list of subreddits 9 | :MessageBot: Listens to new messages of the inbox 10 | 11 | Bottr makes use of the `Python Reddit API Wrapper` 12 | `PRAW `_. 13 | 14 | Documentation: `bottr.readthedocs.io `_ 15 | 16 | Check out `bottr-template `_ for a convenient code template to start with. 17 | 18 | Installation 19 | ------------ 20 | Bottr is available on PyPi and can be installed via 21 | 22 | .. code:: bash 23 | 24 | $ pip install bottr 25 | 26 | :Latest version: :code:`0.1.4` 27 | 28 | Quick Start 29 | ----------- 30 | 31 | The following is a quick example on how to monitor `r/AskReddit` for new comments. If a comment 32 | contains the string :code:`'banana'`, the bot prints the comment information 33 | 34 | .. code:: python 35 | 36 | import praw 37 | import time 38 | 39 | from bottr.bot import CommentBot 40 | 41 | def parse_comment(comment): 42 | """Define what to do with a comment""" 43 | if 'banana' in comment.body: 44 | print('ID: {}'.format(comment.id)) 45 | print('Author: {}'.format(comment.author)) 46 | print('Body: {}'.format(comment.body)) 47 | 48 | if __name__ == '__main__': 49 | 50 | # Get reddit instance with login details 51 | reddit = praw.Reddit(client_id='id', 52 | client_secret='secret', 53 | password='botpassword', 54 | user_agent='Script by /u/...', 55 | username='botname') 56 | 57 | # Create Bot with methods to parse comments 58 | bot = CommentBot(reddit=reddit, 59 | func_comment=parse_comment, 60 | subreddits=['AskReddit']) 61 | 62 | # Start Bot 63 | bot.start() 64 | 65 | # Run bot for 10 minutes 66 | time.sleep(10*60) 67 | 68 | # Stop Bot 69 | bot.stop() 70 | -------------------------------------------------------------------------------- /bottr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braun-steven/bottr/c1b92becc31adfbd5a7b77179b852a51da70b193/bottr/__init__.py -------------------------------------------------------------------------------- /bottr/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | from abc import ABC, abstractmethod 5 | from queue import Queue 6 | from typing import Iterable, List, Callable 7 | 8 | import praw 9 | 10 | 11 | class AbstractBot(ABC): 12 | """ 13 | Abstract bot class. 14 | """ 15 | 16 | def __init__(self, reddit: praw.Reddit, 17 | subreddits: Iterable = None, 18 | name: str = "AbstractBot", 19 | n_jobs=4): 20 | """ 21 | Default constructor 22 | 23 | :param reddit: Reddit instance 24 | :param subreddits: List of subreddits 25 | :param n_jobs: Number of jobs for parallelization 26 | """ 27 | 28 | if subreddits is None: 29 | subreddits = [] # type: List[str] 30 | 31 | if n_jobs < 1: 32 | raise Exception('You need at least one worker thread.') 33 | 34 | self._subs = subreddits 35 | self._name = name 36 | self._reddit = reddit 37 | self._n_jobs = n_jobs 38 | self._stop = False 39 | self._threads = [] # type: List[BotThread] 40 | self.log = logging.getLogger(__name__) 41 | super().__init__() 42 | 43 | @abstractmethod 44 | def start(self): 45 | """ 46 | Start this bot. 47 | """ 48 | pass 49 | 50 | def stop(self): 51 | """ 52 | Stops this bot. 53 | 54 | Returns as soon as all running threads have finished processing. 55 | """ 56 | self.log.debug('Stopping bot {}'.format(self._name)) 57 | self._stop = True 58 | for t in self._threads: 59 | t.join() 60 | 61 | self.log.debug('Stopping bot {} finished. All threads joined.'.format(self._name)) 62 | 63 | def _do_stop(self, q: Queue, threads: List[threading.Thread]): 64 | # For each thread: put None into the queue to stop the thread from polling 65 | for i in range(self._n_jobs): 66 | q.put(None) 67 | 68 | # Join threads 69 | for t in threads: 70 | t.join() 71 | 72 | 73 | class AbstractCommentBot(AbstractBot): 74 | @abstractmethod 75 | def _process_comment(self, comment: praw.models.Comment): 76 | """Process a single comment""" 77 | pass 78 | 79 | def _listen_comments(self): 80 | """Start listening to comments, using a separate thread.""" 81 | # Collect comments in a queue 82 | comments_queue = Queue(maxsize=self._n_jobs * 4) 83 | 84 | threads = [] # type: List[BotQueueWorker] 85 | 86 | try: 87 | 88 | # Create n_jobs CommentsThreads 89 | for i in range(self._n_jobs): 90 | t = BotQueueWorker(name='CommentThread-t-{}'.format(i), 91 | jobs=comments_queue, 92 | target=self._process_comment) 93 | t.start() 94 | threads.append(t) 95 | 96 | # Iterate over all comments in the comment stream 97 | for comment in self._reddit.subreddit('+'.join(self._subs)).stream.comments(): 98 | 99 | # Check for stopping 100 | if self._stop: 101 | self._do_stop(comments_queue, threads) 102 | break 103 | 104 | comments_queue.put(comment) 105 | 106 | self.log.debug('Listen comments stopped') 107 | except Exception as e: 108 | self._do_stop(comments_queue, threads) 109 | self.log.error('Exception while listening to comments:') 110 | self.log.error(str(e)) 111 | self.log.error('Waiting for 10 minutes and trying again.') 112 | time.sleep(10 * 60) 113 | 114 | # Retry 115 | self._listen_comments() 116 | 117 | def start(self): 118 | """ 119 | Starts this bot in a separate thread. Therefore, this call is non-blocking. 120 | 121 | It will listen to all new comments created in the :attr:`~subreddits` list. 122 | """ 123 | super().start() 124 | comments_thread = BotThread(name='{}-comments-stream-thread'.format(self._name), 125 | target=self._listen_comments) 126 | comments_thread.start() 127 | self._threads.append(comments_thread) 128 | self.log.info('Starting comments stream ...') 129 | 130 | 131 | class AbstractSubmissionBot(AbstractBot): 132 | @abstractmethod 133 | def _process_submission(self, submission: praw.models.Submission): 134 | """Process a single submission""" 135 | pass 136 | 137 | def _listen_submissions(self): 138 | """Start listening to submissions, using a separate thread.""" 139 | # Collect submissions in a queue 140 | subs_queue = Queue(maxsize=self._n_jobs * 4) 141 | 142 | threads = [] # type: List[BotQueueWorker] 143 | 144 | try: 145 | # Create n_jobs SubmissionThreads 146 | for i in range(self._n_jobs): 147 | t = BotQueueWorker(name='SubmissionThread-t-{}'.format(i), 148 | jobs=subs_queue, 149 | target=self._process_submission) 150 | t.start() 151 | self._threads.append(t) 152 | 153 | # Iterate over all comments in the comment stream 154 | for submission in self._reddit.subreddit('+'.join(self._subs)).stream.submissions(): 155 | 156 | # Check for stopping 157 | if self._stop: 158 | self._do_stop(subs_queue, threads) 159 | break 160 | 161 | subs_queue.put(submission) 162 | 163 | self.log.debug('Listen submissions stopped') 164 | except Exception as e: 165 | self._do_stop(subs_queue, threads) 166 | self.log.error('Exception while listening to submissions:') 167 | self.log.error(str(e)) 168 | self.log.error('Waiting for 10 minutes and trying again.') 169 | time.sleep(10 * 60) 170 | 171 | # Retry: 172 | self._listen_submissions() 173 | 174 | def start(self): 175 | """ 176 | Starts this bot in a separate thread. Therefore, this call is non-blocking. 177 | 178 | It will listen to all new submissions created in the :attr:`~subreddits` list. 179 | """ 180 | super().start() 181 | submissions_thread = BotThread(name='{}-submissions-stream-thread'.format(self._name), 182 | target=self._listen_submissions) 183 | submissions_thread.start() 184 | self._threads.append(submissions_thread) 185 | self.log.info('Starting submissions stream ...') 186 | 187 | 188 | class AbstractMessageBot(AbstractBot): 189 | def __init__(self, reddit: praw.Reddit, 190 | name: str = "AbstractInboxBot", 191 | n_jobs=1): 192 | """ 193 | Default constructor 194 | 195 | :param reddit: Reddit instance 196 | :param n_jobs: Number of jobs for parallelization 197 | """ 198 | super().__init__(reddit=reddit, name=name, n_jobs=n_jobs) 199 | 200 | @abstractmethod 201 | def _process_inbox_message(self, submission: praw.models.Message): 202 | """Process a single message""" 203 | pass 204 | 205 | def _listen_inbox_messages(self): 206 | """Start listening to messages, using a separate thread.""" 207 | # Collect messages in a queue 208 | inbox_queue = Queue(maxsize=self._n_jobs * 4) 209 | 210 | threads = [] # type: List[BotQueueWorker] 211 | 212 | try: 213 | # Create n_jobs inbox threads 214 | for i in range(self._n_jobs): 215 | t = BotQueueWorker(name='InboxThread-t-{}'.format(i), 216 | jobs=inbox_queue, 217 | target=self._process_inbox_message) 218 | t.start() 219 | self._threads.append(t) 220 | 221 | # Iterate over all messages in the messages stream 222 | for message in self._reddit.inbox.stream(): 223 | # Check for stopping 224 | if self._stop: 225 | self._do_stop(inbox_queue, threads) 226 | break 227 | 228 | inbox_queue.put(message) 229 | 230 | self.log.debug('Listen inbox stopped') 231 | except Exception as e: 232 | self._do_stop(inbox_queue, threads) 233 | self.log.error('Exception while listening to inbox:') 234 | self.log.error(str(e)) 235 | self.log.error('Waiting for 10 minutes and trying again.') 236 | time.sleep(10 * 60) 237 | 238 | # Retry: 239 | self._listen_inbox_messages() 240 | 241 | def start(self): 242 | """ 243 | Starts this bot in a separate thread. Therefore, this call is non-blocking. 244 | 245 | It will listen to all new inbox messages created. 246 | """ 247 | super().start() 248 | inbox_thread = BotThread(name='{}-inbox-stream-thread'.format(self._name), 249 | target=self._listen_inbox_messages) 250 | inbox_thread.start() 251 | self._threads.append(inbox_thread) 252 | self.log.info('Starting inbox stream ...') 253 | 254 | 255 | class CommentBot(AbstractCommentBot): 256 | """ 257 | This bot listens to incoming comments and calls the provided method :code:`func_comment` as 258 | :code:`func_comment(comment, *func_comment_args)` for each :code:`comment` that is submitted in 259 | the given :code:`subreddits`. 260 | 261 | Creates a bot that listens to comments in a list of subreddits and calls a given 262 | function on each new comment. 263 | 264 | :param reddit: :class:`praw.Reddit` instance. Check :ref:`setup` on how to create it. 265 | :param name: Bot name 266 | :param func_comment: Comment function. It needs to accept a :class:`praw.models.Comment` 267 | object and may take more arguments. For each comment created in :code:`subreddits`, a 268 | :class:`praw.models.Comment` object and all :code:`fun_comments_args` are passed to 269 | :code:`func_comment` as arguments. 270 | :param func_comment_args: Comment function arguments. 271 | :param subreddits: List of subreddit names. Example: :code:`['AskReddit', 'Videos', ...]` 272 | :param n_jobs: Number of parallel threads that are started when calling 273 | :func:`~CommentBot.start` to process in the incoming comments. 274 | 275 | **Example usage**:: 276 | 277 | # Write a parsing method 278 | def parse(comment): 279 | if 'banana' in comment.body: 280 | comment.reply('This comment is bananas.') 281 | 282 | reddit = praw.Reddit(...) # Create a PRAW Reddit instance 283 | bot = CommentBot(reddit=reddit, func_comment=parse) 284 | bot.start() 285 | 286 | """ 287 | 288 | def __init__(self, reddit: praw.Reddit, 289 | name: str = "CommentBot", 290 | func_comment: Callable[[praw.models.Comment], None] = None, 291 | func_comment_args: List = None, 292 | subreddits: Iterable = None, 293 | n_jobs=4): 294 | super().__init__(reddit, subreddits, name, n_jobs) 295 | 296 | # Enable comment processing if proper method was given 297 | if func_comment is not None: 298 | if func_comment_args is None: 299 | func_comment_args = [] 300 | 301 | self._func_comment = func_comment 302 | self._func_comment_args = func_comment_args 303 | 304 | def _process_comment(self, comment: praw.models.Comment): 305 | """ 306 | Process a reddit comment. Calls `func_comment(*func_comment_args)`. 307 | 308 | :param comment: Comment to process 309 | """ 310 | self._func_comment(comment, *self._func_comment_args) 311 | 312 | 313 | class MessageBot(AbstractMessageBot): 314 | """ 315 | This bot listens to incoming inbox messages and calls the provided method :code:`func_message` as 316 | :code:`func_message(message, *func_message_args)` for each :code:`message` that is new in the inbox. 317 | 318 | :param reddit: :class:`praw.Reddit` instance. Check :ref:`setup` on how to create it. 319 | :param name: Bot name 320 | :param func_message: Message function. It needs to accept a :class:`praw.models.Message` 321 | object and may take more arguments. For each new message in the inbox, a 322 | :class:`praw.models.Message` object and all :code:`fun_message_args` are passed to 323 | :code:`func_message` as arguments. 324 | :param func_message_args: Message function arguments. 325 | :param n_jobs: Number of parallel threads that are started when calling 326 | :func:`~MessageBot.start` to process in the incoming messages. 327 | 328 | **Example usage**:: 329 | 330 | # Write a parsing method 331 | def parse(message): 332 | message.reply('Hello you!') 333 | 334 | reddit = praw.Reddit(...) # Create a PRAW Reddit instance 335 | bot = MessageBot(reddit=reddit, func_message=parse) 336 | bot.start() 337 | 338 | """ 339 | 340 | def __init__(self, reddit: praw.Reddit, 341 | name: str = "InboxBot", 342 | func_message: Callable[[praw.models.Message], None] = None, 343 | func_message_args: List = None, 344 | n_jobs=1): 345 | super().__init__(reddit=reddit, name=name, n_jobs=n_jobs) 346 | 347 | # Enable comment processing if proper method was given 348 | if func_message is not None: 349 | if func_message_args is None: 350 | func_message_args = [] 351 | 352 | self._func_message = func_message 353 | self._func_message_args = func_message_args 354 | 355 | def _process_inbox_message(self, message: praw.models.Message): 356 | """ 357 | Process a reddit inbox message. Calls `func_message(message, *func_message_args)`. 358 | 359 | :param message: Item to process 360 | """ 361 | self._func_message(message, *self._func_message_args) 362 | 363 | 364 | class SubmissionBot(AbstractSubmissionBot): 365 | """ 366 | Bottr Bot instance that can take a method :code:`func_submission` and calls that method as 367 | :code:`func_submission(submission, *func_submission_args)` 368 | 369 | Can listen to new submissions made on a given list of subreddits. 370 | 371 | :param reddit: :class:`praw.Reddit` instance. Check `here 372 | `_ 373 | on how to create it. 374 | :param name: Bot name 375 | :param func_submission: Submission function. It needs to accept a :class:`praw.models.Submission` 376 | object and may take more arguments. For each submission created in :code:`subreddits`, a 377 | :class:`praw.models.Submission` object and all :code:`fun_submission_args` are passed to 378 | :code:`func_submission` as arguments. 379 | :param func_submission_args: submission function arguments. 380 | :param subreddits: List of subreddit names. Example: :code:`['AskReddit', 'Videos', ...]` 381 | :param n_jobs: Number of parallel threads that are started when calling 382 | :func:`~SubmissionBot.start` to process in the incoming submissions. 383 | 384 | 385 | **Example usage**:: 386 | 387 | # Write a parsing method 388 | def parse(submission): 389 | if 'banana' in submission.title: 390 | submission.reply('This submission is bananas.') 391 | 392 | reddit = praw.Reddit(...) # Create a PRAW Reddit instance 393 | bot = SubmissionBot(reddit=reddit, func_submission=parse) 394 | bot.start() 395 | 396 | """ 397 | 398 | def __init__(self, reddit: praw.Reddit, 399 | name: str = "SubmissionBot", 400 | func_submission: Callable[[praw.models.Comment], None] = None, 401 | func_submission_args: List = None, 402 | subreddits: Iterable = None, 403 | n_jobs=4): 404 | super().__init__(reddit, subreddits, name, n_jobs) 405 | 406 | # Enable comment processing if proper method was given 407 | if func_submission is not None: 408 | if func_submission_args is None: 409 | func_submission_args = [] 410 | 411 | self._func_submission = func_submission 412 | self._func_submission_args = func_submission_args 413 | 414 | def _process_submission(self, submission: praw.models.Submission): 415 | """ 416 | Process a reddit submission. Calls `func_comment(*func_comment_args)`. 417 | 418 | :param submission: Comment to process 419 | """ 420 | self._func_submission(submission, *self._func_submission_args) 421 | 422 | 423 | class BotThread(threading.Thread, ABC): 424 | """ 425 | A thread running bot tasks. 426 | """ 427 | 428 | def __init__(self, name: str, target: classmethod = None, *args): 429 | threading.Thread.__init__(self) 430 | self._args = args 431 | self._target = target 432 | self.name = name 433 | self.log = logging.getLogger(__name__) 434 | 435 | def run(self): 436 | self._call() 437 | 438 | def _call(self): 439 | self._target(*self._args) 440 | 441 | 442 | class BotQueueWorker(BotThread): 443 | """ 444 | A worker thread that works on a given queue. It is polling new jobs from the queue and processes 445 | it. 446 | """ 447 | 448 | def __init__(self, name: str, jobs: Queue = None, target: classmethod = None, *args): 449 | """ 450 | Initialize this worker. 451 | :param name: Name 452 | :param jobs: Job queue 453 | :param bot: Bot object 454 | :param args: Additional arguments 455 | """ 456 | super().__init__(name=name, target=target, *args) 457 | self._jobs = jobs 458 | 459 | def _call(self, *args): 460 | while True: 461 | 462 | # Blocks if no item available 463 | e = self._jobs.get() 464 | self.log.debug('{} processing element: {}'.format(self._name, e)) 465 | 466 | # If None is in queue, exit 467 | if e is None: 468 | break 469 | 470 | # Process the element 471 | self._target(e, *args) 472 | self._jobs.task_done() 473 | -------------------------------------------------------------------------------- /bottr/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braun-steven/bottr/c1b92becc31adfbd5a7b77179b852a51da70b193/bottr/tests/__init__.py -------------------------------------------------------------------------------- /bottr/tests/test_bot.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from bottr.bot import BotThread 3 | 4 | class Test(TestCase): 5 | def test_is_string(self): 6 | self.assertTrue(True) -------------------------------------------------------------------------------- /bottr/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import time 4 | from typing import Callable, Any, List 5 | 6 | import praw 7 | 8 | """Rate limit regular expression to extract the time in minutes/seconds""" 9 | RATELIMIT = re.compile(r'in (\d+) (minutes|seconds)') 10 | 11 | util_logger = logging.getLogger(__name__) 12 | 13 | def parse_wait_time(text: str) -> int: 14 | """Parse the waiting time from the exception""" 15 | val = RATELIMIT.findall(text) 16 | if len(val) > 0: 17 | try: 18 | res = val[0] 19 | if res[1] == 'minutes': 20 | return int(res[0]) * 60 21 | 22 | if res[1] == 'seconds': 23 | return int(res[0]) 24 | except Exception as e: 25 | util_logger.warning('Could not parse ratelimit: ' + str(e)) 26 | return 1 * 60 27 | 28 | 29 | def handle_rate_limit(func: Callable[[Any], Any], *args, **kwargs) -> Any: 30 | """ 31 | Calls :code:`func` with given arguments and handle rate limit exceptions. 32 | 33 | :param func: Function to call 34 | :param args: Argument list for :code:`func` 35 | :param kwargs: Dict arguments for `func` 36 | :returns: :code:`func` result 37 | """ 38 | error_count = 0 39 | while True: 40 | try: 41 | return func(*args, **kwargs) 42 | except Exception as e: 43 | if error_count > 3: 44 | util_logger.error('Retried to call <{}> 3 times without success. ' 45 | 'Continuing without calling it.'.format(func.__name__)) 46 | break 47 | 48 | if 'DELETED_COMMENT' in str(e): 49 | util_logger.warning('The comment has been deleted. ' 50 | 'Function <{}> was not executed.'.format(func.__name__)) 51 | break 52 | wait = parse_wait_time(str(e)) 53 | util_logger.error(e) 54 | util_logger.warning('Waiting ~{} minutes'.format(round(float(wait + 30) / 60))) 55 | time.sleep(wait + 30) 56 | error_count += 1 57 | 58 | 59 | def check_comment_depth(comment: praw.models.Comment, max_depth=3) -> bool: 60 | """ 61 | Check if comment is in a allowed depth range 62 | 63 | :param comment: :class:`praw.models.Comment` to count the depth of 64 | :param max_depth: Maximum allowed depth 65 | :return: True if comment is in depth range between 0 and max_depth 66 | """ 67 | count = 0 68 | while not comment.is_root: 69 | count += 1 70 | if count > max_depth: 71 | return False 72 | 73 | comment = comment.parent() 74 | 75 | return True 76 | 77 | 78 | def init_reddit(creds_path='creds.props') -> praw.Reddit: 79 | """Initialize the reddit session by reading the credentials from the file at :code:`creds_path`. 80 | 81 | :param creds_path: Properties file with the credentials. 82 | 83 | **Example file**:: 84 | 85 | client_id=CLIENT_ID 86 | client_secret=CLIENT_SECRET 87 | password=PASSWORD 88 | user_agent=USER_AGENT 89 | username=USERNAME 90 | """ 91 | with open(creds_path) as f: 92 | prop_lines = [l.replace('\n','').split('=') for l in f.readlines()] 93 | f.close() 94 | props = {l[0]: l[1] for l in prop_lines} 95 | return praw.Reddit(**props) 96 | 97 | 98 | def get_subs(subs_file='subreddits.txt', blacklist_file='blacklist.txt') -> List[str]: 99 | """ 100 | Get subs based on a file of subreddits and a file of blacklisted subreddits. 101 | 102 | :param subs_file: List of subreddits. Each sub in a new line. 103 | :param blacklist_file: List of blacklisted subreddits. Each sub in a new line. 104 | :return: List of subreddits filtered with the blacklisted subs. 105 | 106 | **Example files**:: 107 | 108 | sub0 109 | sub1 110 | sub2 111 | ... 112 | """ 113 | # Get subs and blacklisted subs 114 | subsf = open(subs_file) 115 | blacklf = open(blacklist_file) 116 | subs = [b.lower().replace('\n','') for b in subsf.readlines()] 117 | blacklisted = [b.lower().replace('\n','') for b in blacklf.readlines()] 118 | subsf.close() 119 | blacklf.close() 120 | 121 | # Filter blacklisted 122 | subs_filtered = list(sorted(set(subs).difference(set(blacklisted)))) 123 | return subs_filtered -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = bottr 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/imgs/create_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braun-steven/bottr/c1b92becc31adfbd5a7b77179b852a51da70b193/docs/_static/imgs/create_app.png -------------------------------------------------------------------------------- /docs/_static/imgs/dev_apps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braun-steven/bottr/c1b92becc31adfbd5a7b77179b852a51da70b193/docs/_static/imgs/dev_apps.png -------------------------------------------------------------------------------- /docs/bots.rst: -------------------------------------------------------------------------------- 1 | .. _bots: 2 | 3 | Predefined Bots 4 | =============== 5 | 6 | Description 7 | ----------- 8 | 9 | Bottr comes with a set of predefined bots, e.g. :class:`CommentBot` or :class:`SubmissionBot`. 10 | Both bots accepts a function as constructor argument. The bots listen to a stream of new 11 | comments/submissions and call the given function with a :class:`praw.models.Comment` or 12 | :class:`praw.models.Submission` object respectively. 13 | 14 | The parsing function for comments or submissions might take some time, e.g. calling 15 | :func:`praw.models.Comment.parent` makes a new request to the reddit api and waits for a response. 16 | Therefore, it is possible to specify the argument :code:`n_jobs` when creating the bots. 17 | This is the maximum number of comments/submissions that can be processed in parallel by the bot. 18 | The stream of new comments/submissions are internally put into a :class:`~queue.Queue`, being 19 | available to a list of worker threads that successively poll new objects to process from the queue. 20 | The :code:`n_jobs` argument defines how many worker threads are available. 21 | 22 | Bots 23 | ---- 24 | 25 | .. toctree:: 26 | :maxdepth: 3 27 | 28 | bots/comment 29 | bots/submission 30 | bots/message 31 | -------------------------------------------------------------------------------- /docs/bots/comment.rst: -------------------------------------------------------------------------------- 1 | .. _comment_bot: 2 | 3 | Comment Bot 4 | *********** 5 | 6 | .. autoclass:: bottr.bot.CommentBot 7 | :members: 8 | :inherited-members: 9 | -------------------------------------------------------------------------------- /docs/bots/message.rst: -------------------------------------------------------------------------------- 1 | .. _message_bot: 2 | 3 | Message Bot 4 | *********** 5 | 6 | .. autoclass:: bottr.bot.MessageBot 7 | :members: 8 | :inherited-members: 9 | -------------------------------------------------------------------------------- /docs/bots/submission.rst: -------------------------------------------------------------------------------- 1 | .. _submission_bot: 2 | 3 | Submission Bot 4 | ============== 5 | 6 | .. autoclass:: bottr.bot.SubmissionBot 7 | :members: 8 | :inherited-members: 9 | 10 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # bottr documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Jan 18 16:16:06 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.todo', 36 | 'sphinx.ext.coverage', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx.ext.githubpages', 39 | 'sphinx.ext.napoleon', 40 | 'sphinx.ext.intersphinx'] 41 | 42 | intersphinx_mapping = {'praw': ('http://praw.readthedocs.io/en/latest', None), 43 | 'python': ('https://docs.python.org/3.4', None)} 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # The suffix(es) of source filenames. 48 | # You can specify multiple suffix as a list of string: 49 | # 50 | # source_suffix = ['.rst', '.md'] 51 | source_suffix = '.rst' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = 'bottr' 58 | copyright = '2018, Steven Lang' 59 | author = 'Steven Lang' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = '0.1.4' 67 | # The full version, including alpha/beta/rc tags. 68 | release = version 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | # This patterns also effect to html_static_path and html_extra_path 80 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = 'sphinx' 84 | 85 | # If true, `todo` and `todoList` produce output, else they produce nothing. 86 | todo_include_todos = True 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "sphinx_rtd_theme" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | html_sidebars = { 112 | '**': [ 113 | 'relations.html', # needs 'show_related': True theme option to display 114 | 'searchbox.html', 115 | ] 116 | } 117 | 118 | # -- Options for HTMLHelp output ------------------------------------------ 119 | 120 | # Output file base name for HTML help builder. 121 | htmlhelp_basename = 'bottrdoc' 122 | 123 | # -- Options for LaTeX output --------------------------------------------- 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | 134 | # Additional stuff for the LaTeX preamble. 135 | # 136 | # 'preamble': '', 137 | 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, 145 | # author, documentclass [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, 'bottr.tex', 'bottr Documentation', 148 | 'Steven Lang', 'manual'), 149 | ] 150 | 151 | # -- Options for manual page output --------------------------------------- 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [ 156 | (master_doc, 'bottr', 'bottr Documentation', 157 | [author], 1) 158 | ] 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | (master_doc, 'bottr', 'bottr Documentation', 167 | author, 'bottr', 'Simple Reddit Bot Library', 168 | 'Miscellaneous'), 169 | ] 170 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. bottr documentation master file, created by 2 | sphinx-quickstart on Thu Jan 18 16:16:06 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to bottr's documentation! 7 | ================================= 8 | 9 | Bottr is supposed to make writing bots for reddit easy. It relies on the `Python Reddit API Wrapper` 10 | `PRAW `_. 11 | 12 | 13 | .. toctree:: 14 | :maxdepth: 3 15 | :caption: Contents: 16 | 17 | setup 18 | bots 19 | util 20 | 21 | Check out `bottr-template `_ for a convenient code template to start with. 22 | 23 | Quick Start 24 | ----------- 25 | 26 | The following is a quick example on how to monitor `r/AskReddit` for new comments. If a comment 27 | contains the string :code:`'banana'`, the bot prints the comment information: 28 | 29 | .. code:: python 30 | 31 | import praw 32 | import time 33 | 34 | from bottr.bot import CommentBot 35 | 36 | def parse_comment(comment): 37 | """Define what to do with a comment""" 38 | if 'banana' in comment.body: 39 | print('ID: {}'.format(comment.id)) 40 | print('Author: {}'.format(comment.author)) 41 | print('Body: {}'.format(comment.body)) 42 | 43 | if __name__ == '__main__': 44 | 45 | # Get reddit instance with login details 46 | reddit = praw.Reddit(client_id='id', 47 | client_secret='secret', 48 | password='botpassword', 49 | user_agent='Script by /u/...', 50 | username='botname') 51 | 52 | # Create Bot with methods to parse comments 53 | bot = CommentBot(reddit=reddit, 54 | func_comment=parse_comment, 55 | subreddits=['AskReddit']) 56 | 57 | # Start Bot 58 | bot.start() 59 | 60 | # Run bot for 10 minutes 61 | time.sleep(10*60) 62 | 63 | # Stop Bot 64 | bot.stop() 65 | 66 | Check out :ref:`setup` to see how to get the arguments for :class:`praw.Reddit`. 67 | 68 | .. note:: 69 | Please read the reddit `bottiquette `_ if you intend to 70 | run a bot that interacts with reddit, such as writing submissions/comments etc. -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | .. _setup: 2 | 3 | Bot Account Setup 4 | ================= 5 | 6 | To instantiate a :class:`praw.Reddit` instance, you need to provide a few login credentials. If you 7 | have not done so already, go to https://www.reddit.com/prefs/apps and click the `create a new app` button. 8 | This should pop up the following form: 9 | 10 | .. image:: _static/imgs/create_app.png 11 | :alt: Create Application 12 | :align: center 13 | 14 | After filling out the inputs and pressing `create app`, you will see a new application in the list above: 15 | 16 | .. image:: _static/imgs/dev_apps.png 17 | :alt: Application Box 18 | :align: center 19 | 20 | With the information in this box it is now possible to create a :class:`praw.Reddit` using the following parameters: 21 | 22 | :client_id: The `personal use script` listed in the application box. 23 | :client_secret: The `secret` listed in the application box. 24 | :username: Username of the bot reddit account. 25 | :password: Password of the bot reddit account. 26 | :user_agent: User agent description, e.g. :code:`Script by u/testbot`. See also the `reddit api-rules `_. 27 | 28 | For the above example, a reddit instance can be created as follows: 29 | 30 | .. code:: python 31 | 32 | import praw 33 | reddit = praw.Reddit(client_id='6TC26cMNLi-qaQ', 34 | client_secret='vDY3bsgl8RWXMDil2HRjbD2EUBs', 35 | password='botpassword', 36 | user_agent='Script by u/test-bot', 37 | username='test-bot') 38 | 39 | Check out `Authenticating via OAuth `_ 40 | in the PRAW documentation for further details. 41 | -------------------------------------------------------------------------------- /docs/util.rst: -------------------------------------------------------------------------------- 1 | .. _util: 2 | 3 | Bot Utilities 4 | ============= 5 | 6 | The module :mod:`bottr.util` provides some functions that can be helpful when handling certain 7 | situations while parsing comments or submission. 8 | 9 | .. automodule:: bottr.util 10 | :members: handle_rate_limit, check_comment_depth, get_subs, init_reddit -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | python setup.py sdist bdist_wheel 3 | twine upload dist/* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools==20.7.0 2 | praw==5.3.0 3 | typing==3.6.2 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def readme(): 5 | with open('README.rst') as f: 6 | return f.read() 7 | 8 | 9 | setup(name='bottr', 10 | version='0.1.4', 11 | description='Simple Reddit Bot Library', 12 | long_description=readme(), 13 | url='http://github.com/slang03/bottr', 14 | author='Steven Lang', 15 | author_email='steven.lang.mz@gmail.com', 16 | license='MIT', 17 | packages=['bottr'], 18 | zip_safe=False, 19 | keywords='reddit bot praw', 20 | install_requires=['praw==5.3.0'], 21 | include_package_data=True, 22 | classifiers=[ 23 | 'Development Status :: 3 - Alpha', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Framework :: Robot Framework :: Library', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.2', 28 | 'Programming Language :: Python :: 3.3', 29 | 'Programming Language :: Python :: 3.4', 30 | 'Programming Language :: Python :: 3.5', 31 | 'Programming Language :: Python :: 3.6', 32 | 'Topic :: Internet' 33 | ], 34 | test_suite='nose.collector', 35 | tests_require=['nose'], 36 | ) 37 | --------------------------------------------------------------------------------