Merge pull request #1 from Watchful1/master

Add flair and tag checks, implement remove point command
This commit is contained in:
Collin Rapp
2020-12-28 15:48:02 -08:00
committed by GitHub
5 changed files with 161 additions and 74 deletions

View File

@@ -7,6 +7,7 @@
[core] [core]
# The name of the subreddit to monitor, without the "r/" prefix. # The name of the subreddit to monitor, without the "r/" prefix.
subreddit = "" subreddit = ""
valid_tags = "Java,Bedrock,Dungeons,Earth,Education,Legacy"
################################################################################ ################################################################################

View File

@@ -1,4 +1,5 @@
import logging import logging
from logging.handlers import RotatingFileHandler
import os import os
import os.path import os.path
import re import re
@@ -18,6 +19,7 @@ USER_AGENT = 'PointsBot (by u/GlipGlorp7)'
# Could also use re.IGNORECASE flag instead # Could also use re.IGNORECASE flag instead
SOLVED_PATTERN = re.compile('![Ss]olved') SOLVED_PATTERN = re.compile('![Ss]olved')
MOD_SOLVED_PATTERN = re.compile('/[Ss]olved') MOD_SOLVED_PATTERN = re.compile('/[Ss]olved')
MOD_REMOVE_PATTERN = re.compile('/[Rr]emove[Pp]oint')
### Main Function ### ### Main Function ###
@@ -27,7 +29,7 @@ def run():
cfg = config.load() 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 = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.INFO) 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 author: "%s"', comm.author.name)
logging.debug('Comment text: "%s"', comm.body) 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)
# Skip this "!solved" comment if mark_as_solved:
logging.info('Comment does not mark issue as solved')
continue
logging.info('Comment marks issue 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 have a valid command')
continue
if is_mod_comment(comm): if is_mod_command:
logging.info('Comment was submitted by mod') logging.info('Comment was submitted by mod')
elif is_first_solution(comm): elif is_valid_tag(comm, cfg.tags) and is_valid_flair(comm):
logging.info('Comment is the first to mark the issue as solved') logging.info('Comment has a valid tag and is not already marked as solved')
else: else:
# Skip this "!solved" comment # Skip this "!solved" comment
logging.info('Comment is NOT the first to mark the issue as solved') logging.info('Comment is NOT the first to mark the issue as solved')
continue continue
if not remove_point:
log_solution_info(comm) log_solution_info(comm)
solver = find_solver(comm) solver = find_solver(comm)
if remove_point:
db.remove_point(solver)
logging.info('Removed point for user "%s"', solver.name)
else:
db.add_point(solver) db.add_point(solver)
logging.info('Added point for user "%s"', solver.name) logging.info('Added point for user "%s"', solver.name)
points = db.get_points(solver) points = db.get_points(solver)
logging.info('Total points for user "%s": %d', solver.name, points) logging.info('Total points for user "%s": %d', solver.name, points)
if points > 0:
level_info = level.user_level_info(points, levels) level_info = level.user_level_info(points, levels)
else:
level_info = None
# Reply to the comment marking the submission as solved # Reply to the comment marking the submission as solved
reply_body = reply.make(solver, reply_body = reply.make(solver,
points, points,
level_info, level_info,
feedback_url=cfg.feedback_url, feedback_url=cfg.feedback_url,
scoreboard_url=cfg.scoreboard_url) scoreboard_url=cfg.scoreboard_url,
is_add=not remove_point)
try: try:
comm.reply(reply_body) comm.reply(reply_body)
logging.info('Replied to the comment') logging.info('Replied to the comment')
logging.debug('Reply body: %s', reply_body) logging.debug('Reply body: %s', reply_body)
except praw.exceptions.APIException as e: except praw.exceptions.APIException as e:
logging.error('Unable to reply to comment: %s', e) logging.error('Unable to reply to comment: %s', e)
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) db.remove_point(solver)
logging.error('Removed point that was just awarded to user "%s"', solver.name) logging.error('Removed point that was just awarded to user "%s"', solver.name)
logging.error('Skipping comment') logging.error('Skipping comment')
continue continue
# Check if (non-mod) user flair should be updated to new level # Check if (non-mod) user flair should be updated to new level
if level_info and level_info.current and level_info.current.points == points:
lvl = level_info.current lvl = level_info.current
if lvl and lvl.points == points:
logging.info('User reached level: %s', lvl.name) logging.info('User reached level: %s', lvl.name)
if not subreddit.moderator(redditor=solver): if not subreddit.moderator(redditor=solver):
logging.info('User is not mod; setting flair') 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 = [] # GENERAL_RESPONSE_RULES = []
@@ -233,29 +274,38 @@ def marks_as_solved(comment):
if mod_rules_pass: if mod_rules_pass:
logging.info('Mod marking submission as solved') 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): def is_mod_comment(comment):
return comment.subreddit.moderator(redditor=comment.author) return comment.subreddit.moderator(redditor=comment.author)
def is_first_solution(solved_comment): def is_valid_tag(solved_comment, valid_tags):
"""Return True if this solved comment is the first, False otherwise.""" """Return True if this comments post has one of the allowed tags, False otherwise.
# Retrieve any comments hidden by "more comments" by passing limit=0 If there aren't any tags configured, skip this check"""
submission = solved_comment.submission if valid_tags is None:
submission.comments.replace_more(limit=0)
# 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 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): def find_solver(solved_comment):
"""Determine the redditor responsible for solving the question.""" """Determine the redditor responsible for solving the question."""

