Reworked comment & progress bar

This commit is contained in:
Collin R
2020-02-01 00:23:15 -08:00
parent 74771bd24d
commit 3dee25bd80
11 changed files with 464 additions and 303 deletions

View File

@@ -1,136 +1,169 @@
import configparser
import re
import praw
from . import comment, database
### Globals ###
CONFIGPATH = 'pointsbot.ini'
from . import config, database, level, reply
### Main Function ###
def run():
config = configparser.ConfigParser()
config.read(CONFIGPATH)
# Get the user flair levels in ascending order by point value
# TODO Make levels a dict instead
# TODO Could also make a Level class or namedtuple that contains more info, e.g.
# flair css or template id
# levels = [(o, config.getint('Levels', o)) for o in config.options('Levels')]
# levels.sort(key=lambda pair: pair[1])
levels = []
for opt in config.options('Levels'):
levels.append((opt.title(), config.getint('Levels', opt)))
levels.sort(key=lambda pair: pair[1])
cfg = config.load()
levels = cfg.levels
# Connect to Reddit
reddit = praw.Reddit(site_name=config['Core']['praw_site_name'])
subreddit = reddit.subreddit(config['Core']['subreddit_name'])
reddit = praw.Reddit(site_name=cfg.praw_site_name)
subreddit = reddit.subreddit(cfg.subreddit_name)
print(f'Connected to Reddit as {reddit.user.me()}')
print(f'Read-only? {reddit.read_only}')
print(f'Watching subreddit {subreddit.title}')
print(f'Is mod? {bool(subreddit.moderator(redditor=reddit.user.me()))}')
print_level(0, f'Connected to Reddit as {reddit.user.me()}')
print_level(1, f'Read-only? {reddit.read_only}')
print_level(0, f'Watching subreddit {subreddit.title}')
is_mod = bool(subreddit.moderator(redditor=reddit.user.me()))
print_level(1, f'Is mod? {is_mod}')
# TODO pass database path instead of setting global variable
db = database.Database(config['Core']['database_name'])
testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50))
# The pattern to look for in comments when determining whether to award a point
# solved_pat = re.compile('!solved', re.IGNORECASE)
for sub in subreddit.new():
if sub.title == 'Testing comment scenarios':
redditor = sub.author
for points in testpoints:
body = f'Solver: {redditor}\n\nTotal points after solving: {points}'
print_level(0, body)
comm = sub.reply(body)
if comm:
level_info = level.user_level_info(points, levels)
body = reply.make(redditor, points, level_info)
comm.reply(body)
else:
print_level(1, 'ERROR: Unable to comment')
break
def real_run():
cfg = config.load()
levels = cfg.levels
# Connect to Reddit
reddit = praw.Reddit(site_name=cfg.praw_site_name)
subreddit = reddit.subreddit(cfg.subreddit_name)
print_level(0, f'Connected to Reddit as {reddit.user.me()}')
print_level(1, f'Read-only? {reddit.read_only}')
print_level(0, f'Watching subreddit {subreddit.title}')
is_mod = bool(subreddit.moderator(redditor=reddit.user.me()))
print_level(1, f'Is mod? {is_mod}')
db = database.Database(cfg.database_path)
# The pattern that determines whether a post is marked as solved
# Could also just use re.IGNORECASE flag
solved_pat = re.compile('![Ss]olved')
# Monitor new comments for confirmed solutions
for comm in subreddit.stream.comments(skip_existing=True):
print('Found comment')
print(f'Comment text: "{comm.body}"')
# Passing pause_after=0 will bypass the internal exponential delay, but have
# to check if any comments are returned with each query
for comm in subreddit.stream.comments(skip_existing=True, pause_after=0):
if comm is None:
continue
print_level(0, '\nFound comment')
print_level(1, f'Comment text: "{comm.body}"')
if marks_as_solved(comm, solved_pat):
# Ensure that this is the first "!solved" comment in the thread
submission = comm.submission
# Retrieve any comments hidden by "more comments"
submission.comments.replace_more(limit=0)
# Search the flattened comments tree
is_first_solution = True
for subcomm in submission.comments.list():
if (subcomm.id != comm.id
and marks_as_solved(subcomm, solved_pat)
and subcomm.created_utc < comm.created_utc):
# There is an earlier comment for the same submission
# already marked as a solution by the OP
is_first_solution = False
break
if not is_first_solution:
if not is_first_solution(comm, solved_pat):
# Skip this "!solved" comment and wait for the next
print('This is not the first solution')
print_level(1, 'Not the first solution')
continue
print('This is the first solution')
print_level(1, 'This is the first solution')
print_solution_info(comm)
solution = comm.parent()
solver = solution.author
print(f'Adding point for {solver.name}')
solver = comm.parent().author
print_level(1, f'Adding point for {solver.name}')
db.add_point(solver)
points = db.get_points(solver)
print(f'Points for {solver.name}: {points}')
print_level(1, f'Points for {solver.name}: {points}')
# Reply to the comment containing the solution
reply_body = comment.make(solver, points, levels)
print(f'Replying with: "{reply_body}"')
solution.reply(reply_body)
level_info = level.user_level_info(points, levels)
# TODO move comment to the end and use some things, e.g. whether
# flair is set, when building comment (to avoid duplicate logic)
# Reply to the comment marking the submission as solved
reply_body = reply.make(solver, points, level_info)
# reply_body = reply.make(solver, points, levels)
print_level(1, f'Replying with: "{reply_body}"')
comm.reply(reply_body)
# Check if (non-mod) user flair should be updated to new level
for levelname, levelpoints in levels:
# If the redditor's points total is equal to one of the levels,
# that means they just reached that level
if points == levelpoints:
print('User reached new level')
if not subreddit.moderator(redditor=solver):
# TODO can also use the keyword arg css_class *or*
# flair_template_id to style the flair
print(f'Setting flair text to {levelname}')
subreddit.flair.set(solver, text=levelname)
else:
print('Don\'t update flair b/c user is mod')
# Don't need to check the rest of the levels
break
if level_info.current and level_info.current.points == points:
print_level(1, f'User reached level: {level_info.current.name}')
if not subreddit.moderator(redditor=solver):
print_level(2, 'Setting flair')
print_level(3, f'Flair text: {level_info.current.name}')
# TODO
# print_level(3, f'Flair template ID: {}')
subreddit.flair.set(solver, text=levelname)
else:
print_level(2, 'Solver is mod; don\'t alter flair')
else:
print('Not a "!solved" comment')
print_level(1, 'Not a "!solved" comment')
### Auxiliary Functions ###
### Reddit Comment Functions ###
def marks_as_solved(comment, solved_pattern):
'''Return True if not top-level comment, from OP, contains "!Solved"; False
otherwise.
'''Return True if the comment meets the criteria for marking the submission
as solved, False otherwise.
'''
#comment.refresh() # probably not needed, but the docs are a tad unclear
return (not comment.is_root
and comment.is_submitter
and not comment.parent().is_submitter
and solved_pattern.search(comment.body))
### Debugging ###
def is_first_solution(solved_comment, solved_pattern):
# Retrieve any comments hidden by "more comments"
# Passing limit=0 will replace all "more comments"
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
and marks_as_solved(comment, solved_pattern)
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(comment):
# TODO
pass
def make_flair(points, levels):
# TODO
pass
### Debugging & Logging ###
def print_level(num_levels, string):
print('\t' * num_levels + string)
def print_solution_info(comm):
print('Submission solved')
print('\tSolution comment:')
print(f'\t\tAuthor: {comm.parent().author.name}')
print(f'\t\tBody: {comm.parent().body}')
print('\t"Solved" comment:')
print(f'\t\tAuthor: {comm.author.name}')
print(f'\t\tBody: {comm.body}')
print_level(1, 'Submission solved')
print_level(2, 'Solution comment:')
print_level(3, f'Author: {comm.parent().author.name}')
print_level(3, f'Body: {comm.parent().body}')
print_level(2, '"Solved" comment:')
print_level(3, f'Author: {comm.author.name}')
print_level(3, f'Body: {comm.body}')

View File

@@ -1,92 +0,0 @@
### Globals ###
FILLED_SYMBOL = '\u25AE' # A small filled box character
EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character
DIV_SYMBOL = '|'
### Main Functions ###
def make(redditor, points, levels):
body = (first_point(redditor) + '\n\n') if points == 1 else ''
body += points_status(redditor, points, levels)
return body
### Auxiliary Functions ###
def first_point(redditor):
msg = (f'Congrats, u/{redditor.name}; you have received a point! Points '
'help you "level up" to the next user flair!')
return msg
def points_status(redditor, points, levels):
'''Levels is an iterable of (level_name, points) pairs, sorted in ascending
order by points.
'''
for next_level_name, next_level_points in levels:
if next_level_points > points:
break
pointstext = 'points' if points > 1 else 'point'
if points < next_level_points:
lines = [
f'Next level: "{next_level_name}"',
f'You have {points} {pointstext}',
f'You need {next_level_points} points',
]
else:
lines = [
'MAXIMUM LEVEL ACHIEVED!!!',
f'You have {points} {pointstext}',
]
# 2 spaces are appended to each line to force a line break but not a
# paragraph break
# lines = [line + ' ' for line in lines]
lines = list(map(lambda line: line + ' ', lines))
"""
if points < next_level_points:
lines = [
f'Next level: "{next_level_name}" ',
f'You need {next_level_points} points ',
]
else:
lines = ['MAXIMUM LEVEL ACHIEVED!!! ']
# TODO hacky and bad :(
lines.insert(1, f'You have {points} points ')
"""
lines.append(progress_bar(points, levels))
return '\n'.join(lines)
def progress_bar(points, levels):
'''Assumes levels is sorted in ascending order.'''
progbar = [FILLED_SYMBOL] * points
ndx_shift = 0
for levelndx, level in enumerate(levels):
next_level_name, next_level_points = level
if next_level_points > points:
break
ndx = next_level_points + ndx_shift
progbar.insert(ndx, DIV_SYMBOL)
ndx_shift += 1
if next_level_points <= points:
# If just reached max level, then an extra DIV_SYMBOL was appended
progbar.pop()
else:
# Not max level, so fill slots left until next level with EMPTY_SYMBOL
remaining = next_level_points - points
progbar.extend([EMPTY_SYMBOL] * remaining)
return '[' + ''.join(progbar) + ']'

55
pointsbot/config.py Normal file
View File

@@ -0,0 +1,55 @@
import configparser
import os.path
# from os.path import abspath, dirname, join
from .level import Level
### Globals ###
ROOTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
CONFIGPATH = os.path.join(ROOTPATH, 'pointsbot.ini')
# CONFIGPATH = abspath(join(dirname(__file__), '..', 'pointsbot.ini'))
# TODO add default config values to pass to configparser
### Classes ###
class Config:
def __init__(self, praw_site_name='', subreddit_name='', database_path='',
levels=None):
self.praw_site_name = praw_site_name
self.subreddit_name = subreddit_name
self.database_path = database_path
self.levels = levels if levels is not None else []
@classmethod
def from_configparser(cls, config):
database_path = os.path.join(ROOTPATH, config['Core']['database_name'])
# Get the user flair levels in ascending order by point value
# TODO Make levels a dict instead
levels = []
for opt in config.options('Levels'):
name, points = opt.title(), config.getint('Levels', opt)
levels.append(Level(name, points))
levels.sort(key=lambda lvl: lvl.points)
return cls(
praw_site_name=config['Core']['praw_site_name'],
subreddit_name=config['Core']['subreddit_name'],
database_path=database_path,
levels=levels,
)
### Functions ###
def load():
config = configparser.ConfigParser()
config.read(CONFIGPATH)
return Config.from_configparser(config)

66
pointsbot/level.py Normal file
View File

@@ -0,0 +1,66 @@
from collections import namedtuple
### Data Structures ###
# A (string, int) tuple
Level = namedtuple('Level', 'name points')
# A ([Level], Level, Level) tuple;
# previous can be empty, and exactly one of current and next can be None
LevelInfo = namedtuple('LevelInfo', 'previous current next')
# LevelInfo = namedtuple('LevelInfo', 'prev cur next')
### Functions ###
"""
def get_levels(config):
levels = []
for opt in config.options('Levels'):
name, points = opt.title(), config.getint('Levels', opt)
levels.append(Level(name, points))
# levels.append((opt.title(), config.getint('Levels', opt)))
# levels.sort(key=lambda pair: pair[1])
levels.sort(key=lambda lvl: lvl.points)
return levels
"""
def user_level_info(points, levels):
'''Return a tuple the user's previous (plural), current, and next levels.
If the user has yet to reach the first level, return ([], None, <first
level>).
If the user has reached the max level, return ([previous], <max level>,
None).
Assume levels is sorted in ascending order by points.
'''
past_levels, cur_level, next_level = [], None, None
for level in levels:
lvlname, lvlpoints = level
if points < lvlpoints:
next_level = level
break
if cur_level:
past_levels.append(cur_level)
cur_level = level
else:
next_level = None
return LevelInfo(past_levels, cur_level, next_level)
def is_max_level(level_info):
return not level_info.next
"""
def is_max_level(points, levels):
'''Assume levels is sorted in ascending order by points.'''
# return points >= levels[-1][1]
return points >= levels[-1].points
"""

124
pointsbot/reply.py Normal file
View File

@@ -0,0 +1,124 @@
from . import level
### Globals ###
EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character
FILLED_SYMBOL = '\u25AE' # A small filled box character
EXCESS_SYMBOL = '\u2B51' # A star character
DIV_SYMBOL = '|'
# Number of "excess" points should be greater than max level points
EXCESS_POINTS = 100 # TODO move this to level?
### Main Functions ###
def make(redditor, points, level_info):
paras = [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:
if level_info.current and points == level_info.current.points:
paras.append(level_up(redditor, level_info.current.name))
elif not level_info.next and points > 0 and points % EXCESS_POINTS == 0:
first_star = (points == EXCESS_POINTS)
paras.append(new_star(redditor, first_star))
else:
paras.append(normal_greeting(redditor))
paras.append(points_status(redditor, points, level_info))
paras.append(footer())
return '\n\n'.join(paras)
### Comment Section Functions ###
def header():
return 'Thanks! Post marked as Solved!'
def first_greeting(redditor):
msg = (f'Congrats, u/{redditor.name}, you have received a point! Points '
'help you "level up" to the next user flair!')
return msg
def normal_greeting(redditor):
return f'u/{redditor.name}, here is your points status:'
def level_up(redditor, level_name, tag_user=True):
start = f'Congrats u/{redditor.name}, y' if tag_user else 'Y'
return (f'{start}ou have leveled up to "{level_name}"! Your flair has been '
'updated accordingly.')
# return (f'Congrats u/{redditor.name}, y
# return (f'Congrats u/{redditor.name}, you have leveled up to "{level_name}"! '
# 'Your flair has been updated accordingly.')
def new_star(redditor, first_star):
num_stars_msg = '' if first_star else 'another '
return (f'Congrats u/{redditor.name} on getting '
'{num_stars_msg}{EXCESS_POINTS} points! They are shown as a star '
'in your progress bar.')
def points_status(redditor, points, level_info):
pointstext = 'points' if points > 1 else 'point'
if level_info.next:
lines = [
f'Next level: "{level_info.next.name}"',
f'You have {points} {pointstext}',
f'You need {level_info.next.points} points',
]
else:
lines = [
'MAXIMUM LEVEL ACHIEVED!!!',
f'You have {points} {pointstext}',
]
# 2 spaces are appended to each line to force a Markdown line break
lines = [line + ' ' for line in lines]
lines.append(progress_bar(points, level_info))
return '\n'.join(lines)
def progress_bar(points, level_info):
if points < EXCESS_POINTS:
past, cur, nxt = level_info
allpoints = [lvl.points for lvl in [*past, cur]]
diffs = [a - b for a, b in zip(allpoints, [0] + allpoints)]
bar = [FILLED_SYMBOL * diff for diff in diffs]
if nxt:
have = points if not cur else points - cur.points
need = nxt.points - points
bar.append((FILLED_SYMBOL * have) + (EMPTY_SYMBOL * need))
bar = DIV_SYMBOL.join(bar)
else:
num_excess, num_leftover = divmod(points, EXCESS_POINTS)
bar = [DIV_SYMBOL.join(EXCESS_SYMBOL * num_excess)]
if num_leftover > 0:
bar.append(DIV_SYMBOL)
bar.append(FILLED_SYMBOL * num_leftover)
bar = ''.join(bar)
return f'[{bar}]'
def footer():
return ('^(This bot is written and maintained by GlipGlorp7 '
'| Learn more and view the source code on '
'[Github](https://github.com/cur33/PointsBot))')
# ^(This bot is written and maintained by u/GlipGlorp7 | Learn more and view the source code on [Github](https://github.com/cur33/PointsBot))
# ^([Learn more]() | [View source code](https://github.com/cur33/PointsBot) | [Contacts mods]())