Reworked comment & progress bar
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,9 +1,10 @@
|
|||||||
praw.ini
|
praw.ini
|
||||||
pointsbot.ini
|
pointsbot.ini
|
||||||
todo.md
|
|
||||||
*.db
|
|
||||||
|
|
||||||
|
*.db
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
docs/
|
||||||
|
*.out
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -6,8 +6,6 @@
|
|||||||
* [Installation](#installation)
|
* [Installation](#installation)
|
||||||
* [Configuration](#configuration)
|
* [Configuration](#configuration)
|
||||||
* [Usage](#usage)
|
* [Usage](#usage)
|
||||||
* [Ideas](#ideas)
|
|
||||||
* [Questions](#questions)
|
|
||||||
* [Terms of Use for a bot for Reddit](#terms-of-use-for-a-bot-for-reddit)
|
* [Terms of Use for a bot for Reddit](#terms-of-use-for-a-bot-for-reddit)
|
||||||
* [License](#license)
|
* [License](#license)
|
||||||
|
|
||||||
@@ -16,17 +14,13 @@
|
|||||||
This is a bot for Reddit that monitors solutions to questions or problems in a
|
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.
|
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/).
|
[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
|
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
|
"!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
|
submission. These points will allow the redditor to advance to different
|
||||||
levels:
|
levels.
|
||||||
|
|
||||||
* Helper (5 points)
|
|
||||||
* Trusted Helper (15 points)
|
|
||||||
* Super Helper (40 points)
|
|
||||||
|
|
||||||
At each level, the redditor's flair for the subreddit will be updated to reflect
|
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.
|
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
|
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
|
points they need to reach the next level and a reminder of the title of the next
|
||||||
level.
|
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
|
The first time a point is awarded, the bot's reply comment will also include a
|
||||||
brief message detailing the points system.
|
brief message detailing the points system.
|
||||||
@@ -171,103 +168,6 @@ run:
|
|||||||
pipenv run python -m pointsbot
|
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
|
## Terms of Use for a bot for Reddit
|
||||||
|
|
||||||
Since this is an open-source, unmonetized program, it should be considered
|
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 re
|
||||||
|
|
||||||
import praw
|
import praw
|
||||||
|
|
||||||
from . import comment, database
|
from . import config, database, level, reply
|
||||||
|
|
||||||
### Globals ###
|
|
||||||
|
|
||||||
CONFIGPATH = 'pointsbot.ini'
|
|
||||||
|
|
||||||
### Main Function ###
|
### Main Function ###
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
config = configparser.ConfigParser()
|
cfg = config.load()
|
||||||
config.read(CONFIGPATH)
|
levels = cfg.levels
|
||||||
|
|
||||||
# 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])
|
|
||||||
|
|
||||||
# Connect to Reddit
|
# Connect to Reddit
|
||||||
reddit = praw.Reddit(site_name=config['Core']['praw_site_name'])
|
reddit = praw.Reddit(site_name=cfg.praw_site_name)
|
||||||
subreddit = reddit.subreddit(config['Core']['subreddit_name'])
|
subreddit = reddit.subreddit(cfg.subreddit_name)
|
||||||
|
|
||||||
print(f'Connected to Reddit as {reddit.user.me()}')
|
print_level(0, f'Connected to Reddit as {reddit.user.me()}')
|
||||||
print(f'Read-only? {reddit.read_only}')
|
print_level(1, f'Read-only? {reddit.read_only}')
|
||||||
print(f'Watching subreddit {subreddit.title}')
|
print_level(0, f'Watching subreddit {subreddit.title}')
|
||||||
print(f'Is mod? {bool(subreddit.moderator(redditor=reddit.user.me()))}')
|
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
|
testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50))
|
||||||
db = database.Database(config['Core']['database_name'])
|
|
||||||
|
|
||||||
# The pattern to look for in comments when determining whether to award a point
|
for sub in subreddit.new():
|
||||||
# solved_pat = re.compile('!solved', re.IGNORECASE)
|
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')
|
solved_pat = re.compile('![Ss]olved')
|
||||||
|
|
||||||
# Monitor new comments for confirmed solutions
|
# Monitor new comments for confirmed solutions
|
||||||
for comm in subreddit.stream.comments(skip_existing=True):
|
# Passing pause_after=0 will bypass the internal exponential delay, but have
|
||||||
print('Found comment')
|
# to check if any comments are returned with each query
|
||||||
print(f'Comment text: "{comm.body}"')
|
for comm in subreddit.stream.comments(skip_existing=True, pause_after=0):
|
||||||
|
if comm is None:
|
||||||
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')
|
|
||||||
continue
|
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)
|
print_solution_info(comm)
|
||||||
|
|
||||||
solution = comm.parent()
|
solver = comm.parent().author
|
||||||
solver = solution.author
|
print_level(1, f'Adding point for {solver.name}')
|
||||||
print(f'Adding point for {solver.name}')
|
|
||||||
db.add_point(solver)
|
db.add_point(solver)
|
||||||
points = db.get_points(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
|
level_info = level.user_level_info(points, levels)
|
||||||
reply_body = comment.make(solver, points, levels)
|
|
||||||
print(f'Replying with: "{reply_body}"')
|
# TODO move comment to the end and use some things, e.g. whether
|
||||||
solution.reply(reply_body)
|
# 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
|
# Check if (non-mod) user flair should be updated to new level
|
||||||
for levelname, levelpoints in levels:
|
if level_info.current and level_info.current.points == points:
|
||||||
# If the redditor's points total is equal to one of the levels,
|
print_level(1, f'User reached level: {level_info.current.name}')
|
||||||
# that means they just reached that level
|
|
||||||
if points == levelpoints:
|
|
||||||
print('User reached new level')
|
|
||||||
if not subreddit.moderator(redditor=solver):
|
if not subreddit.moderator(redditor=solver):
|
||||||
# TODO can also use the keyword arg css_class *or*
|
print_level(2, 'Setting flair')
|
||||||
# flair_template_id to style the flair
|
print_level(3, f'Flair text: {level_info.current.name}')
|
||||||
print(f'Setting flair text to {levelname}')
|
# TODO
|
||||||
|
# print_level(3, f'Flair template ID: {}')
|
||||||
subreddit.flair.set(solver, text=levelname)
|
subreddit.flair.set(solver, text=levelname)
|
||||||
else:
|
else:
|
||||||
print('Don\'t update flair b/c user is mod')
|
print_level(2, 'Solver is mod; don\'t alter flair')
|
||||||
|
|
||||||
# Don't need to check the rest of the levels
|
|
||||||
break
|
|
||||||
else:
|
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):
|
def marks_as_solved(comment, solved_pattern):
|
||||||
'''Return True if not top-level comment, from OP, contains "!Solved"; False
|
'''Return True if the comment meets the criteria for marking the submission
|
||||||
otherwise.
|
as solved, False otherwise.
|
||||||
'''
|
'''
|
||||||
#comment.refresh() # probably not needed, but the docs are a tad unclear
|
|
||||||
return (not comment.is_root
|
return (not comment.is_root
|
||||||
and comment.is_submitter
|
and comment.is_submitter
|
||||||
and not comment.parent().is_submitter
|
and not comment.parent().is_submitter
|
||||||
and solved_pattern.search(comment.body))
|
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):
|
def print_solution_info(comm):
|
||||||
print('Submission solved')
|
print_level(1, 'Submission solved')
|
||||||
print('\tSolution comment:')
|
print_level(2, 'Solution comment:')
|
||||||
print(f'\t\tAuthor: {comm.parent().author.name}')
|
print_level(3, f'Author: {comm.parent().author.name}')
|
||||||
print(f'\t\tBody: {comm.parent().body}')
|
print_level(3, f'Body: {comm.parent().body}')
|
||||||
print('\t"Solved" comment:')
|
print_level(2, '"Solved" comment:')
|
||||||
print(f'\t\tAuthor: {comm.author.name}')
|
print_level(3, f'Author: {comm.author.name}')
|
||||||
print(f'\t\tBody: {comm.body}')
|
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