View File

@@ -43,7 +43,7 @@ class Config:
def __init__(self, filepath, subreddit, client_id, client_secret, username, def __init__(self, filepath, subreddit, client_id, client_secret, username,
password, levels, database_path=None, log_path=None, 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._filepath = filepath
self._dirname = os.path.dirname(filepath) self._dirname = os.path.dirname(filepath)
@@ -71,6 +71,10 @@ class Config:
self.password = password self.password = password
self.levels = levels self.levels = levels
if tag_string is None:
self.tags = None
else:
self.tags = tag_string.lower().split(",")
@classmethod @classmethod
def from_toml(cls, filepath): def from_toml(cls, filepath):
@@ -105,6 +109,7 @@ class Config:
log_path=logpath, log_path=logpath,
feedback_url=obj['links']['feedback'], feedback_url=obj['links']['feedback'],
scoreboard_url=obj['links']['scoreboard'], scoreboard_url=obj['links']['scoreboard'],
tag_string=obj['core']['valid_tags'],
) )
def save(self): def save(self):
@@ -118,6 +123,8 @@ class Config:
'flair_template_id': level.flair_template_id, 'flair_template_id': level.flair_template_id,
}) })
obj['tags'] = ','.join(obj['tags'])
with open(self._filepath, 'w') as f: with open(self._filepath, 'w') as f:
toml.dump(obj, f) toml.dump(obj, f)

View File

@@ -63,6 +63,16 @@ class Database:
self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name}) self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name})
return self.cursor.rowcount 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): def add_point(self, redditor):
return self._update_points(redditor, 1) return self._update_points(redditor, 1)
@@ -73,6 +83,9 @@ class Database:
def _update_points(self, redditor, points_modifier): def _update_points(self, redditor, points_modifier):
"""points_modifier is positive to add points, negative to subtract.""" """points_modifier is positive to add points, negative to subtract."""
points = self.get_points(redditor, add_if_none=True) points = self.get_points(redditor, add_if_none=True)
if points + points_modifier <= 0:
return self.remove_redditor(redditor)
else:
params = { params = {
'id': redditor.id, 'id': redditor.id,
'name': redditor.name, 'name': redditor.name,
@@ -101,6 +114,8 @@ class Database:
elif add_if_none: elif add_if_none:
points = 0 points = 0
self.add_redditor(redditor) self.add_redditor(redditor)
else:
points = 0
return points return points

View File

@@ -21,10 +21,16 @@ EXCESS_SYMBOL_TITLE = 'a star' # Used in comment body
### Main Functions ### ### Main Functions ###
def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None): def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None, is_add=True):
paras = [header()] if is_add:
paras = [solved_header()]
else:
paras = [remove_header()]
if points == 1: if level_info is None:
paras.append(no_points(redditor))
else:
if points <= 1:
paras.append(first_greeting(redditor)) paras.append(first_greeting(redditor))
if level_info.current and points == level_info.current.points: if level_info.current and points == level_info.current.points:
paras.append(level_up(redditor, paras.append(level_up(redditor,
@@ -58,10 +64,18 @@ def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None):
### Comment Section Functions ### ### Comment Section Functions ###
def header(): def solved_header():
return 'Thanks! Post marked as Solved!' 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): def first_greeting(redditor):
return (f'Congrats, u/{redditor.name}, you have received a point! Points ' return (f'Congrats, u/{redditor.name}, you have received a point! Points '
'help you "level up" to the next user flair!') 'help you "level up" to the next user flair!')
@@ -137,16 +151,16 @@ def divider():
def footer(feedback_url=None, scoreboard_url=None): def footer(feedback_url=None, scoreboard_url=None):
footer_sections = ['Bot maintained by GlipGlorp7'] footer_sections = ['^(Bot maintained by GlipGlorp7)']
if scoreboard_url: if scoreboard_url:
# https://points.minecrafthelp.co.uk # https://points.minecrafthelp.co.uk
footer_sections.append(f'[Scoreboard]({scoreboard_url})') footer_sections.append(f'[^Scoreboard]({scoreboard_url})')
if feedback_url: if feedback_url:
# https://forms.gle/m94aGjFQwGopqQ836 # https://forms.gle/m94aGjFQwGopqQ836
footer_sections.append(f'[Feedback]({feedback_url})') footer_sections.append(f'[^Feedback]({feedback_url})')
footer_sections.append('[Source Code](https://github.com/cur33/PointsBot)') footer_sections.append('[^Source ^Code](https://github.com/cur33/PointsBot)')
return '^(' + ' | '.join(footer_sections) + ')' return ' ^| '.join(footer_sections)
# return ('^(Bot maintained by GlipGlorp7 ' # return ('^(Bot maintained by GlipGlorp7 '
# '| [Scoreboard](https://points.minecrafthelp.co.uk) ' # '| [Scoreboard](https://points.minecrafthelp.co.uk) '