diff --git a/.gitignore b/.gitignore index 8f7e103..3117056 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ *.pyc __pycache__ +build/ +dist/ + *.html *.out *.swp diff --git a/Pipfile b/Pipfile index 9a38135..cf9fdec 100644 --- a/Pipfile +++ b/Pipfile @@ -4,6 +4,7 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] +pyinstaller = "*" [packages] toml = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 1177136..f8556e2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c5de2f495184b77e04ef9eeabbf802ca765c0a28bc36b8e6f84da0dcac053224" + "sha256": "d2ab36131af79e2a9efe43cbaf017a804bbf43d83863ec9ff3cba1987535ee30" }, "pipfile-spec": 6, "requires": { @@ -18,10 +18,10 @@ "default": { "certifi": { "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" ], - "version": "==2019.11.28" + "version": "==2020.4.5.1" }, "chardet": { "hashes": [ @@ -32,32 +32,33 @@ }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], - "version": "==2.8" + "version": "==2.9" }, "praw": { "hashes": [ - "sha256:252246f8ea2ae6fba59bbf45de3fed568a80c086bca66747a2745dff5e11df4a", - "sha256:544904cb821afff43c22e2dad4245658d41135d84b3a9463a5e29dd132da6efe" + "sha256:65129169d560800261908415ed955f3cbc63648549820b3ccce0a823ffa2fd78", + "sha256:74e4b6c3f206342d05272ce1770ac7b9c48207c9a7ffea3d5251460b70f18188", + "sha256:dcdcf13b7f7ae2393afd914644bf16b254eaf5230c81adf2feafe1ec514307ca" ], "index": "pypi", - "version": "==6.5.1" + "version": "==7.0.0" }, "prawcore": { "hashes": [ - "sha256:25dd14bf121bc0ad2ffc78e2322d9a01a516017105a5596cc21bb1e9a928b40c", - "sha256:ab5558efb438aa73fc66c4178bfc809194dea3ce2addf4dec873de7e2fd2824e" + "sha256:a982a49bc911fe0e3a9751319091c380f79d5e1ba1ba19cb8dbbce21ad8b0ca7", + "sha256:b907843ab969d759cbc03f1f749acea24d11859d6aed447b2fa1cd0eda9ecf34" ], - "version": "==1.0.1" + "version": "==1.3.0" }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" ], - "version": "==2.22.0" + "version": "==2.23.0" }, "six": { "hashes": [ @@ -76,17 +77,17 @@ }, "update-checker": { "hashes": [ - "sha256:59cfad7f9a0ee99f95f1dfc60f55bf184937bcab46a7270341c2c33695572453", - "sha256:70e39446fccf77b21192cf7a8214051fa93a636dc3b5c8b602b589d100a168b8" + "sha256:1ff5dc7aab340b4f7710bd6c69d08ff5a5351617cd4ba0eb8886ddb285e2104f", + "sha256:2def8db7f63bd45c7d19df5df570f3f3dfeb1a1f050869d7036529295db10e62" ], - "version": "==0.16" + "version": "==0.17" }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "version": "==1.25.8" + "version": "==1.25.9" }, "websocket-client": { "hashes": [ @@ -96,5 +97,39 @@ "version": "==0.57.0" } }, - "develop": {} + "develop": { + "altgraph": { + "hashes": [ + "sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa", + "sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe" + ], + "version": "==0.17" + }, + "future": { + "hashes": [ + "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" + ], + "version": "==0.18.2" + }, + "pefile": { + "hashes": [ + "sha256:a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645" + ], + "version": "==2019.4.18" + }, + "pyinstaller": { + "hashes": [ + "sha256:3730fa80d088f8bb7084d32480eb87cbb4ddb64123363763cf8f2a1378c1c4b7" + ], + "index": "pypi", + "version": "==3.6" + }, + "pywin32-ctypes": { + "hashes": [ + "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", + "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98" + ], + "version": "==0.2.0" + } + } } diff --git a/PointsBot.py b/PointsBot.py new file mode 100644 index 0000000..ff559cd --- /dev/null +++ b/PointsBot.py @@ -0,0 +1,3 @@ +"""Dummy file used for either runnning or freezing the bot.""" +import pointsbot +pointsbot.run() diff --git a/PointsBot.spec b/PointsBot.spec new file mode 100644 index 0000000..b8edd57 --- /dev/null +++ b/PointsBot.spec @@ -0,0 +1,37 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + + +a = Analysis(['PointsBot.py'], + pathex=['C:\\Users\\Collin\\Documents\\git\\PointsBot'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=['.\\pyinstaller-hooks\\'], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='PointsBot', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True ) +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='PointsBot') diff --git a/freeze.cmd b/freeze.cmd new file mode 100644 index 0000000..f62f5b9 --- /dev/null +++ b/freeze.cmd @@ -0,0 +1,13 @@ +@echo off + +REM FOR /F "tokens=* USEBACKQ" %%F IN (`pipenv --venv`) DO ( + REM SET pipenvdir=%%F +REM ) + +REM --add-data "%pipenvdir%\Lib\site-packages\praw\praw.ini;site-packages\praw\praw.ini" ^ + +REM --noconfirm ^ +pyinstaller ^ + --onedir ^ + --additional-hooks-dir .\pyinstaller-hooks\ ^ + PointsBot.py diff --git a/freeze.sh b/freeze.sh new file mode 100644 index 0000000..8d5eef0 --- /dev/null +++ b/freeze.sh @@ -0,0 +1,5 @@ +pyinstaller \ + --noconfirm \ + --onedir \ + --additional-hooks-dir ./pyinstaller-hooks/ \ + PointsBot.py diff --git a/pointsbot/bot.py b/pointsbot/bot.py index 2ad96e9..f3f4a0b 100644 --- a/pointsbot/bot.py +++ b/pointsbot/bot.py @@ -1,3 +1,6 @@ +import logging +import os +import os.path import re import praw @@ -9,8 +12,13 @@ from . import config, database, level, reply USER_AGENT = 'PointsBot (by u/GlipGlorp7)' +# TODO put this in config +LOG_FILEPATH = os.path.abspath(os.path.join(os.path.expanduser('~'), + '.pointsbot', + 'pointsbot.log.txt')) + # The pattern that determines whether a post is marked as solved -# Could also just use re.IGNORECASE flag +# Could also use re.IGNORECASE flag instead SOLVED_PAT = re.compile('![Ss]olved') MOD_SOLVED_PAT = re.compile('/[Ss]olved') @@ -18,6 +26,13 @@ MOD_SOLVED_PAT = re.compile('/[Ss]olved') 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() + cfg = config.load() levels = cfg.levels db = database.Database(cfg.database_path) @@ -30,21 +45,26 @@ def run(): username=cfg.username, password=cfg.password, user_agent=USER_AGENT) - subreddit = reddit.subreddit(cfg.subreddit) + logging.info('Connected to Reddit as %s', reddit.user.me()) + if not reddit.read_only: + logging.info('Has write access to Reddit') + else: + logging.info('Has read-only access to Reddit') - 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}') + subreddit = reddit.subreddit(cfg.subreddit) + logging.info('Watching subreddit %s', subreddit.title) + if subreddit.moderator(redditor=reddit.user.me()): + logging.info('Is moderator for monitored subreddit') + else: + logging.warning('Is NOT moderator for monitored subreddit') monitor_comments(subreddit, db, levels) # Ignoring other potential exceptions for now, since we may not be able # to recover from them as well as from this one except prawcore.exceptions.RequestException as e: - print('Unable to connect; attempting again....') + log.error('Unable to connect; attempting again....') except prawcore.exceptions.ServerError as e: - print('Lost connection to Reddit; attempting to reconnect....') + log.error('Lost connection to Reddit; attempting to reconnect....') def monitor_comments(subreddit, db, levels): @@ -55,57 +75,70 @@ def monitor_comments(subreddit, db, levels): if comm is None: continue - print_level(0, '\nFound comment') - print_level(1, f'Comment text: "{comm.body}"') + logging.info('Received comment') + # TODO more debug info about comment, eg author + 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): - print_level(1, 'Not a "![Ss]olved" comment') + logging.info('Comment does not mark issue as solved') + # print_level(1, 'Not a "![Ss]olved" comment') continue + logging.info('Comment marks issues as solved') + if is_mod_comment(comm): - print_level(1, 'Mod comment') + logging.info('Comment was submitted by mod') + # print_level(1, 'Mod comment') elif not is_first_solution(comm): - # Skip this "!solved" comment and wait for the next - print_level(1, 'Not the first solution') + # Skip this "!solved" comment + logging.info('Comment is NOT the first to mark the issue as solved') + # print_level(1, 'Not the first solution') continue - print_level(1, 'This is the first solution found') - print_solution_info(comm) + 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) solver = find_solver(comm) db.add_point(solver) - print_level(1, f'Added point for {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) - print_level(1, f'Total points for {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) # Reply to the comment marking the submission as solved reply_body = reply.make(solver, points, level_info) try: comm.reply(reply_body) - print_level(1, f'Replied to comment with: "{reply_body}"') + logging.info('Replied to the comment') + logging.debug('Reply body: %s', reply_body) + # print_level(1, f'Replied to comment with: "{reply_body}"') except praw.exceptions.APIException as e: - print_level(1, 'Unable to reply to comment') - print_level(2, f'{e}') + logging.error('Unable to reply to comment: %s', e) db.remove_point(solver) - print_level(1, f'Removed point awarded to {solver.name}') - print_level(1, 'Skipping comment') + logging.error('Removed point that was just awarded to user "%s"', solver.name) + logging.error('Skipping comment') continue # Check if (non-mod) user flair should be updated to new level lvl = level_info.current if lvl and lvl.points == points: - print_level(1, f'User reached level: {lvl.name}') + logging.info('User reached level: %s', lvl.name) if not subreddit.moderator(redditor=solver): - print_level(2, 'Setting flair') - print_level(3, f'Flair text: {lvl.name}') - print_level(3, f'Flair template ID: {lvl.flair_template_id}') + # print_level(2, 'Setting flair') + logging.info('User is not mod; setting flair') + logging.info('Flair text: %s', lvl.name) + logging.info('Flair template ID: %s', 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') + logging.info('Solver is mod; don\'t alter flair') ### Reddit Comment Functions ### @@ -154,20 +187,40 @@ def find_solver(solved_comment): return solved_comment.parent().author -### Debugging & Logging ### +### Print Functions ### + + +def print_separator_line(): + print('#' * 80) + + +def print_welcome_message(): + print_separator_line() + print('\n*** Welcome to PointsBot! ***\n') + print_separator_line() + print('\nThis bot will monitor the subreddit specified in the ' + 'configuration file as long as this program is running.') + print('\nAny Reddit activity that occurs while this program is not running ' + 'will be missed. You can work around this by using features ' + 'mentioned in the README.') + print('\nThe output from this program can be referenced if any issues are ' + 'to occur, and the relevant error message or crash report can be ' + 'sent to the developer by reporting an issue on the Github page.') + print('\nFuture updates will hopefully resolve these issues, but for the ' + "moment, this is what we've got to work with! :)\n") def print_level(num_levels, string): print('\t' * num_levels + string) -def print_solution_info(comm): - 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}') +def log_solution_info(comm): + logging.info('Submission solved') + logging.debug('Solution comment:') + logging.debug('Author: %s', comm.parent().author.name) + logging.debug('Body: %s', comm.parent().body) + logging.debug('"Solved" comment:') + logging.debug('Author: %s', comm.author.name) + logging.debug('Body: %s', comm.body) diff --git a/pointsbot/config.py b/pointsbot/config.py index 2aef8bb..e8dac1d 100644 --- a/pointsbot/config.py +++ b/pointsbot/config.py @@ -115,19 +115,39 @@ def interactive_config(dest): print('#' * 80 + '\nCONFIGURING THE BOT\n' + '#' * 80) print('\nType a value for each field, then press enter.') - print('\nIf the field is specified as optional, leave blank to skip.\n') + print('\nIf a field is specified as optional, then you can skip it by just ' + 'pressing enter.\n') - configvals['core']['subreddit'] = input('subreddit? ') + configvals['core']['subreddit'] = input('name of subreddit to monitor? ') print() configvals['filepaths']['database'] = input('database filename? (optional) ') - print() + print('\n*** Bot account details ***\n') configvals['credentials']['client_id'] = input('client_id? ') configvals['credentials']['client_secret'] = input('client_secret? ') - configvals['credentials']['username'] = input('username? ') - configvals['credentials']['password'] = input('password? ') + configvals['credentials']['username'] = input('bot username? ') + configvals['credentials']['password'] = input('bot password? ') - add_another_level = True - while add_another_level: + print('\n*** Flair Levels ***\n') + print('These fields will determine the different levels that your ' + 'subreddit users can achieve by earning points.') + print('\nFor each level, you should provide...') + print("\t- Level name: the text that appears in the user's flair") + print('\t- Level points: the number of points needed to reach the level') + print('\t- Flair template ID: (optional) the flair template ID in your') + print('\t subreddit to be used for this level flair') + print('\nThese may be provided in any order; the bot will sort them later.') + print('\nDo not provide more than one level with the same number of points.') + print('\nNote that at the moment, providing a level points value of zero ' + 'will not set a default flair, because users must solve at least one ' + 'issue before the bot will keep track of their points and set their ' + 'flair for the first time.') + print('\nFor any more questions, please refer to the README on the Github ' + 'page.') + + # add_another_level = True + response = 'y' + while response.lower().startswith('y'): + print('\n*** Adding a level ***') level = {} level['name'] = input('\nLevel name? ') level['points'] = int(input('Level points? ')) @@ -135,7 +155,7 @@ def interactive_config(dest): configvals['levels'].append(level) response = input('\nAdd another level? (y/n) ') - add_another_level = response.lower().startswith('y') + # add_another_level = response.lower().startswith('y') with open(dest, 'w') as f: toml.dump(configvals, f) diff --git a/pyinstaller-hooks/hook-praw.py b/pyinstaller-hooks/hook-praw.py new file mode 100644 index 0000000..706a285 --- /dev/null +++ b/pyinstaller-hooks/hook-praw.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files('praw')