| @@ -442,8 +442,8 @@ class Blowfish(object): | |||
| def encrypt(self, data): | |||
| if not len(data) == 8: | |||
| raise BlockSizeError("blocks must be 8 bytes long, but tried to " + | |||
| "encrypt one {0} bytes long".format(len(data))) | |||
| e = "blocks must be 8 bytes long, but tried to encrypt one {0} bytes long" | |||
| raise BlockSizeError(e.format(len(data))) | |||
| # Use big endianess since that's what everyone else uses | |||
| xl = ord (data[3]) | (ord (data[2]) << 8) | (ord (data[1]) << 16) | (ord (data[0]) << 24) | |||
| @@ -458,8 +458,8 @@ class Blowfish(object): | |||
| def decrypt(self, data): | |||
| if not len(data) == 8: | |||
| raise BlockSizeError("blocks must be 8 bytes long, but tried to " + | |||
| "decrypt one {0} bytes long".format(len(data))) | |||
| e = "blocks must be 8 bytes long, but tried to decrypt one {0} bytes long" | |||
| raise BlockSizeError(e.format(len(data))) | |||
| # Use big endianess since that's what everyone else uses | |||
| cl = ord (data[3]) | (ord (data[2]) << 8) | (ord (data[1]) << 16) | (ord (data[0]) << 24) | |||
| @@ -482,16 +482,18 @@ class Blowfish(object): | |||
| return 56 * 8 | |||
| def verify_key(self, key): | |||
| """Make sure our key is not too short or too long; if there's a | |||
| problem, raise KeyTooShortError() or KeyTooLongError().""" | |||
| """Make sure our key is not too short or too long. | |||
| If there's a problem, raise KeyTooShortError() or KeyTooLongError(). | |||
| """ | |||
| if not key: | |||
| raise KeyLengthError("no key given") | |||
| if len(key) < 8: | |||
| raise KeyLengthError(("key is {0} bytes long, but it must be at " + | |||
| "least 8").format(len(key))) | |||
| e = "key is {0} bytes long, but it must be at least 8" | |||
| raise KeyLengthError(e.format(len(key))) | |||
| if len(key) > 56: | |||
| raise KeyLengthError(("key is {0} bytes long, but it must be " + | |||
| "less than 56").format(len(key))) | |||
| e = "key is {0} bytes long, but it must be less than 56" | |||
| raise KeyLengthError(e.format(len(key))) | |||
| def encrypt(key, plaintext): | |||
| """Encrypt any length of plaintext using a given key that must be between | |||
| @@ -518,24 +520,25 @@ def decrypt(key, cyphertext): | |||
| try: | |||
| cyphertext = cyphertext.decode("hex") | |||
| except TypeError as error: | |||
| except (TypeError, AttributeError) as error: | |||
| e = error.message | |||
| raise DecryptionError("cyphertext could not be decoded: " + e.lower()) | |||
| if len(cyphertext) % 8 > 0: | |||
| raise DecryptionError("cyphertext cannot be broken into " + | |||
| "8-byte blocks evenly") | |||
| e = "cyphertext cannot be broken into 8-byte blocks evenly" | |||
| raise DecryptionError(e) | |||
| blocks = [cyphertext[f:f+8] for f in range(0, len(cyphertext), 8)] | |||
| msg = ''.join(map(cypher.decrypt, blocks)) | |||
| if not msg.startswith("TRUE"): # sanity check to ensure valid decryption | |||
| raise DecryptionError("the given key is incorrect, or part of the " + | |||
| "cyphertext is malformed") | |||
| # Sanity check to ensure valid decryption: | |||
| if not msg.startswith("TRUE"): | |||
| e = "the given key is incorrect, or part of the cyphertext is malformed" | |||
| raise DecryptionError(e) | |||
| size, msg = msg[4:].split("|", 1) | |||
| while len(msg) > int(size): | |||
| msg = msg[:-1] # remove the padding that we applied earlier | |||
| msg = msg[:-1] # Remove the padding that we applied earlier | |||
| return msg | |||
| @@ -4,6 +4,7 @@ import platform | |||
| import time | |||
| from classes import BaseCommand | |||
| import config | |||
| class Command(BaseCommand): | |||
| """Not an actual command, this module is used to respond to the CTCP | |||
| @@ -40,6 +41,7 @@ class Command(BaseCommand): | |||
| self.connection.notice(target, "\x01TIME {0}\x01".format(ts)) | |||
| elif command == "VERSION": | |||
| vers = "EarwigBot - 0.1-dev - Python/{0} https://github.com/earwig/earwigbot" | |||
| vers = vers.format(platform.python_version()) | |||
| default = "EarwigBot - 0.1-dev - Python/$1 https://github.com/earwig/earwigbot" | |||
| vers = config.metadata.get("ircVersion", default) | |||
| vers = vers.replace("$1", platform.python_version()) | |||
| self.connection.notice(target, "\x01VERSION {0}\x01".format(vers)) | |||
| @@ -8,13 +8,19 @@ including encrypting and decrypting passwords and making a new config file from | |||
| scratch at the inital bot run. | |||
| Usually you'll just want to do "from core import config" and access config data | |||
| from within config's three global variables and one function: | |||
| * config.components - a list of enabled components | |||
| * config.wiki - a dict of config information for wiki-editing | |||
| * config.irc - a dict of config information for IRC | |||
| * config.schedule() - returns a list of tasks scheduled to run at a given | |||
| time | |||
| from within config's four global variables and one function: | |||
| * config.components - a list of enabled components | |||
| * config.wiki - a dict of information about wiki-editing | |||
| * config.irc - a dict of information about IRC | |||
| * config.metadata - a dict of miscellaneous information | |||
| * config.schedule() - returns a list of tasks scheduled to run at a given time | |||
| Additionally, functions related to config loading: | |||
| * config.load() - loads and parses our config file, returning True if | |||
| passwords are stored encrypted or False otherwise | |||
| * config.decrypt() - given a key, decrypts passwords inside our config | |||
| variables; won't work if passwords aren't encrypted | |||
| """ | |||
| import json | |||
| @@ -26,19 +32,12 @@ script_dir = path.dirname(path.abspath(__file__)) | |||
| root_dir = path.split(script_dir)[0] | |||
| config_path = path.join(root_dir, "config.json") | |||
| _config = None # holds data loaded from our config file | |||
| # set our three easy-config-access global variables to None | |||
| components, wiki, irc = (None, None, None) | |||
| _config = None # Holds data loaded from our config file | |||
| def is_config_loaded(): | |||
| """Return True if our config file has already been loaded, and False if it | |||
| hasn't.""" | |||
| if _config is not None: | |||
| return True | |||
| return False | |||
| # Set our four easy-config-access global variables to None | |||
| components, wiki, irc, metadata = None, None, None, None | |||
| def load_config(): | |||
| def _load(): | |||
| """Load data from our JSON config file (config.json) into _config.""" | |||
| global _config | |||
| with open(config_path, 'r') as fp: | |||
| @@ -49,86 +48,92 @@ def load_config(): | |||
| print error | |||
| exit(1) | |||
| def verify_config(): | |||
| """Check to see if we have a valid config file, and if not, notify the | |||
| user. If there is no config file at all, offer to make one; otherwise, | |||
| exit. If everything goes well, return True if stored passwords are | |||
| encrypted in the file, or False if they are not.""" | |||
| if path.exists(config_path): | |||
| load_config() | |||
| try: | |||
| return _config["encryptPasswords"] # are passwords encrypted? | |||
| except KeyError: | |||
| return False # assume passwords are not encrypted by default | |||
| def _make_new(): | |||
| """Make a new config file based on the user's input.""" | |||
| encrypt = raw_input("Would you like to encrypt passwords stored in config.json? [y/n] ") | |||
| if encrypt.lower().startswith("y"): | |||
| is_encrypted = True | |||
| else: | |||
| is_encrypted = False | |||
| return is_encrypted | |||
| def is_loaded(): | |||
| """Return True if our config file has been loaded, otherwise False.""" | |||
| return _config is not None | |||
| def load(): | |||
| """Load, or reload, our config file. | |||
| First, check if we have a valid config file, and if not, notify the user. | |||
| If there is no config file at all, offer to make one, otherwise exit. | |||
| Store data from our config file in four global variables (components, wiki, | |||
| irc, metadata) for easy access (as well as the internal _config variable). | |||
| If everything goes well, return True if stored passwords are | |||
| encrypted in the file, or False if they are not. | |||
| """ | |||
| global components, wiki, irc, metadata | |||
| if not path.exists(config_path): | |||
| print "You haven't configured the bot yet!" | |||
| choice = raw_input("Would you like to do this now? [y/n] ") | |||
| if choice.lower().startswith("y"): | |||
| return make_new_config() | |||
| return _make_new() | |||
| else: | |||
| exit(1) | |||
| def parse_config(key): | |||
| """Store data from our config file in three global variables for easy | |||
| access, and use the key to unencrypt passwords. Catch password decryption | |||
| errors and report them to the user.""" | |||
| global components, wiki, irc | |||
| _load() | |||
| load_config() # we might be re-loading unnecessarily here, but no harm in | |||
| # that! | |||
| try: | |||
| components = _config["components"] | |||
| except KeyError: | |||
| components = [] | |||
| try: | |||
| wiki = _config["wiki"] | |||
| except KeyError: | |||
| wiki = {} | |||
| try: | |||
| irc = _config["irc"] | |||
| except KeyError: | |||
| irc = {} | |||
| components = _config.get("components", []) | |||
| wiki = _config.get("wiki", {}) | |||
| irc = _config.get("irc", {}) | |||
| metadata = _config.get("metadata", {}) | |||
| # Are passwords encrypted? | |||
| return metadata.get("encryptPasswords", False) | |||
| def decrypt(key): | |||
| """Use the key to decrypt passwords in our config file. | |||
| Call this if load() returns True. Catch password decryption errors and | |||
| report them to the user. | |||
| """ | |||
| global irc, wiki | |||
| try: | |||
| try: | |||
| if _config["encryptPasswords"]: | |||
| decrypt(key, "wiki['password']") | |||
| decrypt(key, "irc['frontend']['nickservPassword']") | |||
| decrypt(key, "irc['watcher']['nickservPassword']") | |||
| except KeyError: | |||
| pass | |||
| item = wiki.get("password") | |||
| if item: | |||
| wiki["password"] = blowfish.decrypt(key, item) | |||
| item = irc.get("frontend").get("nickservPassword") | |||
| if item: | |||
| irc["frontend"]["nickservPassword"] = blowfish.decrypt(key, item) | |||
| item = irc.get("watcher").get("nickservPassword") | |||
| if item: | |||
| irc["watcher"]["nickservPassword"] = blowfish.decrypt(key, item) | |||
| except blowfish.BlowfishError as error: | |||
| print "\nError decrypting passwords:" | |||
| print "{0}: {1}.".format(error.__class__.__name__, error) | |||
| exit(1) | |||
| def decrypt(key, item): | |||
| """Decrypt 'item' with blowfish.decrypt() using the given key and set it to | |||
| the decrypted result. 'item' should be a string, like | |||
| decrypt(key, "wiki['password']"), NOT decrypt(key, wiki['password'), | |||
| because that won't work.""" | |||
| global irc, wiki | |||
| try: | |||
| result = blowfish.decrypt(key, eval(item)) | |||
| except KeyError: | |||
| return | |||
| exec "{0} = result".format(item) | |||
| def schedule(minute, hour, month_day, month, week_day): | |||
| """Return a list of tasks that are scheduled to run at the time specified | |||
| by the function arguments. The schedule data comes from our config file's | |||
| 'schedule' field, which is stored as _config["schedule"]. Call this | |||
| function with config.schedule(args).""" | |||
| tasks = [] # tasks to run this turn, each as a tuple of either (task_name, | |||
| # kwargs), or just task_name | |||
| """Return a list of tasks scheduled to run at the specified time. | |||
| The schedule data comes from our config file's 'schedule' field, which is | |||
| stored as _config["schedule"]. Call this function as config.schedule(args). | |||
| """ | |||
| # Tasks to run this turn, each as a list of either [task_name, kwargs], or | |||
| # just the task_name: | |||
| tasks = [] | |||
| now = {"minute": minute, "hour": hour, "month_day": month_day, | |||
| "month": month, "week_day": week_day} | |||
| try: | |||
| data = _config["schedule"] | |||
| except KeyError: | |||
| return [] # nothing is in our schedule | |||
| data = _config.get("schedule", []) | |||
| for event in data: | |||
| do = True | |||
| for key, value in now.items(): | |||
| @@ -146,15 +151,3 @@ def schedule(minute, hour, month_day, month, week_day): | |||
| pass | |||
| return tasks | |||
| def make_new_config(): | |||
| """Make a new config file based on the user's input.""" | |||
| encrypt = raw_input("Would you like to encrypt passwords stored in " + | |||
| "config.json? [y/n] ") | |||
| if encrypt.lower().startswith("y"): | |||
| is_encrypted = True | |||
| else: | |||
| is_encrypted = False | |||
| return is_encrypted | |||
| @@ -4,9 +4,9 @@ | |||
| """ | |||
| EarwigBot's Core | |||
| This (should) not be run directly; the wrapper in "earwigbot.py" is preferred, | |||
| This should not be run directly; the wrapper in "earwigbot.py" is preferred, | |||
| but it should work fine alone, as long as you enter the password-unlock key at | |||
| the initial hidden prompt. | |||
| the initial hidden prompt if one is needed. | |||
| The core is essentially responsible for starting the various bot components | |||
| (irc, scheduler, etc) and making sure they are all happy. An explanation of the | |||
| @@ -103,12 +103,14 @@ def irc_frontend(): | |||
| f_conn.close() | |||
| def run(): | |||
| config.load() | |||
| try: | |||
| key = raw_input() # wait for our password unlock key from the bot's | |||
| except EOFError: # wrapper | |||
| key = None | |||
| config.parse_config(key) # load data from the config file and parse it | |||
| # using the unlock key | |||
| key = raw_input() # wait for our password decrypt key from the bot's | |||
| except EOFError: # wrapper, then decrypt passwords | |||
| pass | |||
| else: | |||
| config.decrypt(key) | |||
| enabled = config.components | |||
| if "irc_frontend" in enabled: # make the frontend run on our primary | |||
| @@ -120,7 +122,7 @@ def run(): | |||
| tasks.load() # watcher on another thread iff it | |||
| if "irc_watcher" in enabled: # is enabled | |||
| print "\nStarting IRC watcher..." | |||
| t_watcher = threading.Thread(target=irc_watcher, args=()) | |||
| t_watcher = threading.Thread(target=irc_watcher) | |||
| t_watcher.name = "irc-watcher" | |||
| t_watcher.daemon = True | |||
| t_watcher.start() | |||
| @@ -3,18 +3,18 @@ | |||
| """ | |||
| EarwigBot's Wiki Toolset: Constants | |||
| This module defines some useful constants, such as default namespace IDs for | |||
| easy lookup and our user agent. | |||
| This module defines some useful constants: | |||
| * USER_AGENT - our default User Agent when making API queries | |||
| * NS_* - default namespace IDs for easy lookup | |||
| Import with `from wiki.constants import *`. | |||
| Import with `from wiki import constants` or `from wiki.constants import *`. | |||
| """ | |||
| # Default User Agent when making API queries: | |||
| import platform | |||
| # User agent when making API queries | |||
| USER_AGENT = "EarwigBot/0.1-dev (Python/{0}; https://github.com/earwig/earwigbot)".format(platform.python_version()) | |||
| # Default namespace IDs | |||
| # Default namespace IDs: | |||
| NS_MAIN = 0 | |||
| NS_TALK = 1 | |||
| NS_USER = 2 | |||
| @@ -14,6 +14,7 @@ from cookielib import LWPCookieJar, LoadError | |||
| import errno | |||
| from getpass import getpass | |||
| from os import chmod, path | |||
| import platform | |||
| import stat | |||
| import config | |||
| @@ -30,12 +31,10 @@ def _load_config(): | |||
| directly from Python's interpreter and not the bot itself, because | |||
| earwigbot.py or core/main.py will already call these functions. | |||
| """ | |||
| is_encrypted = config.verify_config() | |||
| is_encrypted = config.load() | |||
| if is_encrypted: # passwords in the config file are encrypted | |||
| key = getpass("Enter key to unencrypt bot passwords: ") | |||
| config.parse_config(key) | |||
| else: | |||
| config.parse_config(None) | |||
| config.decrypt(key) | |||
| def _get_cookiejar(): | |||
| """Returns a LWPCookieJar object loaded from our .cookies file. The same | |||
| @@ -87,6 +86,10 @@ def _get_site_object_from_dict(name, d): | |||
| namespaces = d.get("namespaces", {}) | |||
| login = (config.wiki.get("username"), config.wiki.get("password")) | |||
| cookiejar = _get_cookiejar() | |||
| user_agent = config.metadata.get("userAgent") | |||
| if user_agent: | |||
| user_agent = user_agent.replace("$1", platform.python_version()) | |||
| for key, value in namespaces.items(): # Convert string keys to integers | |||
| del namespaces[key] | |||
| @@ -98,7 +101,8 @@ def _get_site_object_from_dict(name, d): | |||
| return Site(name=name, project=project, lang=lang, base_url=base_url, | |||
| article_path=article_path, script_path=script_path, sql=sql, | |||
| namespaces=namespaces, login=login, cookiejar=cookiejar) | |||
| namespaces=namespaces, login=login, cookiejar=cookiejar, | |||
| user_agent=user_agent) | |||
| def get_site(name=None, project=None, lang=None): | |||
| """Returns a Site instance based on information from our config file. | |||
| @@ -40,7 +40,8 @@ class Site(object): | |||
| def __init__(self, name=None, project=None, lang=None, base_url=None, | |||
| article_path=None, script_path=None, sql=(None, None), | |||
| namespaces=None, login=(None, None), cookiejar=None): | |||
| namespaces=None, login=(None, None), cookiejar=None, | |||
| user_agent=None): | |||
| """Constructor for new Site instances. | |||
| This probably isn't necessary to call yourself unless you're building a | |||
| @@ -57,8 +58,8 @@ class Site(object): | |||
| the API, and then log in if a username/pass was given and we aren't | |||
| already logged in. | |||
| """ | |||
| # attributes referring to site information, filled in by an API query | |||
| # if they are missing (and an API url can be determined) | |||
| # Attributes referring to site information, filled in by an API query | |||
| # if they are missing (and an API url can be determined): | |||
| self._name = name | |||
| self._project = project | |||
| self._lang = lang | |||
| @@ -68,19 +69,21 @@ class Site(object): | |||
| self._sql = sql | |||
| self._namespaces = namespaces | |||
| # set up cookiejar and URL opener for making API queries | |||
| # Set up cookiejar and URL opener for making API queries: | |||
| if cookiejar is not None: | |||
| self._cookiejar = cookiejar | |||
| else: | |||
| self._cookiejar = CookieJar() | |||
| if user_agent is None: | |||
| user_agent = USER_AGENT # Set default UA from wiki.constants | |||
| self._opener = build_opener(HTTPCookieProcessor(self._cookiejar)) | |||
| self._opener.addheaders = [("User-Agent", USER_AGENT), | |||
| self._opener.addheaders = [("User-Agent", user_agent), | |||
| ("Accept-Encoding", "gzip")] | |||
| # get all of the above attributes that were not specified as arguments | |||
| # Get all of the above attributes that were not specified as arguments: | |||
| self._load_attributes() | |||
| # if we have a name/pass and the API says we're not logged in, log in | |||
| # If we have a name/pass and the API says we're not logged in, log in: | |||
| self._login_info = name, password = login | |||
| if name is not None and password is not None: | |||
| logged_in_as = self._get_username_from_cookies() | |||
| @@ -112,7 +115,7 @@ class Site(object): | |||
| raise SiteAPIError(e) | |||
| url = ''.join((self._base_url, self._script_path, "/api.php")) | |||
| params["format"] = "json" # this is the only format we understand | |||
| params["format"] = "json" # This is the only format we understand | |||
| data = urlencode(params) | |||
| print url, data # debug code | |||
| @@ -135,7 +138,7 @@ class Site(object): | |||
| stream = StringIO(result) | |||
| gzipper = GzipFile(fileobj=stream) | |||
| result = gzipper.read() | |||
| return loads(result) # parse as a JSON object | |||
| return loads(result) # Parse as a JSON object | |||
| def _load_attributes(self, force=False): | |||
| """Load data about our Site from the API. | |||
| @@ -147,8 +150,8 @@ class Site(object): | |||
| Additionally, you can call this with `force=True` to forcibly reload | |||
| all attributes. | |||
| """ | |||
| # all attributes to be loaded, except _namespaces, which is a special | |||
| # case because it requires additional params in the API query | |||
| # All attributes to be loaded, except _namespaces, which is a special | |||
| # case because it requires additional params in the API query: | |||
| attrs = [self._name, self._project, self._lang, self._base_url, | |||
| self._article_path, self._script_path] | |||
| @@ -158,9 +161,9 @@ class Site(object): | |||
| params["siprop"] = "general|namespaces|namespacealiases" | |||
| result = self._api_query(params) | |||
| self._load_namespaces(result) | |||
| elif all(attrs): # everything is already specified and we're not told | |||
| elif all(attrs): # Everything is already specified and we're not told | |||
| return # to force a reload, so do nothing | |||
| else: # we're only loading attributes other than _namespaces | |||
| else: # We're only loading attributes other than _namespaces | |||
| params["siprop"] = "general" | |||
| result = self._api_query(params) | |||
| @@ -240,9 +243,9 @@ class Site(object): | |||
| continue | |||
| if cookie.name != name: | |||
| continue | |||
| # build a regex that will match domains this cookie affects | |||
| # Build a regex that will match domains this cookie affects: | |||
| search = ''.join(("(.*?)", re_escape(cookie.domain))) | |||
| if re_match(search, domain): # test it against our site | |||
| if re_match(search, domain): # Test it against our site | |||
| user_name = self._get_cookie("centralauth_User", cookie.domain) | |||
| if user_name is not None: | |||
| return user_name.value | |||
| @@ -402,7 +405,7 @@ class Site(object): | |||
| """ | |||
| lname = name.lower() | |||
| for ns_id, names in self._namespaces.items(): | |||
| lnames = [n.lower() for n in names] # be case-insensitive | |||
| lnames = [n.lower() for n in names] # Be case-insensitive | |||
| if lname in lnames: | |||
| return ns_id | |||
| @@ -421,7 +424,7 @@ class Site(object): | |||
| """ | |||
| prefixes = self.namespace_id_to_name(NS_CATEGORY, all=True) | |||
| prefix = title.split(":", 1)[0] | |||
| if prefix != title: # avoid a page that is simply "Category" | |||
| if prefix != title: # Avoid a page that is simply "Category" | |||
| if prefix in prefixes: | |||
| return Category(self, title, follow_redirects) | |||
| return Page(self, title, follow_redirects) | |||
| @@ -33,7 +33,7 @@ bot_script = path.join(path.dirname(path.abspath(__file__)), "bot", "main.py") | |||
| def main(): | |||
| print "EarwigBot v{0}\n".format(__version__) | |||
| is_encrypted = config.verify_config() | |||
| is_encrypted = config.load() | |||
| if is_encrypted: # passwords in the config file are encrypted | |||
| key = getpass("Enter key to unencrypt bot passwords: ") | |||
| else: | |||
| @@ -41,7 +41,7 @@ def main(): | |||
| while 1: | |||
| bot = Popen([executable, bot_script], stdin=PIPE) | |||
| bot.communicate(key) # give the key to core.config.load_config() | |||
| bot.communicate(key) # give the key to core.config.decrypt() | |||
| return_code = bot.wait() | |||
| if return_code == 1: | |||
| exit() # let critical exceptions in the subprocess cause us to | |||