@@ -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 | |||