Merge pull request #1 from Watchful1/master
Add flair and tag checks, implement remove point command
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|||||||
112
pointsbot/bot.py
112
pointsbot/bot.py
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) '
|
||||||
|
|||||||
Reference in New Issue
Block a user