diff --git a/pointsbot.sample.toml b/pointsbot.sample.toml index 6e87ee9..02dad3a 100644 --- a/pointsbot.sample.toml +++ b/pointsbot.sample.toml @@ -7,6 +7,7 @@ [core] # The name of the subreddit to monitor, without the "r/" prefix. subreddit = "" +valid_tags = "Java,Bedrock,Dungeons,Earth,Education,Legacy" ################################################################################ diff --git a/pointsbot/bot.py b/pointsbot/bot.py index e2ea457..3bf8df8 100644 --- a/pointsbot/bot.py +++ b/pointsbot/bot.py @@ -1,4 +1,5 @@ import logging +from logging.handlers import RotatingFileHandler import os import os.path import re @@ -18,6 +19,7 @@ USER_AGENT = 'PointsBot (by u/GlipGlorp7)' # Could also use re.IGNORECASE flag instead SOLVED_PATTERN = re.compile('![Ss]olved') MOD_SOLVED_PATTERN = re.compile('/[Ss]olved') +MOD_REMOVE_PATTERN = re.compile('/[Rr]emove[Pp]oint') ### Main Function ### @@ -27,7 +29,7 @@ def run(): cfg = config.load() - file_handler = logging.FileHandler(cfg.log_path, 'w', 'utf-8') + file_handler = RotatingFileHandler(cfg.log_path, maxBytes=1024*1024, backupCount=3, encoding='utf-8') console_handler = logging.StreamHandler(sys.stderr) console_handler.setLevel(logging.INFO) @@ -97,50 +99,67 @@ def monitor_comments(reddit, subreddit, db, levels, cfg): logging.debug('Comment author: "%s"', comm.author.name) logging.debug('Comment text: "%s"', comm.body) - if not marks_as_solved(comm): + mark_as_solved, remove_point, is_mod_command = marks_as_solved(comm) + if mark_as_solved: + logging.info('Comment marks issue as solved') + elif remove_point: + logging.info('Comment removes point') + else: # Skip this "!solved" comment - logging.info('Comment does not mark issue as solved') + logging.info('Comment does not have a valid command') continue - logging.info('Comment marks issue as solved') - if is_mod_comment(comm): + if is_mod_command: logging.info('Comment was submitted by mod') - elif is_first_solution(comm): - logging.info('Comment is the first to mark the issue as solved') + elif is_valid_tag(comm, cfg.tags) and is_valid_flair(comm): + logging.info('Comment has a valid tag and is not already marked as solved') else: # Skip this "!solved" comment logging.info('Comment is NOT the first to mark the issue as solved') continue - log_solution_info(comm) + if not remove_point: + log_solution_info(comm) solver = find_solver(comm) - db.add_point(solver) - logging.info('Added point for user "%s"', solver.name) + if remove_point: + db.remove_point(solver) + logging.info('Removed point for user "%s"', solver.name) + else: + 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) + if points > 0: + level_info = level.user_level_info(points, levels) + else: + level_info = None # 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) + scoreboard_url=cfg.scoreboard_url, + is_add=not remove_point) try: comm.reply(reply_body) logging.info('Replied to the comment') logging.debug('Reply body: %s', reply_body) except praw.exceptions.APIException as e: logging.error('Unable to reply to comment: %s', e) - db.remove_point(solver) - logging.error('Removed point that was just awarded to user "%s"', solver.name) + if remove_point: + db.add_point(solver) + logging.error('Re-added point that was just removed from user "%s"', solver.name) + else: + db.remove_point(solver) + logging.error('Removed point that was just awarded to user "%s"', solver.name) logging.error('Skipping comment') continue # Check if (non-mod) user flair should be updated to new level - lvl = level_info.current - if lvl and lvl.points == points: + if level_info and level_info.current and level_info.current.points == points: + lvl = level_info.current logging.info('User reached level: %s', lvl.name) if not subreddit.moderator(redditor=solver): logging.info('User is not mod; setting flair') @@ -208,6 +227,28 @@ MOD_RESPONSE_RULES = [ ), ] +MOD_REMOVE_RULES = [ + SolutionResponseRule( + 'contains mod "removepoint" pattern', + 'Comment contains mod "removepoint" pattern', + 'Comment does not contain mod "removepoint" pattern', + lambda c: MOD_REMOVE_PATTERN.search(c.body), + ), + SolutionResponseRule( + 'is a reply (not top-level)', + 'Comment is a reply to another comment', + 'Comment is a top-level comment', + lambda c: not c.is_root, + ), + SolutionResponseRule( + 'author is mod', + 'Comment author is a mod', + 'Comment author is not a mod', + # functions + lambda c: is_mod_comment(c), + ), +] + # GENERAL_RESPONSE_RULES = [] @@ -233,28 +274,37 @@ def marks_as_solved(comment): if mod_rules_pass: logging.info('Mod marking submission as solved') - return op_rules_pass or mod_rules_pass + mod_remove_pass = check_rules(MOD_REMOVE_RULES, comment) + if mod_remove_pass: + logging.info('Mod removing point') + + return op_rules_pass or mod_rules_pass, mod_remove_pass, mod_rules_pass or mod_remove_pass def is_mod_comment(comment): return comment.subreddit.moderator(redditor=comment.author) -def is_first_solution(solved_comment): - """Return True if this solved comment is the first, False otherwise.""" - # Retrieve any comments hidden by "more comments" by passing limit=0 - submission = solved_comment.submission - submission.comments.replace_more(limit=0) +def is_valid_tag(solved_comment, valid_tags): + """Return True if this comments post has one of the allowed tags, False otherwise. + If there aren't any tags configured, skip this check""" + if valid_tags is None: + return True - # Search the flattened comments tree - for comment in submission.comments.list(): - if (comment.id != solved_comment.id - and marks_as_solved(comment) - 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 + submission_title = solved_comment.submission.title.lower() + + for valid_tag in valid_tags: + if f"[{valid_tag}]" in submission_title: + return True + + return False + + +def is_valid_flair(solved_comment): + """Return True if this comment's post doesn't already have the Solved flair, False otherwise.""" + submission = solved_comment.submission + + return submission.link_flair_text.lower() != "solved" def find_solver(solved_comment): diff --git a/pointsbot/config.py b/pointsbot/config.py index 0a64724..15a8233 100644 --- a/pointsbot/config.py +++ b/pointsbot/config.py @@ -43,7 +43,7 @@ class Config: def __init__(self, filepath, subreddit, client_id, client_secret, username, password, levels, database_path=None, log_path=None, - feedback_url=None, scoreboard_url=None): + feedback_url=None, scoreboard_url=None, tag_string=None): self._filepath = filepath self._dirname = os.path.dirname(filepath) @@ -71,6 +71,10 @@ class Config: self.password = password self.levels = levels + if tag_string is None: + self.tags = None + else: + self.tags = tag_string.lower().split(",") @classmethod def from_toml(cls, filepath): @@ -105,6 +109,7 @@ class Config: log_path=logpath, feedback_url=obj['links']['feedback'], scoreboard_url=obj['links']['scoreboard'], + tag_string=obj['core']['valid_tags'], ) def save(self): @@ -118,6 +123,8 @@ class Config: 'flair_template_id': level.flair_template_id, }) + obj['tags'] = ','.join(obj['tags']) + with open(self._filepath, 'w') as f: toml.dump(obj, f) diff --git a/pointsbot/database.py b/pointsbot/database.py index 3d28966..95d90d4 100644 --- a/pointsbot/database.py +++ b/pointsbot/database.py @@ -63,6 +63,16 @@ class Database: self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name}) return self.cursor.rowcount + @transaction + def remove_redditor(self, redditor): + insert_stmt = ''' + DELETE FROM redditor_points + WHERE id = :id + AND name = :name + ''' + self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name}) + return self.cursor.rowcount + def add_point(self, redditor): return self._update_points(redditor, 1) @@ -73,18 +83,21 @@ class Database: def _update_points(self, redditor, points_modifier): """points_modifier is positive to add points, negative to subtract.""" points = self.get_points(redditor, add_if_none=True) - params = { - 'id': redditor.id, - 'name': redditor.name, - 'points': points + points_modifier, - } - update_stmt = ''' - UPDATE redditor_points - SET points = :points - WHERE id = :id AND name = :name - ''' - self.cursor.execute(update_stmt, params) - return self.cursor.rowcount + if points + points_modifier <= 0: + return self.remove_redditor(redditor) + else: + params = { + 'id': redditor.id, + 'name': redditor.name, + 'points': points + points_modifier, + } + update_stmt = ''' + UPDATE redditor_points + SET points = :points + WHERE id = :id AND name = :name + ''' + self.cursor.execute(update_stmt, params) + return self.cursor.rowcount @transaction def get_points(self, redditor, add_if_none=False): @@ -101,6 +114,8 @@ class Database: elif add_if_none: points = 0 self.add_redditor(redditor) + else: + points = 0 return points diff --git a/pointsbot/reply.py b/pointsbot/reply.py index 581e1da..7dd4dfe 100644 --- a/pointsbot/reply.py +++ b/pointsbot/reply.py @@ -21,35 +21,41 @@ EXCESS_SYMBOL_TITLE = 'a star' # Used in comment body ### Main Functions ### -def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None): - paras = [header()] +def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None, is_add=True): + if is_add: + paras = [solved_header()] + else: + paras = [remove_header()] - if points == 1: - paras.append(first_greeting(redditor)) - if level_info.current and points == level_info.current.points: - paras.append(level_up(redditor, - level_info.current.name, - tag_user=False)) - elif points > 1: - user_already_tagged = False + if level_info is None: + paras.append(no_points(redditor)) + else: + if points <= 1: + paras.append(first_greeting(redditor)) + if level_info.current and points == level_info.current.points: + paras.append(level_up(redditor, + level_info.current.name, + tag_user=False)) + elif points > 1: + user_already_tagged = False - if level_info.current and points == level_info.current.points: - paras.append(level_up(redditor, - level_info.current.name, - tag_user=(not user_already_tagged))) - user_already_tagged = True + if level_info.current and points == level_info.current.points: + paras.append(level_up(redditor, + level_info.current.name, + tag_user=(not user_already_tagged))) + user_already_tagged = True - if points % EXCESS_POINTS == 0: - first_excess = (points == EXCESS_POINTS) - paras.append(new_excess_symbol(redditor, - first_excess=first_excess, - tag_user=(not user_already_tagged))) - user_already_tagged = True + if points % EXCESS_POINTS == 0: + first_excess = (points == EXCESS_POINTS) + paras.append(new_excess_symbol(redditor, + first_excess=first_excess, + tag_user=(not user_already_tagged))) + user_already_tagged = True - if not user_already_tagged: - paras.append(normal_greeting(redditor)) + if not user_already_tagged: + paras.append(normal_greeting(redditor)) - paras.append(points_status(redditor, points, level_info)) + paras.append(points_status(redditor, points, level_info)) paras.append(divider()) paras.append(footer(feedback_url=feedback_url, scoreboard_url=scoreboard_url)) return '\n\n'.join(paras) @@ -58,10 +64,18 @@ def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None): ### Comment Section Functions ### -def header(): +def solved_header(): return 'Thanks! Post marked as Solved!' +def remove_header(): + return 'Point removed.' + + +def no_points(redditor): + return f'u/{redditor.name} now has no points' + + def first_greeting(redditor): return (f'Congrats, u/{redditor.name}, you have received a point! Points ' 'help you "level up" to the next user flair!') @@ -137,16 +151,16 @@ def divider(): def footer(feedback_url=None, scoreboard_url=None): - footer_sections = ['Bot maintained by GlipGlorp7'] + footer_sections = ['^(Bot maintained by GlipGlorp7)'] if scoreboard_url: # https://points.minecrafthelp.co.uk - footer_sections.append(f'[Scoreboard]({scoreboard_url})') + footer_sections.append(f'[^Scoreboard]({scoreboard_url})') if feedback_url: # https://forms.gle/m94aGjFQwGopqQ836 - footer_sections.append(f'[Feedback]({feedback_url})') - footer_sections.append('[Source Code](https://github.com/cur33/PointsBot)') + footer_sections.append(f'[^Feedback]({feedback_url})') + footer_sections.append('[^Source ^Code](https://github.com/cur33/PointsBot)') - return '^(' + ' | '.join(footer_sections) + ')' + return ' ^| '.join(footer_sections) # return ('^(Bot maintained by GlipGlorp7 ' # '| [Scoreboard](https://points.minecrafthelp.co.uk) '