Initial commit

This commit is contained in:
Collin R
2020-01-15 11:07:13 -08:00
commit 3f2d754756
13 changed files with 1468 additions and 0 deletions

18
pointsbot/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
'''
A bot for Reddit to award points to helpful subreddit members.
Copyright (C) 2020 Collin U. Rapp
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
from .bot import run

21
pointsbot/__main__.py Normal file
View File

@@ -0,0 +1,21 @@
'''
A bot for Reddit to award points to helpful subreddit members.
Copyright (C) 2020 Collin U. Rapp
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
from . import bot
if __name__ == '__main__':
bot.run()

160
pointsbot/bot.py Normal file
View File

@@ -0,0 +1,160 @@
'''
A bot for Reddit to award points to helpful subreddit members.
Copyright (C) 2020 Collin U. Rapp
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
import re
import praw
from . import comment, database
### Globals ###
# SUBREDDIT_NAME = 'MinecraftHelp'
SUBREDDIT_NAME = 'GlipGlorp7BotTests'
PRAW_SITE_NAME = 'testbot'
# 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 = [
('Helper', 5),
('Trusted Helper', 15),
('Super Helper', 40),
]
### Main Function ###
def run():
# Connect to Reddit
reddit = praw.Reddit(site_name=PRAW_SITE_NAME)
subreddit = reddit.subreddit(SUBREDDIT_NAME)
# xdebugx
print(f'Connected to reddit as {reddit.user.me()}')
print(f'Read-only? {reddit.read_only}')
print(f'Watching subreddit {subreddit.title}')
print(f'Mod? {bool(subreddit.moderator(redditor=reddit.user.me()))}')
database.init()
# Initialize database
#if not database.exists():
#database.create()
# The pattern to look for in comments when determining whether to award a point
# solved_pat = re.compile('!solved', re.IGNORECASE)
solved_pat = re.compile('![Ss]olved')
# Monitor new comments for confirmed solutions
for comm in subreddit.stream.comments(skip_existing=True):
# xdebugx
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.is_submitter
# and subcomm.id != comm.id
# and not subcomm.is_root
# and solved_pat.search(subcomm.body)
# and subcomm.created_utc < comm.created_utc):
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
# xdebugx
print('This is not the first solution')
continue
# xdebugx
print('This is the first solution')
print_solution_info(comm)
solution = comm.parent()
solver = solution.author
print(f'Adding point for {solver.name}')
database.add_point(solver)
points = database.get_redditor_points(solver)
print(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)
# 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:
# xdebugx
print('Don\'t update flair b/c user is mod')
# Don't need to check the rest of the levels
break
else:
# xdebugx
print('Not a "!solved" comment')
### Auxiliary Functions ###
def marks_as_solved(comment, solved_pattern):
'''Return True if not top-level comment, from OP, contains "!Solved"; False
otherwise.
'''
#comment.refresh()
return (not comment.is_root
and comment.is_submitter
and solved_pattern.search(comment.body))
### Debugging ###
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}')

110
pointsbot/comment.py Normal file
View File

@@ -0,0 +1,110 @@
'''
A bot for Reddit to award points to helpful subreddit members.
Copyright (C) 2020 Collin U. Rapp
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
### 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) + ']'

121
pointsbot/database.py Normal file
View File

@@ -0,0 +1,121 @@
'''
A bot for Reddit to award points to helpful subreddit members.
Copyright (C) 2020 Collin U. Rapp
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
import functools
import os.path
import sqlite3 as sqlite
### Globals ###
# TODO put name/path in a config file?
DB_NAME = 'pointsbot.db'
DB_PATH = DB_NAME
DB_SCHEMA = '''
CREATE TABLE IF NOT EXISTS redditor_points (
id TEXT UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL,
points INTEGER DEFAULT 0
)
'''
### Decorators ###
def transaction(func):
@functools.wraps(func)
def newfunc(*args, **kwargs):
conn = sqlite.connect(DB_PATH)
conn.row_factory = sqlite.Row
cursor = conn.cursor()
ret = func(cursor, *args, **kwargs)
cursor.close()
if conn.in_transaction:
conn.commit()
conn.close()
return ret
return newfunc
### Private Functions ###
# These functions are intended for internal use, since they need to be explicity
# passed a database cursor. The public methods below are wrapped with the
# transaction decorator to remove the need for keeping a connection or cursor
# opened for the entire life of the program.
def _init(cursor):
if not exists():
cursor.execute(DB_SCHEMA)
def _add_redditor(cursor, redditor):
insert_stmt = '''
INSERT OR IGNORE INTO redditor_points (id, name)
VALUES (:id, :name)
'''
cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name})
return cursor.rowcount
def _add_point(cursor, redditor):
points = _get_redditor_points(cursor, redditor, add_if_none=True)
params = {'id': redditor.id, 'name': redditor.name, 'points': points + 1}
update_stmt = '''
UPDATE redditor_points
SET points = :points
WHERE id = :id AND name = :name
'''
cursor.execute(update_stmt, params)
return cursor.rowcount
def _get_redditor_points(cursor, redditor, add_if_none=False):
params = {'id': redditor.id, 'name': redditor.name}
select_stmt = '''
SELECT points
FROM redditor_points
WHERE id = :id AND name = :name
'''
cursor.execute(select_stmt, params)
row = cursor.fetchone() # TODO check if more than one row
if row:
points = row['points']
elif add_if_none:
points = 0
_add_redditor(cursor, redditor)
return points
### Public Functions ###
def exists():
return os.path.exists(DB_PATH)
# Public wrappers for the database access functions
init = transaction(_init)
add_redditor = transaction(_add_redditor)
add_point = transaction(_add_point)
get_redditor_points = transaction(_get_redditor_points)