Reworked comment & progress bar
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,9 +1,10 @@
|
||||
praw.ini
|
||||
pointsbot.ini
|
||||
todo.md
|
||||
*.db
|
||||
|
||||
*.db
|
||||
*.pyc
|
||||
__pycache__
|
||||
|
||||
docs/
|
||||
*.out
|
||||
*.swp
|
||||
|
||||
110
README.md
110
README.md
@@ -6,8 +6,6 @@
|
||||
* [Installation](#installation)
|
||||
* [Configuration](#configuration)
|
||||
* [Usage](#usage)
|
||||
* [Ideas](#ideas)
|
||||
* [Questions](#questions)
|
||||
* [Terms of Use for a bot for Reddit](#terms-of-use-for-a-bot-for-reddit)
|
||||
* [License](#license)
|
||||
|
||||
@@ -16,17 +14,13 @@
|
||||
This is a bot for Reddit that monitors solutions to questions or problems in a
|
||||
subreddit and awards points to the user responsible for the solution.
|
||||
|
||||
This bot is intended as a solution for
|
||||
This bot was conceived as a response to
|
||||
[this request](https://www.reddit.com/r/RequestABot/comments/emdeim/expert_level_bot_coding/).
|
||||
|
||||
The bot will award a point to a redditor when the OP of a submission includes
|
||||
"!Solved" or "!solved" somewhere in a reply to the redditor's comment on that
|
||||
submission. These points will allow the redditor to advance to different
|
||||
levels:
|
||||
|
||||
* Helper (5 points)
|
||||
* Trusted Helper (15 points)
|
||||
* Super Helper (40 points)
|
||||
levels.
|
||||
|
||||
At each level, the redditor's flair for the subreddit will be updated to reflect
|
||||
their current level. However, the bot should not change a mod's flair.
|
||||
@@ -35,6 +29,9 @@ Each time a point is awarded, the bot will reply to the solution comment to
|
||||
notify the redditor of their total points, with a progress bar to show how many
|
||||
points they need to reach the next level and a reminder of the title of the next
|
||||
level.
|
||||
In order to prevent the progress bar from being excessively long, some points
|
||||
will be consolidated into stars instead. Right now, stars are hard-coded to
|
||||
represent 100 points each, but this behavior may be configurable in the future.
|
||||
|
||||
The first time a point is awarded, the bot's reply comment will also include a
|
||||
brief message detailing the points system.
|
||||
@@ -171,103 +168,6 @@ run:
|
||||
pipenv run python -m pointsbot
|
||||
```
|
||||
|
||||
## Ideas
|
||||
|
||||
### Config
|
||||
|
||||
* Store all config and data in a hidden `.pointsbot` directory or similar in the
|
||||
user's home or data directory (OS-dependent).
|
||||
* Could do something similar to packages like PRAW:
|
||||
1. Look in current working directory first.
|
||||
2. Look in OS-dependent location next.
|
||||
3. (Maybe) Finally, could look in the directory containing the source files
|
||||
(using `__file__`).
|
||||
* Could also allow use of environment variables, but this seems unnecessary and
|
||||
could be a little too technical (though any more technically-minded users
|
||||
might appreciate the option).
|
||||
* Consolidate `pointsbot.ini` and `praw.ini` into a single config file.
|
||||
* Write a CLI script or GUI to handle this so the config values don't have to be
|
||||
changed manually.
|
||||
|
||||
### Database
|
||||
|
||||
* Should it keep track of the solved posts for future reference and calculate
|
||||
points on the fly, rather than just keeping track of points? If so, should
|
||||
have a column denoting whether the post has been deleted, if that
|
||||
information is decided to be useful when determining a user's points.
|
||||
|
||||
### Determining when to award points
|
||||
|
||||
To ensure that points are only awarded for the first comment marked as a
|
||||
solution:
|
||||
|
||||
* Alter the database to allow for tracking of each submission and the first
|
||||
comment marked as the solution. Then, everytime a new solution comment is
|
||||
detected, simply check the database to see if the submission is already
|
||||
counted. This will avoid unnecessary calls to Reddit, which would include
|
||||
scanning all the submission comments each time.
|
||||
* This approach could also make it simpler to check whether a solution comment
|
||||
has been edited. Instead of having to do a daily search for edits, it could
|
||||
just check the original solution comment to ensure that it still contains
|
||||
the "!solved" string. If not, it can remove points from that author and
|
||||
award points to the new author.
|
||||
|
||||
To ensure that a point is awarded to the correct user:
|
||||
|
||||
* We could expand the "!solved" bot summons to include an optional username
|
||||
argument. Without the argument, the bot will award the point to either the
|
||||
top-level comment or the parent comment of the "!solved" comment, whichever
|
||||
is decided upon. However, if the username argument is provided, the bot
|
||||
could simple check that one of the comments in the comment tree belongs to
|
||||
that user, and then award them the point.
|
||||
- Honestly, this is probably overcomplicated and unnecessary, though.
|
||||
|
||||
## Questions
|
||||
|
||||
1. Should it really display a progress bar without a bounds, since the user could
|
||||
get a large amount of points? Or should it end at 40 or whatever the max
|
||||
level is?
|
||||
1. When replying to a solution, should the bot...
|
||||
1. Reply directly to the comment containing the solution (current behavior),
|
||||
or
|
||||
1. Reply to the comment marking the submission as "!solved" and tag the
|
||||
solver?
|
||||
1. Should the bot check whether comments containing "!solved" have been edited to
|
||||
remove it?
|
||||
- If so, it could do that daily or something.
|
||||
- This will be especially important if a "recovery mode" is implemented that
|
||||
crawls through the whole subreddit to rebuild the database, since the
|
||||
bot would only be able to see comments that haven't been removed, or
|
||||
the newest version of edited comments.
|
||||
1. Should the bot be prepared to handle complex comment threads (e.g. multiple
|
||||
replies to the comment before it is marked as solved)? In other words, when
|
||||
the bot finds a comment containing the "!solved" string, should it...
|
||||
1. Assume that the author of the top-level comment is earning the point?
|
||||
1. Assume that the author of the parent comment of the "!solved" comment
|
||||
is earning the point? (current behavior)
|
||||
- Currently, the bot will either award the point to the author of the
|
||||
parent comment to the "!solved" comment, unless the author is the
|
||||
submission's OP, in which case is simply ignores the "!solved"
|
||||
comment altogether.
|
||||
- If we go with this behavior, I will probably change it to start at the
|
||||
parent of the "!solved" comment and work its way up until it finds a
|
||||
comment author who is not the OP, and then award them the point.
|
||||
- A workaround for this is to ask users to provide a username following
|
||||
the "!solved" word, starting with "r/" of course to make it easy to
|
||||
identify. Then, in situations where the bot is unable to determine
|
||||
who earned the point and the OP didn't provide a username after
|
||||
"!solved", the bot could even reply to the OP's "!solved" comment
|
||||
and request the username of the solver.
|
||||
1. Should the bot still keep track of points for mods, even though it doesn't
|
||||
update their flair? (I'd assume so, but it doesn't hurt to ask).
|
||||
1. Should the bot's reply comment always tag the user earning the point, even if
|
||||
it's responding directly to their comment? (Currently, it only tags the user
|
||||
when they earn their first point.)
|
||||
1. When a user levels up, should the reply comment also mention that they have
|
||||
been awarded a new flair?
|
||||
1. Should the comment contain a notice that the post was made by a bot, similar
|
||||
to the notice on posts by automod?
|
||||
|
||||
## Terms of Use for a bot for Reddit
|
||||
|
||||
Since this is an open-source, unmonetized program, it should be considered
|
||||
|
||||
211
pointsbot/bot.py
211
pointsbot/bot.py
@@ -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}"')
|
||||
|
||||
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:
|
||||
# Skip this "!solved" comment and wait for the next
|
||||
print('This is not the first solution')
|
||||
# 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('This is the first solution')
|
||||
print_level(0, '\nFound comment')
|
||||
print_level(1, f'Comment text: "{comm.body}"')
|
||||
|
||||
if marks_as_solved(comm, solved_pat):
|
||||
if not is_first_solution(comm, solved_pat):
|
||||
# Skip this "!solved" comment and wait for the next
|
||||
print_level(1, 'Not the first solution')
|
||||
continue
|
||||
|
||||
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 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):
|
||||
# TODO can also use the keyword arg css_class *or*
|
||||
# flair_template_id to style the flair
|
||||
print(f'Setting flair text to {levelname}')
|
||||
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('Don\'t update flair b/c user is mod')
|
||||
|
||||
# Don't need to check the rest of the levels
|
||||
break
|
||||
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}')
|
||||
|
||||
|
||||
|
||||
@@ -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
55
pointsbot/config.py
Normal 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
66
pointsbot/level.py
Normal 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
124
pointsbot/reply.py
Normal 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]())
|
||||
|
||||
|
||||
5
tests/context.py
Normal file
5
tests/context.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from os.path import abspath, dirname, join
|
||||
import sys
|
||||
sys.path.insert(0, abspath(join(dirname(__file__), '..')))
|
||||
|
||||
import pointsbot
|
||||
@@ -1,15 +0,0 @@
|
||||
import comment
|
||||
|
||||
levels = [
|
||||
('Helper', 5),
|
||||
('Trusted Helper', 15),
|
||||
('Super Helper', 40),
|
||||
]
|
||||
|
||||
tests = [1, 5, 10, 15, 30, 40, 45]
|
||||
|
||||
for testpoints in tests:
|
||||
progbar = comment.progress_bar(testpoints, levels)
|
||||
print(f'Points: {testpoints}')
|
||||
print(progbar)
|
||||
print()
|
||||
40
tests/test_level.py
Normal file
40
tests/test_level.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import level
|
||||
|
||||
levels = [
|
||||
('Helper', 5),
|
||||
('Trusted Helper', 15),
|
||||
('Super Helper', 45),
|
||||
]
|
||||
|
||||
|
||||
### Test user_level_info ###
|
||||
|
||||
|
||||
pastlvls, curlvl, nextlvl = user_level_info(1, levels)
|
||||
assert (pastlevels == [] and curlvl is None and nextlvl == levels[0])
|
||||
|
||||
pastlvls, curlvl, nextlvl = user_level_info(5, levels)
|
||||
assert (pastlevels == [] and curlvl == levels[0] and nextlvl == levels[1])
|
||||
|
||||
pastlvls, curlvl, nextlvl = user_level_info(15, levels)
|
||||
assert (pastlvls == levels[:1] and curlvl == levels[1] and nextlvl == levels[2])
|
||||
|
||||
pastlvls, curlvl, nextlvl = user_level_info(45, levels)
|
||||
assert (pastlvls == levels[:2] and curlvl == levels[2] and nextlvl is None)
|
||||
|
||||
|
||||
### Test is_max_level ###
|
||||
|
||||
|
||||
# TODO I mean, this could be tested exhaustively with positive numbers, even if
|
||||
# the number of points for the max level is decently large
|
||||
assert not level.is_max_level(-1, levels)
|
||||
assert not level.is_max_level(0, levels)
|
||||
assert not level.is_max_level(4, levels)
|
||||
assert not level.is_max_level(5, levels)
|
||||
assert not level.is_max_level(14, levels)
|
||||
assert not level.is_max_level(15, levels)
|
||||
assert not level.is_max_level(16, levels)
|
||||
assert not level.is_max_level(44, levels)
|
||||
assert level.is_max_level(45, levels)
|
||||
assert level.is_max_level(46, levels)
|
||||
44
tests/test_reply.py
Normal file
44
tests/test_reply.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from collections import namedtuple
|
||||
|
||||
from context import pointsbot
|
||||
|
||||
### Data Structures ###
|
||||
|
||||
MockRedditor = namedtuple('MockRedditor', 'id name')
|
||||
|
||||
### Functions ###
|
||||
|
||||
|
||||
def leftpad(msg, num_indents=1):
|
||||
return '\n'.join([('\t' * num_indents + l) for l in msg.split('\n')])
|
||||
|
||||
|
||||
### Tests ###
|
||||
|
||||
levels = [
|
||||
pointsbot.level.Level('Novice', 1),
|
||||
pointsbot.level.Level('Apprentice', 5),
|
||||
pointsbot.level.Level('Journeyman', 15),
|
||||
pointsbot.level.Level('Expert', 45),
|
||||
pointsbot.level.Level('Master I', 100),
|
||||
pointsbot.level.Level('Master II', 200),
|
||||
pointsbot.level.Level('Master III', 300),
|
||||
pointsbot.level.Level('Master IV', 400),
|
||||
pointsbot.level.Level('Master V', 500),
|
||||
]
|
||||
|
||||
testredditors = [MockRedditor('1', 'Tim_the_Sorcerer')]
|
||||
testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50))
|
||||
|
||||
for redditor in testredditors:
|
||||
for points in testpoints:
|
||||
level_info = pointsbot.level.user_level_info(points, levels)
|
||||
body = pointsbot.reply.make(redditor, points, level_info)
|
||||
print('*' * 80)
|
||||
print()
|
||||
print(f'Name: {redditor.name}')
|
||||
print(f'Points: {points}')
|
||||
print(f'Body:')
|
||||
print(leftpad(body, num_indents=1))
|
||||
print()
|
||||
print('*' * 80)
|
||||
Reference in New Issue
Block a user