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)
if mark_as_solved:
logging.info('Comment marks issue as solved')
elif remove_point:
logging.info('Comment removes point')
else:
# Skip this "!solved" comment # Skip this "!solved" comment
logging.info('Comment does not mark issue as solved') logging.info('Comment does not have a valid command')
continue continue
logging.info('Comment marks issue as solved')
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
log_solution_info(comm) if not remove_point:
log_solution_info(comm)
solver = find_solver(comm) solver = find_solver(comm)
db.add_point(solver) if remove_point:
logging.info('Added point for user "%s"', solver.name) 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) 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)
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 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)
db.remove_point(solver) if remove_point:
logging.error('Removed point that was just awarded to user "%s"', solver.name) 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') 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
lvl = level_info.current if level_info and level_info.current and level_info.current.points == points:
if lvl and lvl.points == points: lvl = level_info.current
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,28 +274,37 @@ 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) return True
# Search the flattened comments tree submission_title = solved_comment.submission.title.lower()
for comment in submission.comments.list():
if (comment.id != solved_comment.id for valid_tag in valid_tags:
and marks_as_solved(comment) if f"[{valid_tag}]" in submission_title:
and comment.created_utc < solved_comment.created_utc): return True
# There is an earlier comment for the same submission
# already marked as a solution by the OP return False
return False
return True
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):

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,18 +83,21 @@ 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)
params = { if points + points_modifier <= 0:
'id': redditor.id, return self.remove_redditor(redditor)
'name': redditor.name, else:
'points': points + points_modifier, params = {
} 'id': redditor.id,
update_stmt = ''' 'name': redditor.name,
UPDATE redditor_points 'points': points + points_modifier,
SET points = :points }
WHERE id = :id AND name = :name update_stmt = '''
''' UPDATE redditor_points
self.cursor.execute(update_stmt, params) SET points = :points
return self.cursor.rowcount WHERE id = :id AND name = :name
'''
self.cursor.execute(update_stmt, params)
return self.cursor.rowcount
@transaction @transaction
def get_points(self, redditor, add_if_none=False): def get_points(self, redditor, add_if_none=False):
@@ -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,35 +21,41 @@ 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(first_greeting(redditor)) paras.append(no_points(redditor))
if level_info.current and points == level_info.current.points: else:
paras.append(level_up(redditor, if points <= 1:
level_info.current.name, paras.append(first_greeting(redditor))
tag_user=False)) if level_info.current and points == level_info.current.points:
elif points > 1: paras.append(level_up(redditor,
user_already_tagged = False level_info.current.name,
tag_user=False))
elif points > 1:
user_already_tagged = False
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,
level_info.current.name, level_info.current.name,
tag_user=(not user_already_tagged))) tag_user=(not user_already_tagged)))
user_already_tagged = True user_already_tagged = True
if points % EXCESS_POINTS == 0: if points % EXCESS_POINTS == 0:
first_excess = (points == EXCESS_POINTS) first_excess = (points == EXCESS_POINTS)
paras.append(new_excess_symbol(redditor, paras.append(new_excess_symbol(redditor,
first_excess=first_excess, first_excess=first_excess,
tag_user=(not user_already_tagged))) tag_user=(not user_already_tagged)))
user_already_tagged = True user_already_tagged = True
if not user_already_tagged: if not user_already_tagged:
paras.append(normal_greeting(redditor)) 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(divider())
paras.append(footer(feedback_url=feedback_url, scoreboard_url=scoreboard_url)) paras.append(footer(feedback_url=feedback_url, scoreboard_url=scoreboard_url))
return '\n\n'.join(paras) return '\n\n'.join(paras)
@@ -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) '