diff --git a/README.md b/README.md index 56cb7c9..c32cdb0 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ This will create the following wiki pages (customizable in default_wiki_config.j # The imported config from default_wiki_config.json /wiki/twitchbot_config - # A list of banned twitch.tv usernames seperated by newlines + # A list of banned twitch.tv usernames separated by newlines /wiki/banned_streams - # A list of twitch.tv usernames seperated by newlines + # A list of twitch.tv usernames separated by newlines /wiki/streams setup.py will also print out a string for you to put into your sidebar to allow people to PM the bot their livestreams in the correct format: @@ -28,33 +28,59 @@ setup.py will also print out a string for you to put into your sidebar to allow with your bots username and the subreddit it's running in (taken from config.py) substituted where marked. +### OAuth configuration +The use of OAuth in PRAW requires a praw.ini file located in your config folder (varies by platform) or in the root directory if this project (overrides global settings) + +No matter where you put it, you need a praw.ini file with the following included: +``` +[DEFAULT] +domain: www.reddit.com +oauth_client_id: +oauth_client_secret: +oauth_redirect_uri: http://127.0.0.1:65010/authorize_callback +oauth_refresh_token: +``` +#### Authenticating with OAuth + +There a few steps to authenticate via OAuth: +1. Get the `client_id` and `secret_access_key` from [Reddit](https://www.reddit.com/prefs/apps/) +2. Copy `client_id` to praw.ini as `oath_client_id` and `secret_access_key` as `oauth_client_secret` in praw.ini +3. Run `authenticate.py`. It will open up your default web browser and present a page to grant access to the application +4. You will be redirected to the url at `oauth_redirect_uri` in praw.ini. If you leave the default value, you will likely get a page not found error. This is okay. All you need is the code at the end of URL: `http://127.0.0.1:65010/authorize_callback?state=obtainingAuthentication&code=THIS_IS_THE_CODE_YOU_WANT` +5. Copy the code into the prompt and press enter +6. The script gets the refresh token and prints it to stdout. Copy the refresh token to praw.ini as `oauth_refresh_token` + +Now you will be good to go! You will not have to re-run authenticate.py unless you de-authorize your application from Reddit or you allow your refresh token to expire. + +See [here](http://praw.readthedocs.org/en/latest/pages/oauth.html) for more details on the PRAW implementation of OAuth + ###twitchbot_config All of the following config is editable in default_wiki_config.json before you run setup.py, and after you've ran setup.py the bot will pull it from `/wiki/twitchbot_config` { - "max_streams_displayed":"12", - "max_title_length":"50", - "thumbnail_size":{ - "width":"80", - "height":"50" - }, - "stream_marker_start":"[](#startmarker)", - "stream_marker_end":"[](#endmarker)", - "string_format":"> 1. **[{name}](http://twitch.tv/{name})** -**{viewercount} Viewers**\n[{title}](http://twitch.tv/{name})\n", - "no_streams_string":"**No streams are currently live.**\n", - "wikipages":{ - "error_log":"twitchbot_error_log", - "stream_list":"streams", - "ban_list":"banned_streams" - }, - "allowed_games":[], - "messages":{ - "success":"Your stream will be added to the list of livestreams in the sidebar, it will display the next time you are live on twitch.tv.\n\nProblems? [Contact the moderators here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message.", - "banned":"Sorry, but that stream is banned from this subreddit. If you feel this is an incorrect ban, [please message the moderators here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message.", - "already_exists":"Your stream is already in the list of livestreams that this bot checks. If you have just messaged your stream, please wait 5-10 minutes for the sidebar to update.\n\n Problems? Contact the moderators [here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message." - } + "max_streams_displayed":"12", + "max_title_length":"50", + "thumbnail_size":{ + "width":"80", + "height":"50" + }, + "stream_marker_start":"[](#startmarker)", + "stream_marker_end":"[](#endmarker)", + "string_format":"> 1. **[{name}](http://twitch.tv/{name})** -**{viewercount} Viewers**\n[{title}](http://twitch.tv/{name})\n", + "no_streams_string":"**No streams are currently live.**\n", + "wikipages":{ + "error_log":"twitchbot_error_log", + "stream_list":"streams", + "ban_list":"banned_streams" + }, + "allowed_games":[], + "messages":{ + "success":"Your stream will be added to the list of livestreams in the sidebar, it will display the next time you are live on twitch.tv.\n\nProblems? [Contact the moderators here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message.", + "banned":"Sorry, but that stream is banned from this subreddit. If you feel this is an incorrect ban, [please message the moderators here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message.", + "already_exists":"Your stream is already in the list of livestreams that this bot checks. If you have just messaged your stream, please wait 5-10 minutes for the sidebar to update.\n\n Problems? Contact the moderators [here](http://www.reddit.com/message/compose?to=%2Fr%2F{subreddit})\n\n Do not reply to this message." + } } ### Config information diff --git a/authenticate.py b/authenticate.py new file mode 100644 index 0000000..bf48d70 --- /dev/null +++ b/authenticate.py @@ -0,0 +1,11 @@ +import praw, webbrowser + +r = praw.Reddit('Temporary reddit app to obtain authentication information') + +url = r.get_authorize_url(state='obtainingAuthentication', scope='modconfig modwiki wikiread privatemessages', refreshable=True) +webbrowser.open(url) +reddit_code = raw_input('Please enter the code from the redirect url: ') + +access_information = r.get_access_information(reddit_code) + +print 'Refresh token: ' + str(access_information.get('refresh_token')) diff --git a/config.py b/config.py index 126e920..3cc35e3 100644 --- a/config.py +++ b/config.py @@ -1,3 +1,2 @@ username = "username" -password = "password" subreddit = "subreddit" \ No newline at end of file diff --git a/setup.py b/setup.py index ce76a3f..dcbbd06 100644 --- a/setup.py +++ b/setup.py @@ -1,63 +1,272 @@ +import re import os +import json +import HTMLParser +from random import shuffle +from StringIO import StringIO + import praw -import json import requests -from config import username, password, subreddit +from PIL import Image -# Why is this even a function +from config import subreddit -def wikilog(r, subreddit, wikipage, error): - r.edit_wiki_page(subreddit, wikipage, error, error) +def chunker(seq, size): + return (seq[pos:pos + size] for pos in xrange(0, len(seq), size)) -if __name__ == "__main__": - print "Starting setup.py..." - print "Attempting to log in..." - r = praw.Reddit("Sidebar livestream updater for /r/{} by /u/andygmb ".format(subreddit)) - try: - r.login(username=username, password=password) - print "Success!\n" - except praw.errors.InvalidUserPass: - print "Make sure you have your bot's details in config.py" - sub = r.get_subreddit(subreddit) - path_to_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "default_wiki_config.json") - with open(path_to_file, "r") as configjson: +class configuration(): + def __init__(self): + self.r, self.subreddit = self.reddit_setup() + self.config = self.get_config() + self.streams = self.wikipage_check(self.config["wikipages"]["stream_list"]) + self.banned = self.bans() + self.messages = self.check_inbox() + + def get_config(self): try: - print "\nAttempting to load default_wiki_config.json..." - config = json.load(configjson) - prettyconfig = " " + json.dumps(config, indent=4).replace("\n", "\n ") - print "Success!\n" - except ValueError: - print "Invalid JSON in local file: default_wiki_config.json" - wikilog(r, sub, "twitchbot_error_log", "Invalid JSON in local file: default_wiki_config.json") + config = self.r.get_wiki_page(self.subreddit,"twitchbot_config").content_md + try: + config = json.loads(config) + except ValueError: + print "No JSON object could be found, or the config page has broken JSON.\nUse www.jsonlint.com to validate your wiki config page." + self.wikilog("No JSON object could be decoded from twitchbot config wiki page.") + raise + return HTMLParser.HTMLParser().unescape(config) + except requests.exceptions.HTTPError: + print "Couldn't access config wiki page, reddit may be down." + self.config = {"wikipages":{"error_log":"twitchbot_error_log"}} + self.wikilog("Couldn't access config wiki page, reddit may be down.") raise - try: - print "Attempting to add default_wiki_config.json to https://www.reddit.com/r/{}/twitchbot_config ...".format(subreddit) - r.edit_wiki_page(sub, "twitchbot_config", prettyconfig, "Initial setup from setup.py") - print "Success!\n".format(subreddit) - except requests.exceptions.HTTPError: - print "Couldn't access https://www.reddit.com/r/{}/wiki/{}, reddit may be down.".format(subreddit, "twitchbot_config") - wikilog(r, sub, config["wikipages"]["error_log"], "Couldn't access wiki page, reddit may be down.") - raise + def wikilog(self, error): + self.r.edit_wiki_page(self.subreddit, self.config["wikipages"]["error_log"], error, error) - for wikipage in config["wikipages"].values(): + def reddit_setup(self): + print "Logging in" + r = praw.Reddit("Sidebar livestream updater for /r/{} by /u/andygmb ".format(subreddit)) try: - if not "config/" in wikipage: - content = r.get_wiki_page(sub, wikipage).content_md - print "Attempting to set up https://www.reddit.com/r/{}/wiki/{} ...".format(subreddit, wikipage) - r.edit_wiki_page(sub, wikipage, content.encode("utf8"), "Initial setup from setup.py") - print "Success!\n".format(wikipage) + r.refresh_access_information() + print "Success!\n" + # TODO: Make these more explicit; maybe have a catch all for both types? + except praw.errors.APIException, e: + raise e + except praw.errors.ClientException(), e: + raise e + sub = r.get_subreddit(subreddit) + return r, sub + + def wikipage_check(self, wikipage): + try: + wiki_list = self.r.get_wiki_page(self.subreddit, wikipage).content_md.splitlines() + results = [item.lower() for item in wiki_list if len(item)] except requests.exceptions.HTTPError: - print "Couldn't access https://www.reddit.com/r/{}/wiki/{}, reddit may be down.".format(subreddit, wikipage) - wikilog(r, sub, wikipage, "Couldn't access wiki page, reddit may be down.") + print "No wikipage found at http://www.reddit.com/r/{}/wiki/{}".format(self.subreddit.display_name, wikipage) + self.wikilog("Couldn't access wikipage at /wiki/{}/".format(wikipage)) + results = [] + return results + + def check_inbox(self): + if self.config["accept_messages"].lower() not in ["false", "no", "n"]: + streams = [] + inbox = self.r.get_inbox() + print "Checking inbox for new messages" + for message in inbox: + if message.new \ + and message.subject == "Twitch.tv request /r/{}".format(self.subreddit): + message_content = message.body.split()[0] + try: + re_pattern = 'twitch.tv/(\w+)' + # pattern matches twitch username in the first group + re_result = re.search(re_pattern, message_content) + if re_result: + stream_name = re_result.group(1).lower() + # extract the username stored in regex group 1 + else: + print "Could not find stream name in message." + continue # skip to next message + except ValueError: + message.mark_as_read() + stream_name = "null" + print "Could not find stream name in message." + + if "twitch.tv/" in message_content \ + and len(stream_name) <=25 \ + and stream_name not in self.banned \ + and stream_name not in self.streams: + streams.append(stream_name) + message.reply(self.config["messages"]["success"].format(subreddit=self.subreddit)) + message.mark_as_read() + + elif stream_name in self.banned: + message.reply(self.config["messages"]["banned"].format(subreddit=self.subreddit)) + message.mark_as_read() + + elif stream_name in self.streams: + message.reply(self.config["messages"]["already_exists"].format(subreddit=self.subreddit)) + message.mark_as_read() + + if streams: + new_streams = list(set([stream for stream in streams if stream not in [self.streams, self.banned]])) + self.streams.extend(new_streams) + self.subreddit.edit_wiki_page( + self.config["wikipages"]["stream_list"], + "\n".join(self.streams), + reason="Adding stream(s): " + ", ".join(new_streams) + ) + else: + print "Skipping inbox check as accept_messages config is set to False." + pass + + def update_stylesheet(self): + print "Uploading thumbnail image(s)" + try: + self.subreddit.upload_image("img.png", "img", False) + except praw.errors.APIException: + print "Too many images uploaded." + self.wikilog("Too many images uploaded to the stylesheet, max of 50 images allowed.") + raise + stylesheet = self.r.get_stylesheet(self.subreddit) + stylesheet = HTMLParser.HTMLParser().unescape(stylesheet["stylesheet"]) + self.subreddit.set_stylesheet(stylesheet) + + def update_sidebar(self): + content = self.r.get_wiki_page(self.subreddit, self.config["wikipages"]["stream_location"]).content_md + try: + start = content.index(self.config["stream_marker_start"]) + end = content.index(self.config["stream_marker_end"]) + len(self.config["stream_marker_end"]) + except ValueError: + print "Couldn't find the stream markers in /wiki/{}".format(self.config["wikipages"]["stream_location"]) + self.wikilog("Couldn't find the stream markers in /wiki/{}".format(self.config["wikipages"]["stream_location"])) raise + livestreams_string = "".join([stream["stream_output"] for stream in livestreams.streams]) + if content[start:end] != "{}\n\n{}\n\n{}".format(self.config["stream_marker_start"],livestreams_string.encode("ascii", "ignore"),self.config["stream_marker_end"]): + print "Updating sidebar" + content = content.replace( + content[start:end], + "{}\n\n{}\n\n{}".format(self.config["stream_marker_start"],livestreams_string.encode("ascii", "ignore"),self.config["stream_marker_end"]) + ) + self.r.edit_wiki_page(self.subreddit, self.config["wikipages"]["stream_location"], content, reason="Updating livestreams") + return True + else: + print "The stream content is exactly the same as what is already on https://www.reddit.com/r/{}/wiki/{}. Skipping update.".format(self.subreddit, self.config["wikipages"]["stream_location"]) + return False + + def sort_streams(self, streams): + reverse = False + if self.config["sort_type"].lower() == "descending": + reverse = True + if self.config["sort_type"].lower() == "random": + return shuffle(streams) + if self.config["sort_by"].lower() in ["viewercount", "views", "view", "viewers", "viewer"]: + return sorted(streams, key=lambda stream:stream["json_data"]["viewers"], reverse=reverse) + if self.config["sort_by"].lower() == "title": + return sorted(streams, key=lambda stream:stream["json_data"]["channel"]["status"]) + + def bans(self): + banned_streams = self.wikipage_check(self.config["wikipages"]["ban_list"]) + bans = [] + for stream in banned_streams: + if stream in self.streams[:]: + bans.append(stream) + self.streams.remove(stream) + if bans: + print "Removing banned stream(s): " + ", ".join(bans) + self.subreddit.edit_wiki_page( + self.config["wikipages"]["stream_list"], + "\n".join(self.streams), + reason="Removing banned stream(s): " + ", ".join(bans) + ) + return self.wikipage_check(self.config["wikipages"]["ban_list"]) + - if config["accept_messages"].lower() in ["true", "yes", "y"]: - print "Setup.py finished!\n\n" - print "By default the bot will respond to PMs and update https://wwww.reddit.com/r/{}/wiki/{} with any twitch.tv links users send it.".format(subreddit, config["wikipages"]["stream_list"]) - print "Place the following link in your sidebar for people to message the bot twitch.tv streams:" - print "http://www.reddit.com/message/compose?to={username}&subject=Twitch.tv+request+%2Fr%2F{subreddit}&message=http%3A%2F%2Fwww.twitch.tv%2F{username}".format(username=username, subreddit=subreddit) - print '\nIf you do not want the bot to update the list of streams through PM, please edit https://wwww.reddit.com/r/{}/wiki/twitchbot_config and set "accept_messages" to "False".'.format(subreddit) +class livestreams(): + def __init__(self, config): + self.config = config # This is kind of retarded, should probably subclass or just have the entire bot in one class. + self.streams = [] + + def check_stream_length(self): + max_streams = int(self.config.config["max_streams_displayed"]) + if len(self.streams) > max_streams: + self.streams = self.streams[:max_streams] + print "There are more than {max_stream_count} streams currently \ + - the amount displayed has been reduced to {max_stream_count}. \ + You can increase this in your config.py file.".format(max_stream_count=max_streams) + if len(self.streams): + self.streams = self.config.sort_streams(self.streams) + return True + else: + self.streams = [{"stream_output":self.config.config["no_streams_string"]}] + return False + + def get_livestreams(self): + print "Requesting stream info" + for chunk in chunker(self.config.streams, 100): + api_link = "https://api.twitch.tv/kraken/streams?channel=" + for stream in chunk: + api_link += stream + "," + try: + data = requests.get(api_link).json() + if data["_total"] > 0: + self.parse_stream_info(data) + else: + pass + except: + pass + + + def parse_stream_info(self, data): + print "Parsing stream info" + allowed_games = [str(game.lower()) for game in self.config.config["allowed_games"]] + for streamer in data["streams"]: + if not len(allowed_games) or streamer["game"].lower() in allowed_games: + # Removing characters that can break reddit formatting + game = re.sub(r'[*)(>/#\[\]\\]*', '', streamer["game"]).replace("\n", "").encode("utf-8") + title = re.sub(r'[*)(>/#\[\]\\]*', '', streamer["channel"]["status"]).replace("\n", "").encode("utf-8") + #Add elipises if title is too long + if len(title) >= int(self.config.config["max_title_length"]): + title = title[0:int(self.config.config["max_title_length"]) - 3] + "..." + name = re.sub(r'[*)(>/#\[\]\\]*', '', streamer["channel"]["name"]).replace("\n", "").encode("utf-8") + display_name = re.sub(r'[*)(>/#\[\]\\]*', '', streamer["channel"]["display_name"]).replace("\n", "").encode("utf-8") + viewercount = "{:,}".format(streamer["viewers"]) + self.streams.append({ + "stream_output":HTMLParser.HTMLParser().unescape(self.config.config["string_format"].format(name=name, title=title, viewercount=viewercount, display_name=display_name, game=game)), + "json_data":streamer + }) + + def create_spritesheet(self): + print "Creating image spritesheet" + width, height = ( + int(self.config.config["thumbnail_size"]["width"]), + int(self.config.config["thumbnail_size"]["height"]) + ) + preview_images = [] + for stream in self.streams: + url = stream["json_data"]["preview"]["template"] + preview_data = requests.get(url.format(width=str(width), height=str(height) )).content + # Download image + preview_img = Image.open(StringIO(preview_data)) + # Convert to PIL Image + preview_images.append(preview_img) + w, h = width, height * (len(preview_images) or 1) + spritesheet = Image.new("RGB", (w, h)) + xpos = 0 + ypos = 0 + for img in preview_images: + bbox = (xpos, ypos) + spritesheet.paste(img,bbox) + ypos = ypos + height + path_to_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "img.png") + spritesheet.save(path_to_file) + + +if __name__ == "__main__": + config = configuration() + livestreams = livestreams(config) + livestreams.get_livestreams() + if livestreams.check_stream_length(): + if livestreams.config.update_sidebar(): + livestreams.create_spritesheet() + livestreams.config.update_stylesheet() else: - print "Setup.py finished!\n\n" \ No newline at end of file + livestreams.config.update_sidebar() diff --git a/twitchy.py b/twitchy.py index 6029f97..0f6af94 100644 --- a/twitchy.py +++ b/twitchy.py @@ -10,7 +10,7 @@ import requests from PIL import Image -from config import username, password, subreddit +from config import subreddit def chunker(seq, size): return (seq[pos:pos + size] for pos in xrange(0, len(seq), size)) @@ -45,7 +45,13 @@ def wikilog(self, error): def reddit_setup(self): print "Logging in" r = praw.Reddit("Sidebar livestream updater for /r/{} by /u/andygmb ".format(subreddit)) - r.login(username=username, password=password) + try: + r.refresh_access_information() + # TODO: not sure which of these may be excepted; need to test further + except praw.errors.APIException, e: + raise e + except praw.errors.ClientException(), e: + raise e sub = r.get_subreddit(subreddit) return r, sub