Files
PointsBot/pointsbot/bot.py

219 lines
8.0 KiB
Python
Raw Normal View History

import logging
import os
import os.path
2020-01-15 11:07:13 -08:00
import re
import praw
import prawcore
2020-01-15 11:07:13 -08:00
2020-02-01 00:23:15 -08:00
from . import config, database, level, reply
2020-01-15 17:42:49 -08:00
2020-02-03 18:53:23 -08:00
### Globals ###
USER_AGENT = 'PointsBot (by u/GlipGlorp7)'
# TODO put this in config
# LOG_FILEPATH = os.path.abspath(os.path.join(os.path.expanduser('~'),
# '.pointsbot',
# 'pointsbot.log'))
2020-02-04 17:25:41 -08:00
# The pattern that determines whether a post is marked as solved
# Could also use re.IGNORECASE flag instead
2020-02-04 17:25:41 -08:00
SOLVED_PAT = re.compile('![Ss]olved')
MOD_SOLVED_PAT = re.compile('/[Ss]olved')
2020-01-15 11:07:13 -08:00
### Main Function ###
def run():
print_welcome_message()
2020-02-04 23:21:53 -08:00
cfg = config.load()
logging.basicConfig(filename=cfg.log_path,
level=logging.DEBUG,
format='%(asctime)s %(levelname)s:%(module)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
2020-02-01 00:23:15 -08:00
levels = cfg.levels
db = database.Database(cfg.database_path)
2020-01-15 11:07:13 -08:00
# Run indefinitely, reconnecting any time a connection is lost
while True:
try:
reddit = praw.Reddit(client_id=cfg.client_id,
client_secret=cfg.client_secret,
username=cfg.username,
password=cfg.password,
user_agent=USER_AGENT)
logging.info('Connected to Reddit as %s', reddit.user.me())
if not reddit.read_only:
logging.info('Has write access to Reddit')
else:
logging.info('Has read-only access to Reddit')
subreddit = reddit.subreddit(cfg.subreddit)
logging.info('Watching subreddit %s', subreddit.title)
if subreddit.moderator(redditor=reddit.user.me()):
logging.info('Is moderator for monitored subreddit')
else:
logging.warning('Is NOT moderator for monitored subreddit')
2020-02-11 09:08:03 -08:00
monitor_comments(subreddit, db, levels)
# Ignoring other potential exceptions for now, since we may not be able
# to recover from them as well as from this one
except prawcore.exceptions.RequestException as e:
log.error('Unable to connect; attempting again....')
except prawcore.exceptions.ServerError as e:
log.error('Lost connection to Reddit; attempting to reconnect....')
2020-02-11 09:08:03 -08:00
def monitor_comments(subreddit, db, levels):
"""Monitor new comments in the subreddit, looking for confirmed solutions."""
# Passing pause_after=0 will bypass the internal exponential delay, but have
# to check if any comments are returned with each query
2020-02-01 00:23:15 -08:00
for comm in subreddit.stream.comments(skip_existing=True, pause_after=0):
if comm is None:
continue
logging.info('Received comment')
# TODO more debug info about comment, eg author
logging.debug('Comment text: "%s"', comm.body)
2020-01-15 11:07:13 -08:00
if not marks_as_solved(comm):
logging.info('Comment does not mark issue as solved')
continue
logging.info('Comment marks issues as solved')
if is_mod_comment(comm):
logging.info('Comment was submitted by mod')
elif not is_first_solution(comm):
# Skip this "!solved" comment
logging.info('Comment is NOT the first to mark the issue as solved')
continue
logging.info('Comment is the first to mark the issue as solved')
log_solution_info(comm)
solver = find_solver(comm)
db.add_point(solver)
logging.info('Added point for user "%s"', solver.name)
points = db.get_points(solver)
logging.info('Total points for user "%s": %d', solver.name, points)
level_info = level.user_level_info(points, levels)
# Reply to the comment marking the submission as solved
reply_body = reply.make(
solver,
points,
level_info,
feedback_url=cfg.feedback_url,
scoreboard_url=cfg.scoreboard_url
)
2020-02-11 09:08:03 -08:00
try:
comm.reply(reply_body)
logging.info('Replied to the comment')
logging.debug('Reply body: %s', reply_body)
2020-02-11 09:08:03 -08:00
except praw.exceptions.APIException as e:
logging.error('Unable to reply to comment: %s', e)
2020-02-11 09:08:03 -08:00
db.remove_point(solver)
logging.error('Removed point that was just awarded to user "%s"', solver.name)
logging.error('Skipping comment')
2020-02-11 09:08:03 -08:00
continue
# Check if (non-mod) user flair should be updated to new level
lvl = level_info.current
if lvl and lvl.points == points:
logging.info('User reached level: %s', lvl.name)
if not subreddit.moderator(redditor=solver):
logging.info('User is not mod; setting flair')
logging.info('Flair text: %s', lvl.name)
logging.info('Flair template ID: %s', lvl.flair_template_id)
subreddit.flair.set(solver,
text=lvl.name,
flair_template_id=lvl.flair_template_id)
else:
logging.info('Solver is mod; don\'t alter flair')
2020-01-15 11:07:13 -08:00
2020-02-01 00:23:15 -08:00
### Reddit Comment Functions ###
2020-01-15 11:07:13 -08:00
2020-02-04 17:25:41 -08:00
def marks_as_solved(comment):
2020-02-11 09:08:03 -08:00
"""Return True if the comment meets the criteria for marking the submission
2020-02-01 00:23:15 -08:00
as solved, False otherwise.
2020-02-11 09:08:03 -08:00
"""
2020-02-05 10:39:05 -08:00
op_resp_to_solver = (not comment.is_root
and comment.is_submitter
2020-02-04 17:25:41 -08:00
and not comment.parent().is_submitter
and SOLVED_PAT.search(comment.body))
2020-02-05 10:39:05 -08:00
# Mod can only used MOD_SOLVED_PAT on any post, including their own
mod_resp_to_solver = (not comment.is_root
and comment.subreddit.moderator(redditor=comment.author)
2020-02-04 17:25:41 -08:00
and MOD_SOLVED_PAT.search(comment.body))
2020-02-05 10:39:05 -08:00
return op_resp_to_solver or mod_resp_to_solver
2020-01-15 11:07:13 -08:00
def is_mod_comment(comment):
return comment.subreddit.moderator(redditor=comment.author)
2020-02-04 17:25:41 -08:00
def is_first_solution(solved_comment):
2020-02-11 09:08:03 -08:00
"""Return True if this solved comment is the first, False otherwise."""
# Retrieve any comments hidden by "more comments" by passing limit=0
2020-02-01 00:23:15 -08:00
submission = solved_comment.submission
submission.comments.replace_more(limit=0)
# Search the flattened comments tree
for comment in submission.comments.list():
if (comment.id != solved_comment.id
2020-02-04 17:25:41 -08:00
and marks_as_solved(comment)
2020-02-01 00:23:15 -08:00
and comment.created_utc < solved_comment.created_utc):
# There is an earlier comment for the same submission
# already marked as a solution by the OP
return False
return True
def find_solver(solved_comment):
2020-02-11 09:08:03 -08:00
"""Determine the redditor responsible for solving the question."""
return solved_comment.parent().author
### Print Functions ###
def print_separator_line():
print('#' * 80)
def print_welcome_message():
print_separator_line()
print('\n*** Welcome to PointsBot! ***\n')
print_separator_line()
print('\nThis bot will monitor the subreddit specified in the '
'configuration file as long as this program is running.')
print('\nAny Reddit activity that occurs while this program is not running '
'will be missed. You can work around this by using features '
'mentioned in the README.')
print('\nThe output from this program can be referenced if any issues are '
'to occur, and the relevant error message or crash report can be '
'sent to the developer by reporting an issue on the Github page.')
print('\nFuture updates will hopefully resolve these issues, but for the '
"moment, this is what we've got to work with! :)\n")
print_separator_line()
2020-01-15 11:07:13 -08:00
def log_solution_info(comm):
logging.info('Submission solved')
logging.debug('Solution comment:')
logging.debug('Author: %s', comm.parent().author.name)
logging.debug('Body: %s', comm.parent().body)
logging.debug('"Solved" comment:')
logging.debug('Author: %s', comm.author.name)
logging.debug('Body: %s', comm.body)
2020-01-15 11:07:13 -08:00