diff --git a/.gitignore b/.gitignore index 4c8e409..4371844 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ praw.ini pointsbot.ini +pointsbot.toml *.db *.pyc diff --git a/pointsbot.sample.toml b/pointsbot.sample.toml new file mode 100644 index 0000000..f7431b8 --- /dev/null +++ b/pointsbot.sample.toml @@ -0,0 +1,72 @@ +################################################################################ +# Core +# +# These are the primary fields needed to run the bot. +################################################################################ + +[core] +# The name of the subreddit to monitor, without the "r/" prefix. +subreddit = "" + + +################################################################################ +# Filepaths +# +# Any filepaths needed by the bot. Can be relative or absolute paths, or just +# filenames. +################################################################################ + +[filepaths] +# The name of the SQLite database file to use. +database = "pointsbot.db" + + +################################################################################ +# Reddit Credentials +# +# See the bot documentation for more information about these fields. + +# Some of these fields are sensitive, so once these fields are provided, take +# care not to upload or distribute this file through unsecure channels, or to +# public sites. +################################################################################ + +[credentials] +client_id = "" +client_secret = "" +username = "" +password = "" + + +################################################################################ +# User Levels +# +# These items represent the levels that contributors to the subreddit can +# attain. To add a level, add another section with the format: +# +# [[levels]] +# name = "" +# points = +# flair_template_id = "" +# +# These fields are case-sensitive, so enter the level name exactly as you want +# it to appear. +# +# The flair_template_id field is optional, so it may be omitted or left as the +# empty string, "". +################################################################################ + +[[levels]] +name = "Level One" +points = 1 +flair_template_id = "" + +[[levels]] +name = "Level Two" +points = 25 +flair_template_id = "" + +[[levels]] +name = "Level Three" +points = 100 +flair_template_id = "" diff --git a/pointsbot/bot.py b/pointsbot/bot.py index f88228b..81c44f1 100644 --- a/pointsbot/bot.py +++ b/pointsbot/bot.py @@ -4,16 +4,25 @@ import praw from . import config, database, level, reply +### Globals ### + +USER_AGENT = 'PointsBot (by u/GlipGlorp7)' + +TEST_COMMENTS = False + ### Main Function ### def run(): - cfg = config.load() + cfg = config.Config.load() levels = cfg.levels - # Connect to Reddit - reddit = praw.Reddit(site_name=cfg.praw_site_name) - subreddit = reddit.subreddit(cfg.subreddit_name) + reddit = praw.Reddit(client_id=cfg.client_id, + client_secret=cfg.client_secret, + username=cfg.username, + password=cfg.password, + user_agent=USER_AGENT) + subreddit = reddit.subreddit(cfg.subreddit) print_level(0, f'Connected to Reddit as {reddit.user.me()}') print_level(1, f'Read-only? {reddit.read_only}') @@ -21,6 +30,10 @@ def run(): is_mod = bool(subreddit.moderator(redditor=reddit.user.me())) print_level(1, f'Is mod? {is_mod}') + if TEST_COMMENTS: + make_comments(subreddit, levels) + return + db = database.Database(cfg.database_path) # The pattern that determines whether a post is marked as solved @@ -28,8 +41,8 @@ def run(): solved_pat = re.compile('![Ss]olved') # Monitor new comments for confirmed solutions - # Passing pause_after=0 will bypass the internal exponential delay, but have - # to check if any comments are returned with each query + # Passing pause_after=0 will bypass the internal exponential delay; instead, + # 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 @@ -54,24 +67,22 @@ def run(): 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 - if level_info.current and level_info.current.points == points: - print_level(1, f'User reached level: {level_info.current.name}') + lvl = level_info.current + if lvl and lvl.points == points: + print_level(1, f'User reached level: {lvl.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) + print_level(3, f'Flair text: {lvl.name}') + print_level(3, f'Flair template ID: {lvl.flair_template_id}') + subreddit.flair.set(solver, + text=lvl.name, + flair_template_id=lvl.flair_template_id) else: print_level(2, 'Solver is mod; don\'t alter flair') else: @@ -108,16 +119,6 @@ def is_first_solution(solved_comment, solved_pattern): return True -def find_solver(comment): - # TODO - pass - - -def make_flair(points, levels): - # TODO - pass - - ### Debugging & Logging ### @@ -135,20 +136,7 @@ def print_solution_info(comm): print_level(3, f'Body: {comm.body}') -def make_comments(): - 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}') - +def make_comments(subreddit, levels): testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50)) for sub in subreddit.new(): diff --git a/pointsbot/config.py b/pointsbot/config.py index 7980ab9..f3b967e 100644 --- a/pointsbot/config.py +++ b/pointsbot/config.py @@ -1,53 +1,59 @@ -import configparser import os.path -# from os.path import abspath, dirname, join + +import toml from .level import Level ### Globals ### ROOTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -CONFIGPATH = os.path.join(ROOTPATH, 'pointsbot.ini') - -# TODO add default config values to pass to configparser +CONFIGPATH = os.path.join(ROOTPATH, 'pointsbot.toml') ### 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 + # Default config vals + DEFAULT_DBPATH = 'pointsbot.db' + + def __init__(self, subreddit, client_id, client_secret, username, password, + levels, database_path=DEFAULT_DBPATH): + self.subreddit = subreddit self.database_path = database_path - self.levels = levels if levels is not None else [] + + self.client_id = client_id + self.client_secret = client_secret + self.username = username + self.password = password + + self.levels = levels @classmethod - def from_configparser(cls, config): - database_path = os.path.join(ROOTPATH, config['Core']['database_name']) + def load(cls, filepath=CONFIGPATH): + obj = toml.load(filepath) - # Get the user flair levels in ascending order by point value 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) + for lvl in obj['levels']: + flair_template_id = lvl['flair_template_id'] + if flair_template_id == "": + flair_template_id = None + levels.append(Level(lvl['name'], lvl['points'], flair_template_id)) + levels.sort(key=lambda l: l.points) + + database_path = os.path.join(ROOTPATH, obj['filepaths']['database']) return cls( - praw_site_name=config['Core']['praw_site_name'], - subreddit_name=config['Core']['subreddit_name'], + obj['core']['subreddit'], + obj['credentials']['client_id'], + obj['credentials']['client_secret'], + obj['credentials']['username'], + obj['credentials']['password'], + levels, database_path=database_path, - levels=levels, ) - -### Functions ### - - -def load(): - config = configparser.ConfigParser() - config.read(CONFIGPATH) - return Config.from_configparser(config) + def dump(self, filepath=CONFIGPATH): + pass diff --git a/pointsbot/level.py b/pointsbot/level.py index 37d569a..e4b267a 100644 --- a/pointsbot/level.py +++ b/pointsbot/level.py @@ -3,9 +3,9 @@ from collections import namedtuple ### Data Structures ### # A (string, int) tuple -Level = namedtuple('Level', 'name points') +Level = namedtuple('Level', 'name points flair_template_id') -# A ([Level], Level, Level) tuple; +# 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') @@ -25,8 +25,7 @@ def user_level_info(points, levels): past_levels, cur_level, next_level = [], None, None for level in levels: - lvlname, lvlpoints = level - if points < lvlpoints: + if points < level.points: next_level = level break if cur_level: diff --git a/pointsbot/reply.py b/pointsbot/reply.py index a24f5e1..ff5b8b0 100644 --- a/pointsbot/reply.py +++ b/pointsbot/reply.py @@ -2,19 +2,22 @@ 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 +# Progress bar symbols DIV_SYMBOL = '|' +FILLED_SYMBOL = '\u25AE' # A small filled box character +EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character # Number of "excess" points should be greater than max level points -EXCESS_POINTS = 100 # TODO move this to level? +EXCESS_POINTS = 100 # TODO move this to level? +EXCESS_SYMBOL = '\u2605' # A star character +EXCESS_SYMBOL_TITLE = 'a star' # Used in comment body ### 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: @@ -22,12 +25,22 @@ def make(redditor, points, level_info): level_info.current.name, tag_user=False)) elif points > 1: + user_already_tagged = False + 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(level_up(redditor, + level_info.current.name, + tag_user=(not user_already_tagged))) + user_already_tagged = True + + if points % EXCESS_POINTS == 0: + first_excess = (points == EXCESS_POINTS) + paras.append(new_excess_symbol(redditor, + first_excess=first_excess, + tag_user=(not user_already_tagged))) + user_already_tagged = True + + if not user_already_tagged: paras.append(normal_greeting(redditor)) paras.append(points_status(redditor, points, level_info)) @@ -43,9 +56,8 @@ def header(): def first_greeting(redditor): - msg = (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!') - return msg def normal_greeting(redditor): @@ -58,11 +70,12 @@ def level_up(redditor, level_name, tag_user=True): '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 new_excess_symbol(redditor, first_excess=True, tag_user=True): + # Surrounding spaces for simplicity + tag = f' u/{redditor.name} ' if tag_user else ' ' + num_stars_prefix = ' another ' if not first_excess else ' ' + return (f'Congrats{tag}on getting{num_stars_prefix}{EXCESS_POINTS} points! ' + f'They are shown as {EXCESS_SYMBOL_TITLE} in your progress bar.') def points_status(redditor, points, level_info):