Added config options & basic single-file executable

This commit is contained in:
Collin Rapp
2020-05-10 15:25:20 -07:00
parent fb03e9fff5
commit bdbd343f0c
9 changed files with 197 additions and 112 deletions

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## 2020/05/10
Features:
1. Adding basic initial logging to a file
Fixes: N/A
Miscellaneous:
1. Moved feedback & scoreboard links for bot reply into configuration
2. Changed program entry point to `PointsBot.py`
3. Added ability to freeze the app as a simple executable

View File

@@ -1,3 +1,4 @@
"""Dummy file used for either runnning or freezing the bot.""" """Entry point used for either runnning or freezing the bot."""
import pointsbot import pointsbot
pointsbot.run() pointsbot.run()

View File

@@ -19,19 +19,15 @@ pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher) cipher=block_cipher)
exe = EXE(pyz, exe = EXE(pyz,
a.scripts, a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[], [],
exclude_binaries=True,
name='PointsBot', name='PointsBot',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=True,
console=True )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[], upx_exclude=[],
name='PointsBot') runtime_tmpdir=None,
console=True )

118
README.md
View File

@@ -4,8 +4,8 @@
* [Description](#description) * [Description](#description)
* [Installation](#installation) * [Installation](#installation)
* [Prerequisites & Configuration](#prerequisites--configuration)
* [Usage](#usage) * [Usage](#usage)
* [Setup](#setup)
* [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)
@@ -41,10 +41,21 @@ awarded for each submission.
## Installation ## Installation
Requirements: ### Basic Installation
These are the instructions for simply using the bot without needing to edit the
code. These instructions are best suited for users with less technical
experience.
Go the the [releases page](https://github.com/cur33/PointsBot/releases) for this
project, then download and unzip the latest release. Make sure you pick the
release best suited for your machine & operating system.
### Advanced Installation
Requirements:
* [Python 3](https://www.python.org/downloads/) (specifically version 3.7 or greater) * [Python 3](https://www.python.org/downloads/) (specifically version 3.7 or greater)
* pip (should be installed automatically with Python) * pip (should be installed automatically with Python)
* [pipenv](https://pipenv.readthedocs.io/en/latest/) * [pipenv](https://pipenv.readthedocs.io/en/latest/)
* After installying Python & pip, install by running `pip install pipenv` * After installying Python & pip, install by running `pip install pipenv`
* For other installation options, * For other installation options,
@@ -60,39 +71,7 @@ To uninstall (i.e. delete the project's virtual environment and the installed
python packages), navigate to the project root directory and instead run python packages), navigate to the project root directory and instead run
`pipenv --rm`. `pipenv --rm`.
## Usage ## Prerequisites & Configuration
Once you have followed the instructions in the [Setup](#setup) section below,
the simplest way to run the bot is to navigate to the project root directory and
run:
```bash
pipenv run python -m pointsbot
```
## Setup
### Configuration file
The bot stores any necessary data, including configuration, under the
`.pointsbot` directory in your home directory.
If this is your first time running the bot, you will need to copy
`pointsbot.sample.toml` to a new file called `.pointsbot\pointsbot.toml`,
located in the `.pointsbot` directory mentioned above. Any instances of
the word "REDACTED" should be replaced with the appropriate values; other values
should work as-is, but can be changed as needed.
This is because the config file can contain sensitive information, and
maintaining only sample versions of these files helps developers to avoid
accidentally uploading that sensitive information to a public (or even private)
code repository.
More information on the specific config options can be found in the comments in
the sample config file.
You shouldn't have to worry about it, but if you need it, information on the
TOML syntax used for the file can be found on [Github](https://github.com/toml-lang/toml).
### Bot account ### Bot account
@@ -130,6 +109,73 @@ Some of the bot's behaviors, e.g. altering redditor flairs, require moderator
permissions. It should require just the "Flair" and "Posts" permissions and permissions. It should require just the "Flair" and "Posts" permissions and
perhaps the "Access" permission, so you don't need to grant it full permissions. perhaps the "Access" permission, so you don't need to grant it full permissions.
### Configuration file
The bot will store its configuration file in a `.pointsbot` directory under the
home directory for your user on your machine. By default, this directory is also
where it will store its database and log files, but you can alter this behavior
by specifying other locations for those in the configuration process.
If this is your first time running the bot, then it will fail to detect a
configuration file and will prompt you for the necessary fields. This includes
the credentials for the Reddit account and Reddit app which you created for the
bot in the [Prerequisites & Configuration](#prerequisites--configuration) step
above. The bot will create the configuration file and save this information for
future use.
At the moment, there is not a good way to edit the existing configuration unless
you want to edit the configuration file yourself (see next paragraph). This is
the recommended process for editing the configuration until this feature is
added:
1. Go to your home directory and open the `.pointsbot` directory.
2. Either rename the `pointsbot.toml` file to something else, or move it to a
different directory.
3. Open that file in a text editor (if you haven't installed one, you can use
the default text editor for your operating system, like Notepad on Windows).
4. Run the bot and walk through the configuration process again, copying and
pasting any unchanged values from the original configuration file.
5. Once you are finished, you can either keep the old configuration file for a
while just in case, or you can delete it.
If you'd prefer, you can instead create and edit the configuration file
yourself. You will need to copy `pointsbot.sample.toml` to a new file called
`pointsbot.toml` located in the `.pointsbot` directory mentioned above. Any
instances of the word "REDACTED" should be replaced with the appropriate values;
other values should work as-is, but can be changed as needed.
This is because the config file can contain sensitive information, and
maintaining only sample versions of these files in this repository helps
developers to avoid accidentally uploading that sensitive information to a
public (or even private) code repository.
More information on the specific config options can be found in the comments in
the sample config file.
You shouldn't have to worry about it, but if you need it, information on the
TOML syntax used for the file can be found on
[TOML's Github page](https://github.com/toml-lang/toml).
## Usage
### Basic Usage
Follow these instructions if you downloaded the bot from the releases page in
the [Installation](#installation) step above.
In the unzipped folder, double-click on the `PointsBot.exe` file. It will open a
command prompt that will ask you for any additional information it may require.
You will *not* need any knowledge of the command prompt for your operating
system to interact with the bot.
### Advanced Usage
The simplest way to run the bot is to navigate to the project root directory and
run:
```bash
pipenv run python PointsBot.py
```
## 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

View File

@@ -1,13 +1,14 @@
@echo off @echo off
REM The below is an alternative to using a custom hook for praw
REM FOR /F "tokens=* USEBACKQ" %%F IN (`pipenv --venv`) DO ( REM FOR /F "tokens=* USEBACKQ" %%F IN (`pipenv --venv`) DO (
REM SET pipenvdir=%%F REM SET pipenvdir=%%F
REM ) REM )
REM --add-data "%pipenvdir%\Lib\site-packages\praw\praw.ini;site-packages\praw\praw.ini" ^ REM --add-data "%pipenvdir%\Lib\site-packages\praw\praw.ini;site-packages\praw\praw.ini" ^
REM --noconfirm ^ REM using the --noconfirm option sometimes causes issues when rebuilding
REM (ie when it tries to delete the previous dist directory)
pyinstaller ^ pyinstaller ^
--onedir ^ --onefile ^
--additional-hooks-dir .\pyinstaller-hooks\ ^ --additional-hooks-dir .\pyinstaller-hooks\ ^
PointsBot.py PointsBot.py

View File

@@ -1,4 +0,0 @@
from . import bot
if __name__ == '__main__':
bot.run()

View File

@@ -13,9 +13,9 @@ from . import config, database, level, reply
USER_AGENT = 'PointsBot (by u/GlipGlorp7)' USER_AGENT = 'PointsBot (by u/GlipGlorp7)'
# TODO put this in config # TODO put this in config
LOG_FILEPATH = os.path.abspath(os.path.join(os.path.expanduser('~'), # LOG_FILEPATH = os.path.abspath(os.path.join(os.path.expanduser('~'),
'.pointsbot', # '.pointsbot',
'pointsbot.log.txt')) # 'pointsbot.log'))
# The pattern that determines whether a post is marked as solved # The pattern that determines whether a post is marked as solved
# Could also use re.IGNORECASE flag instead # Could also use re.IGNORECASE flag instead
@@ -26,14 +26,13 @@ MOD_SOLVED_PAT = re.compile('/[Ss]olved')
def run(): def run():
logging.basicConfig(filename=LOG_FILEPATH,
level=logging.DEBUG,
format='%(asctime)s %(module)s:%(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
print_welcome_message() print_welcome_message()
cfg = config.load() cfg = config.load()
logging.basicConfig(filename=cfg.log_path,
level=logging.DEBUG,
format='%(asctime)s %(levelname)s:%(module)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
levels = cfg.levels levels = cfg.levels
db = database.Database(cfg.database_path) db = database.Database(cfg.database_path)
@@ -78,46 +77,43 @@ def monitor_comments(subreddit, db, levels):
logging.info('Received comment') logging.info('Received comment')
# TODO more debug info about comment, eg author # TODO more debug info about comment, eg author
logging.debug('Comment text: "%s"', comm.body) logging.debug('Comment text: "%s"', comm.body)
# print_level(0, '\nFound comment')
# print_level(1, f'Comment text: "{comm.body}"')
if not marks_as_solved(comm): if not marks_as_solved(comm):
logging.info('Comment does not mark issue as solved') logging.info('Comment does not mark issue as solved')
# print_level(1, 'Not a "![Ss]olved" comment')
continue continue
logging.info('Comment marks issues as solved') logging.info('Comment marks issues as solved')
if is_mod_comment(comm): if is_mod_comment(comm):
logging.info('Comment was submitted by mod') logging.info('Comment was submitted by mod')
# print_level(1, 'Mod comment')
elif not is_first_solution(comm): elif not is_first_solution(comm):
# Skip this "!solved" comment # Skip this "!solved" comment
logging.info('Comment is NOT the first to mark the issue as solved') logging.info('Comment is NOT the first to mark the issue as solved')
# print_level(1, 'Not the first solution')
continue continue
logging.info('Comment is the first to mark the issue as solved') logging.info('Comment is the first to mark the issue as solved')
# print_level(1, 'This is the first solution found')
log_solution_info(comm) log_solution_info(comm)
solver = find_solver(comm) solver = find_solver(comm)
db.add_point(solver) db.add_point(solver)
logging.info('Added point for user "%s"', solver.name) logging.info('Added point for user "%s"', solver.name)
# print_level(1, f'Added point for {solver.name}')
points = db.get_points(solver) points = db.get_points(solver)
logging.info('Total points for user "%s": %d', solver.name, points) logging.info('Total points for user "%s": %d', solver.name, points)
# print_level(1, f'Total points for {solver.name}: {points}')
level_info = level.user_level_info(points, levels) level_info = level.user_level_info(points, levels)
# Reply to the comment marking the submission as solved # Reply to the comment marking the submission as solved
reply_body = reply.make(solver, points, level_info) reply_body = reply.make(
solver,
points,
level_info,
feedback_url=cfg.feedback_url,
scoreboard_url=cfg.scoreboard_url
)
try: try:
comm.reply(reply_body) comm.reply(reply_body)
logging.info('Replied to the comment') logging.info('Replied to the comment')
logging.debug('Reply body: %s', reply_body) logging.debug('Reply body: %s', reply_body)
# print_level(1, f'Replied to comment with: "{reply_body}"')
except praw.exceptions.APIException as e: except praw.exceptions.APIException as e:
logging.error('Unable to reply to comment: %s', e) logging.error('Unable to reply to comment: %s', e)
db.remove_point(solver) db.remove_point(solver)
@@ -130,7 +126,6 @@ def monitor_comments(subreddit, db, levels):
if lvl and lvl.points == points: if lvl and lvl.points == points:
logging.info('User reached level: %s', lvl.name) logging.info('User reached level: %s', lvl.name)
if not subreddit.moderator(redditor=solver): if not subreddit.moderator(redditor=solver):
# print_level(2, 'Setting flair')
logging.info('User is not mod; setting flair') logging.info('User is not mod; setting flair')
logging.info('Flair text: %s', lvl.name) logging.info('Flair text: %s', lvl.name)
logging.info('Flair template ID: %s', lvl.flair_template_id) logging.info('Flair template ID: %s', lvl.flair_template_id)
@@ -208,10 +203,7 @@ def print_welcome_message():
'sent to the developer by reporting an issue on the Github page.') 'sent to the developer by reporting an issue on the Github page.')
print('\nFuture updates will hopefully resolve these issues, but for the ' print('\nFuture updates will hopefully resolve these issues, but for the '
"moment, this is what we've got to work with! :)\n") "moment, this is what we've got to work with! :)\n")
print_separator_line()
def print_level(num_levels, string):
print('\t' * num_levels + string)
def log_solution_info(comm): def log_solution_info(comm):

View File

@@ -13,30 +13,57 @@ DATADIR = os.path.join(os.path.expanduser('~'), '.pointsbot')
CONFIGPATH = os.path.join(DATADIR, 'pointsbot.toml') CONFIGPATH = os.path.join(DATADIR, 'pointsbot.toml')
# Path to the sample config file # Path to the sample config file
# Unused for now
SAMPLEPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), SAMPLEPATH = os.path.abspath(os.path.join(os.path.dirname(__file__),
'..', '..',
'pointsbot.sample.toml')) 'pointsbot.sample.toml'))
### Primary Functions ###
def load(filepath=CONFIGPATH):
# Prompt user for config values if file doesn't exist
if not os.path.exists(filepath):
datadir = os.path.dirname(filepath)
if not os.path.exists(datadir):
os.makedirs(datadir)
interactive_config(filepath)
return Config.from_toml(filepath)
### Classes ### ### Classes ###
class Config: class Config:
# Default config vals # Default config vals
DEFAULT_DBNAME = 'pointsbot.db' DEFAULT_DB_NAME = 'pointsbot.db'
DEFAULT_LOG_NAME = 'pointsbot.log'
def __init__(self, filepath, subreddit, client_id, client_secret, username, def __init__(self, filepath, subreddit, client_id, client_secret, username,
password, levels, database_path=None): password, levels, database_path=None, log_path=None,
feedback_url=None, scoreboard_url=None):
self._filepath = filepath self._filepath = filepath
self._dirname = os.path.dirname(filepath) self._dirname = os.path.dirname(filepath)
if not log_path:
log_path = os.path.join(self._dirname, self.DEFAULT_LOG_NAME)
elif os.path.isdir(log_path):
log_path = os.path.join(log_path, self.DEFAULT_LOG_NAME)
self.log_path = log_path
# TODO init logging here so it can be used immediately?
if not database_path: if not database_path:
database_path = os.path.join(self._dirname, self.DEFAULT_DBNAME) database_path = os.path.join(self._dirname, self.DEFAULT_DB_NAME)
elif os.path.isdir(database_path): elif os.path.isdir(database_path):
database_path = os.path.join(database_path, self.DEFAULT_DBNAME) database_path = os.path.join(database_path, self.DEFAULT_DB_NAME)
self.database_path = database_path self.database_path = database_path
self.subreddit = subreddit self.subreddit = subreddit
self.feedback_url = feedback_url
self.scoreboard_url = scoreboard_url
self.client_id = client_id self.client_id = client_id
self.client_secret = client_secret self.client_secret = client_secret
@@ -62,6 +89,10 @@ class Config:
if dbpath: if dbpath:
dbpath = os.path.abspath(os.path.expandvars(os.path.expanduser(dbpath))) dbpath = os.path.abspath(os.path.expandvars(os.path.expanduser(dbpath)))
logpath = obj['filepaths']['log']
if logpath:
logpath = os.path.abspath(os.path.expandvars(os.path.expanduser(logpath)))
return cls( return cls(
filepath, filepath,
obj['core']['subreddit'], obj['core']['subreddit'],
@@ -71,12 +102,15 @@ class Config:
obj['credentials']['password'], obj['credentials']['password'],
levels, levels,
database_path=dbpath, database_path=dbpath,
log_path=logpath,
feedback_url=obj['links']['feedback'],
scoreboard_url=obj['links']['scoreboard'],
) )
def save(self): def save(self):
obj = deepcopy(vars(self)) obj = deepcopy(vars(self))
orig_levels = obj['levels'] orig_levels, obj['levels'] = obj['levels'], []
obj['levels'] = [] # obj['levels'] = []
for level in orig_levels: for level in orig_levels:
obj['levels'].append({ obj['levels'].append({
'name': level.name, 'name': level.name,
@@ -88,20 +122,6 @@ class Config:
toml.dump(obj, f) toml.dump(obj, f)
### Functions ###
def load(filepath=CONFIGPATH):
# Prompt user for config values if file doesn't exist
if not os.path.exists(filepath):
datadir = os.path.dirname(filepath)
if not os.path.exists(datadir):
os.makedirs(datadir)
interactive_config(filepath)
return Config.from_toml(filepath)
### Interactive Config Editing ### ### Interactive Config Editing ###
@@ -113,14 +133,25 @@ def interactive_config(dest):
'levels': [], 'levels': [],
} }
print('#' * 80 + '\nCONFIGURING THE BOT\n' + '#' * 80) print('\n' + ('#' * 80) + '\nCONFIGURING THE BOT\n' + ('#' * 80) + '\n')
print('\nType a value for each field, then press enter.') print('Type a value for each field, then press enter.')
print('\nIf a field is specified as optional, then you can skip it by just ' print('\nIf a field is specified as optional, then you can skip it by just '
'pressing enter.\n') 'pressing enter.')
print("\nIt is recommended that you skip any fields that you aren't sure "
'about')
print('\n*** Core Configuration ***\n')
configvals['core']['subreddit'] = input('name of subreddit to monitor? ') configvals['core']['subreddit'] = input('name of subreddit to monitor? ')
print() print()
configvals['filepaths']['database'] = input('database filename? (optional) ') configvals['filepaths']['database'] = input('database filepath? (optional) ')
configvals['filepaths']['log'] = input('log filepath? (optional) ')
print('\n*** Website Links ***\n')
print('These values should only be provided if you have valid URLs for '
'websites that provide these services for your subreddit.\n')
configvals['links']['feedback'] = input('feedback webpage URL? (optional) ')
configvals['links']['scoreboard'] = input('scoreboard webpage URL? (optional) ')
print('\n*** Bot account details ***\n') print('\n*** Bot account details ***\n')
configvals['credentials']['client_id'] = input('client_id? ') configvals['credentials']['client_id'] = input('client_id? ')
configvals['credentials']['client_secret'] = input('client_secret? ') configvals['credentials']['client_secret'] = input('client_secret? ')
@@ -144,18 +175,16 @@ def interactive_config(dest):
print('\nFor any more questions, please refer to the README on the Github ' print('\nFor any more questions, please refer to the README on the Github '
'page.') 'page.')
# add_another_level = True
response = 'y' response = 'y'
while response.lower().startswith('y'): while response.lower().startswith('y'):
print('\n*** Adding a level ***') print('\n*** Adding a level ***\n')
level = {} level = {}
level['name'] = input('\nLevel name? ') level['name'] = input('Level name? ')
level['points'] = int(input('Level points? ')) level['points'] = int(input('Level points? '))
level['flair_template_id'] = input('Flair template ID? (optional) ') level['flair_template_id'] = input('Flair template ID? (optional) ')
configvals['levels'].append(level) configvals['levels'].append(level)
response = input('\nAdd another level? (y/n) ') response = input('\nAdd another level? (y/n) ')
# add_another_level = response.lower().startswith('y')
with open(dest, 'w') as f: with open(dest, 'w') as f:
toml.dump(configvals, f) toml.dump(configvals, f)

View File

@@ -8,14 +8,14 @@ FILLED_SYMBOL = '\u25AE' # A small filled box character
EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character
# Number of "excess" points should be greater than max level points # 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 and/or config?
EXCESS_SYMBOL = '\u2605' # A star character EXCESS_SYMBOL = '\u2605' # A star character
EXCESS_SYMBOL_TITLE = 'a star' # Used in comment body EXCESS_SYMBOL_TITLE = 'a star' # Used in comment body
### Main Functions ### ### Main Functions ###
def make(redditor, points, level_info): def make(redditor, points, level_info, feedback_url=None, scoreboard_url=None):
paras = [header()] paras = [header()]
if points == 1: if points == 1:
@@ -45,7 +45,7 @@ def make(redditor, points, level_info):
paras.append(points_status(redditor, points, level_info)) paras.append(points_status(redditor, points, level_info))
paras.append(divider()) paras.append(divider())
paras.append(footer()) paras.append(footer(feedback_url=feedback_url, scoreboard_url=scoreboard_url))
return '\n\n'.join(paras) return '\n\n'.join(paras)
@@ -130,10 +130,21 @@ def divider():
return '***' return '***'
def footer(): def footer(feedback_url=None, scoreboard_url=None):
return ('^(Bot maintained by GlipGlorp7 ' footer_sections = ['Bot maintained by GlipGlorp7']
'| [Scoreboard](https://points.minecrafthelp.co.uk) ' if scoreboard_url:
'| [Feedback](https://forms.gle/m94aGjFQwGopqQ836) ' # https://points.minecrafthelp.co.uk
'| [Source Code](https://github.com/cur33/PointsBot))') footer_sections.append(f'[Scoreboard]({scoreboard_url})')
if feedback_url:
# https://forms.gle/m94aGjFQwGopqQ836
footer_sections.append(f'[Feedback]({feedback_url})')
footer_sections.append('[Source Code](https://github.com/cur33/PointsBot)')
return '^(' + ' | '.join(footer_sections) + ')'
# return ('^(Bot maintained by GlipGlorp7 '
# '| [Scoreboard](https://points.minecrafthelp.co.uk) '
# '| [Feedback](https://forms.gle/m94aGjFQwGopqQ836) '
# '| [Source Code](https://github.com/cur33/PointsBot))')