@@ -1,9 +1,3 @@ | |||||
# Ignore bot-specific files: | |||||
logs/ | |||||
config.yml | |||||
sites.db | |||||
.cookies | |||||
# Ignore python bytecode: | # Ignore python bytecode: | ||||
*.pyc | *.pyc | ||||
@@ -1,70 +0,0 @@ | |||||
#! /usr/bin/env python | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
""" | |||||
EarwigBot | |||||
This is a thin wrapper for EarwigBot's main bot code, specified by bot_script. | |||||
The wrapper will automatically restart the bot when it shuts down (from | |||||
!restart, for example). It requests the bot's password at startup and reuses it | |||||
every time the bot restarts internally, so you do not need to re-enter the | |||||
password after using !restart. | |||||
For information about the bot as a whole, see the attached README.md file (in | |||||
markdown format!), the docs/ directory, and the LICENSE file for licensing | |||||
information. EarwigBot is released under the MIT license. | |||||
""" | |||||
from getpass import getpass | |||||
from subprocess import Popen, PIPE | |||||
from os import path | |||||
from sys import executable | |||||
from time import sleep | |||||
import earwigbot | |||||
bot_script = path.join(earwigbot.__path__[0], "runner.py") | |||||
def main(): | |||||
print "EarwigBot v{0}\n".format(earwigbot.__version__) | |||||
is_encrypted = earwigbot.config.config.load() | |||||
if is_encrypted: # Passwords in the config file are encrypted | |||||
key = getpass("Enter key to unencrypt bot passwords: ") | |||||
else: | |||||
key = None | |||||
while 1: | |||||
bot = Popen([executable, bot_script], stdin=PIPE) | |||||
print >> bot.stdin, path.dirname(path.abspath(__file__)) | |||||
if is_encrypted: | |||||
print >> bot.stdin, key | |||||
return_code = bot.wait() | |||||
if return_code == 1: | |||||
exit() # Let critical exceptions in the subprocess cause us to | |||||
# exit as well | |||||
else: | |||||
sleep(5) # Sleep between bot runs following a non-critical | |||||
# subprocess exit | |||||
if __name__ == "__main__": | |||||
main() |
@@ -31,6 +31,4 @@ __license__ = "MIT License" | |||||
__version__ = "0.1.dev" | __version__ = "0.1.dev" | ||||
__email__ = "ben.kurtovic@verizon.net" | __email__ = "ben.kurtovic@verizon.net" | ||||
from earwigbot import ( | |||||
blowfish, commands, config, irc, main, runner, tasks, tests, wiki | |||||
) | |||||
from earwigbot import blowfish, bot, commands, config, irc, tasks, util, wiki |
@@ -0,0 +1,113 @@ | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
import threading | |||||
from time import sleep, time | |||||
from earwigbot.config import BotConfig | |||||
from earwigbot.irc import Frontend, Watcher | |||||
from earwigbot.tasks import task_manager | |||||
class Bot(object): | |||||
""" | |||||
The Bot class is the core of EarwigBot, essentially responsible for | |||||
starting the various bot components and making sure they are all happy. An | |||||
explanation of the different components follows: | |||||
EarwigBot has three components that can run independently of each other: an | |||||
IRC front-end, an IRC watcher, and a wiki scheduler. | |||||
* The IRC front-end runs on a normal IRC server and expects users to | |||||
interact with it/give it commands. | |||||
* The IRC watcher runs on a wiki recent-changes server and listens for | |||||
edits. Users cannot interact with this part of the bot. | |||||
* The wiki scheduler runs wiki-editing bot tasks in separate threads at | |||||
user-defined times through a cron-like interface. | |||||
""" | |||||
def __init__(self, root_dir): | |||||
self.config = BotConfig(root_dir) | |||||
self.logger = logging.getLogger("earwigbot") | |||||
self.frontend = None | |||||
self.watcher = None | |||||
self._keep_scheduling = True | |||||
self._lock = threading.Lock() | |||||
def _start_thread(self, name, target): | |||||
thread = threading.Thread(name=name, target=target) | |||||
thread.start() | |||||
def _wiki_scheduler(self): | |||||
while self._keep_scheduling: | |||||
time_start = time() | |||||
task_manager.schedule() | |||||
time_end = time() | |||||
time_diff = time_start - time_end | |||||
if time_diff < 60: # Sleep until the next minute | |||||
sleep(60 - time_diff) | |||||
def _start_components(self): | |||||
if self.config.components.get("irc_frontend"): | |||||
self.logger.info("Starting IRC frontend") | |||||
self.frontend = Frontend(self.config) | |||||
self._start_thread(name, self.frontend.loop) | |||||
if self.config.components.get("irc_watcher"): | |||||
self.logger.info("Starting IRC watcher") | |||||
self.watcher = Watcher(self.config, self.frontend) | |||||
self._start_thread(name, self.watcher.loop) | |||||
if self.config.components.get("wiki_scheduler"): | |||||
self.logger.info("Starting wiki scheduler") | |||||
self._start_thread(name, self._wiki_scheduler) | |||||
def _loop(self): | |||||
while 1: | |||||
with self._lock: | |||||
if self.frontend and self.frontend.is_stopped(): | |||||
self.frontend._connect() | |||||
if self.watcher and self.watcher.is_stopped(): | |||||
self.watcher._connect() | |||||
sleep(5) | |||||
def run(self): | |||||
self.config.load() | |||||
self.config.decrypt(config.wiki, "password") | |||||
self.config.decrypt(config.wiki, "search", "credentials", "key") | |||||
self.config.decrypt(config.wiki, "search", "credentials", "secret") | |||||
self.config.decrypt(config.irc, "frontend", "nickservPassword") | |||||
self.config.decrypt(config.irc, "watcher", "nickservPassword") | |||||
self._start_components() | |||||
self._loop() | |||||
def reload(self): | |||||
#components = self.config.components | |||||
with self._lock: | |||||
self.config.load() | |||||
#if self.config.components.get("irc_frontend"): | |||||
def stop(self): | |||||
if self.frontend: | |||||
self.frontend.stop() | |||||
if self.watcher: | |||||
self.watcher.stop() | |||||
self._keep_scheduling = False |
@@ -34,4 +34,4 @@ class Command(BaseCommand): | |||||
return | return | ||||
self.connection.logger.info("Restarting bot per owner request") | self.connection.logger.info("Restarting bot per owner request") | ||||
self.connection.is_running = False | |||||
self.connection.stop() |
@@ -78,10 +78,9 @@ class Command(BaseCommand): | |||||
for thread in threads: | for thread in threads: | ||||
tname = thread.name | tname = thread.name | ||||
if tname == "MainThread": | if tname == "MainThread": | ||||
tname = self.get_main_thread_name() | |||||
t = "\x0302{0}\x0301 (as main thread, id {1})" | |||||
normal_threads.append(t.format(tname, thread.ident)) | |||||
elif tname in ["irc-frontend", "irc-watcher", "wiki-scheduler"]: | |||||
t = "\x0302MainThread\x0301 (id {1})" | |||||
normal_threads.append(t.format(thread.ident)) | |||||
elif tname in config.components: | |||||
t = "\x0302{0}\x0301 (id {1})" | t = "\x0302{0}\x0301 (id {1})" | ||||
normal_threads.append(t.format(tname, thread.ident)) | normal_threads.append(t.format(tname, thread.ident)) | ||||
elif tname.startswith("reminder"): | elif tname.startswith("reminder"): | ||||
@@ -157,12 +156,3 @@ class Command(BaseCommand): | |||||
task_manager.start(task_name, **data.kwargs) | task_manager.start(task_name, **data.kwargs) | ||||
msg = "task \x0302{0}\x0301 started.".format(task_name) | msg = "task \x0302{0}\x0301 started.".format(task_name) | ||||
self.connection.reply(data, msg) | self.connection.reply(data, msg) | ||||
def get_main_thread_name(self): | |||||
"""Return the "proper" name of the MainThread.""" | |||||
if "irc_frontend" in config.components: | |||||
return "irc-frontend" | |||||
elif "wiki_schedule" in config.components: | |||||
return "wiki-scheduler" | |||||
else: | |||||
return "irc-watcher" |
@@ -20,31 +20,7 @@ | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
# SOFTWARE. | # SOFTWARE. | ||||
""" | |||||
EarwigBot's YAML Config File Parser | |||||
This handles all tasks involving reading and writing to our config file, | |||||
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 earwigbot.config import config", which | |||||
returns a singleton _BotConfig object, with data accessible from various | |||||
attributes and functions: | |||||
* config.components - enabled components | |||||
* config.wiki - information about wiki-editing | |||||
* config.tasks - information for bot tasks | |||||
* config.irc - information about IRC | |||||
* config.metadata - miscellaneous information | |||||
* config.schedule() - tasks scheduled to run at a given time | |||||
Additionally, _BotConfig has some functions used in 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 | |||||
""" | |||||
from getpass import getpass | |||||
import logging | import logging | ||||
import logging.handlers | import logging.handlers | ||||
from os import mkdir, path | from os import mkdir, path | ||||
@@ -53,44 +29,36 @@ import yaml | |||||
from earwigbot import blowfish | from earwigbot import blowfish | ||||
__all__ = ["config"] | |||||
class _ConfigNode(object): | |||||
def __iter__(self): | |||||
for key in self.__dict__.iterkeys(): | |||||
yield key | |||||
def __getitem__(self, item): | |||||
return self.__dict__.__getitem__(item) | |||||
def _dump(self): | |||||
data = self.__dict__.copy() | |||||
for key, val in data.iteritems(): | |||||
if isinstance(val, _ConfigNode): | |||||
data[key] = val._dump() | |||||
return data | |||||
def _load(self, data): | |||||
self.__dict__ = data.copy() | |||||
def _decrypt(self, key, intermediates, item): | |||||
base = self.__dict__ | |||||
try: | |||||
for inter in intermediates: | |||||
base = base[inter] | |||||
except KeyError: | |||||
return | |||||
if item in base: | |||||
base[item] = blowfish.decrypt(key, base[item]) | |||||
def get(self, *args, **kwargs): | |||||
return self.__dict__.get(*args, **kwargs) | |||||
class _BotConfig(object): | |||||
def __init__(self): | |||||
self._script_dir = path.dirname(path.abspath(__file__)) | |||||
self._root_dir = path.split(self._script_dir)[0] | |||||
class BotConfig(object): | |||||
""" | |||||
EarwigBot's YAML Config File Manager | |||||
This handles all tasks involving reading and writing to our config file, | |||||
including encrypting and decrypting passwords and making a new config file | |||||
from scratch at the inital bot run. | |||||
BotConfig has a few properties and functions, including the following: | |||||
* config.root_dir - bot's working directory; contains config.yml, logs/ | |||||
* config.path - path to the bot's config file | |||||
* config.components - enabled components | |||||
* config.wiki - information about wiki-editing | |||||
* config.tasks - information for bot tasks | |||||
* config.irc - information about IRC | |||||
* config.metadata - miscellaneous information | |||||
* config.schedule() - tasks scheduled to run at a given time | |||||
BotConfig also has some functions used in config loading: | |||||
* config.load() - loads and parses our config file, returning True if | |||||
passwords are stored encrypted or False otherwise; | |||||
can also be used to easily reload config | |||||
* config.decrypt() - given a key, decrypts passwords inside our config | |||||
variables, and remembers to decrypt the password if | |||||
config is reloaded; won't do anything if passwords | |||||
aren't encrypted | |||||
""" | |||||
def __init__(self, root_dir): | |||||
self._root_dir = root_dir | |||||
self._config_path = path.join(self._root_dir, "config.yml") | self._config_path = path.join(self._root_dir, "config.yml") | ||||
self._log_dir = path.join(self._root_dir, "logs") | self._log_dir = path.join(self._root_dir, "logs") | ||||
self._decryption_key = None | self._decryption_key = None | ||||
@@ -104,17 +72,17 @@ class _BotConfig(object): | |||||
self._nodes = [self._components, self._wiki, self._tasks, self._irc, | self._nodes = [self._components, self._wiki, self._tasks, self._irc, | ||||
self._metadata] | self._metadata] | ||||
self._decryptable_nodes = [] | |||||
def _load(self): | def _load(self): | ||||
"""Load data from our JSON config file (config.yml) into _config.""" | |||||
"""Load data from our JSON config file (config.yml) into self._data.""" | |||||
filename = self._config_path | filename = self._config_path | ||||
with open(filename, 'r') as fp: | with open(filename, 'r') as fp: | ||||
try: | try: | ||||
self._data = yaml.load(fp) | self._data = yaml.load(fp) | ||||
except yaml.YAMLError as error: | except yaml.YAMLError as error: | ||||
print "Error parsing config file {0}:".format(filename) | print "Error parsing config file {0}:".format(filename) | ||||
print error | |||||
exit(1) | |||||
raise | |||||
def _setup_logging(self): | def _setup_logging(self): | ||||
"""Configures the logging module so it works the way we want it to.""" | """Configures the logging module so it works the way we want it to.""" | ||||
@@ -135,7 +103,7 @@ class _BotConfig(object): | |||||
else: | else: | ||||
msg = "log_dir ({0}) exists but is not a directory!" | msg = "log_dir ({0}) exists but is not a directory!" | ||||
print msg.format(log_dir) | print msg.format(log_dir) | ||||
exit(1) | |||||
return | |||||
main_handler = hand(logfile("bot.log"), "midnight", 1, 7) | main_handler = hand(logfile("bot.log"), "midnight", 1, 7) | ||||
error_handler = hand(logfile("error.log"), "W6", 1, 4) | error_handler = hand(logfile("error.log"), "W6", 1, 4) | ||||
@@ -149,27 +117,29 @@ class _BotConfig(object): | |||||
h.setFormatter(formatter) | h.setFormatter(formatter) | ||||
logger.addHandler(h) | logger.addHandler(h) | ||||
stream_handler = logging.StreamHandler() | |||||
stream_handler.setLevel(logging.DEBUG) | |||||
stream_handler.setFormatter(color_formatter) | |||||
logger.addHandler(stream_handler) | |||||
stream_handler = logging.StreamHandler() | |||||
stream_handler.setLevel(logging.DEBUG) | |||||
stream_handler.setFormatter(color_formatter) | |||||
logger.addHandler(stream_handler) | |||||
else: | |||||
logger.addHandler(logging.NullHandler()) | |||||
def _decrypt(self, node, nodes): | |||||
"""Try to decrypt the contents of a config node. Use self.decrypt().""" | |||||
try: | |||||
node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) | |||||
except blowfish.BlowfishError as error: | |||||
print "Error decrypting passwords:" | |||||
raise | |||||
def _make_new(self): | def _make_new(self): | ||||
"""Make a new config file based on the user's input.""" | """Make a new config file based on the user's input.""" | ||||
encrypt = raw_input("Would you like to encrypt passwords stored in config.yml? [y/n] ") | |||||
if encrypt.lower().startswith("y"): | |||||
is_encrypted = True | |||||
else: | |||||
is_encrypted = False | |||||
return is_encrypted | |||||
@property | |||||
def script_dir(self): | |||||
return self._script_dir | |||||
#m = "Would you like to encrypt passwords stored in config.yml? [y/n] " | |||||
#encrypt = raw_input(m) | |||||
#if encrypt.lower().startswith("y"): | |||||
# is_encrypted = True | |||||
#else: | |||||
# is_encrypted = False | |||||
raise NotImplementedError() | |||||
# yaml.dumps() | |||||
@property | @property | ||||
def root_dir(self): | def root_dir(self): | ||||
@@ -182,7 +152,7 @@ class _BotConfig(object): | |||||
@property | @property | ||||
def log_dir(self): | def log_dir(self): | ||||
return self._log_dir | return self._log_dir | ||||
@property | @property | ||||
def data(self): | def data(self): | ||||
"""The entire config file.""" | """The entire config file.""" | ||||
@@ -221,7 +191,7 @@ class _BotConfig(object): | |||||
"""Return True if passwords are encrypted, otherwise False.""" | """Return True if passwords are encrypted, otherwise False.""" | ||||
return self.metadata.get("encryptPasswords", False) | return self.metadata.get("encryptPasswords", False) | ||||
def load(self, config_path=None, log_dir=None): | |||||
def load(self): | |||||
"""Load, or reload, our config file. | """Load, or reload, our config file. | ||||
First, check if we have a valid config file, and if not, notify the | First, check if we have a valid config file, and if not, notify the | ||||
@@ -232,19 +202,14 @@ class _BotConfig(object): | |||||
wiki, tasks, irc, metadata) for easy access (as well as the internal | wiki, tasks, irc, metadata) for easy access (as well as the internal | ||||
_data variable). | _data variable). | ||||
If everything goes well, return True if stored passwords are | |||||
encrypted in the file, or False if they are not. | |||||
If config is being reloaded, encrypted items will be automatically | |||||
decrypted if they were decrypted beforehand. | |||||
""" | """ | ||||
if config_path: | |||||
self._config_path = config_path | |||||
if log_dir: | |||||
self._log_dir = log_dir | |||||
if not path.exists(self._config_path): | if not path.exists(self._config_path): | ||||
print "You haven't configured the bot yet!" | print "You haven't configured the bot yet!" | ||||
choice = raw_input("Would you like to do this now? [y/n] ") | choice = raw_input("Would you like to do this now? [y/n] ") | ||||
if choice.lower().startswith("y"): | if choice.lower().startswith("y"): | ||||
return self._make_new() | |||||
self._make_new() | |||||
else: | else: | ||||
exit(1) | exit(1) | ||||
@@ -257,25 +222,28 @@ class _BotConfig(object): | |||||
self.metadata._load(data.get("metadata", {})) | self.metadata._load(data.get("metadata", {})) | ||||
self._setup_logging() | self._setup_logging() | ||||
return self.is_encrypted() | |||||
if self.is_encrypted(): | |||||
if not self._decryption_key: | |||||
key = getpass("Enter key to decrypt bot passwords: ") | |||||
self._decryption_key = key | |||||
for node, nodes in self._decryptable_nodes: | |||||
self._decrypt(node, nodes) | |||||
def decrypt(self, node, *nodes): | def decrypt(self, node, *nodes): | ||||
"""Use self._decryption_key to decrypt an object in our config tree. | """Use self._decryption_key to decrypt an object in our config tree. | ||||
If this is called when passwords are not encrypted (check with | If this is called when passwords are not encrypted (check with | ||||
config.is_encrypted()), nothing will happen. | |||||
config.is_encrypted()), nothing will happen. We'll also keep track of | |||||
this node if config.load() is called again (i.e. to reload) and | |||||
automatically decrypt it. | |||||
An example usage would be: | |||||
Example usage: | |||||
config.decrypt(config.irc, "frontend", "nickservPassword") | config.decrypt(config.irc, "frontend", "nickservPassword") | ||||
-> decrypts config.irc["frontend"]["nickservPassword"] | |||||
""" | """ | ||||
if not self.is_encrypted(): | |||||
return | |||||
try: | |||||
node._decrypt(self._decryption_key, nodes[:-1], nodes[-1]) | |||||
except blowfish.BlowfishError as error: | |||||
print "\nError decrypting passwords:" | |||||
print "{0}: {1}.".format(error.__class__.__name__, error) | |||||
exit(1) | |||||
self._decryptable_nodes.append((node, nodes)) | |||||
if self.is_encrypted(): | |||||
self._decrypt(node, nodes) | |||||
def schedule(self, minute, hour, month_day, month, week_day): | def schedule(self, minute, hour, month_day, month, week_day): | ||||
"""Return a list of tasks scheduled to run at the specified time. | """Return a list of tasks scheduled to run at the specified time. | ||||
@@ -311,6 +279,38 @@ class _BotConfig(object): | |||||
return tasks | return tasks | ||||
class _ConfigNode(object): | |||||
def __iter__(self): | |||||
for key in self.__dict__.iterkeys(): | |||||
yield key | |||||
def __getitem__(self, item): | |||||
return self.__dict__.__getitem__(item) | |||||
def _dump(self): | |||||
data = self.__dict__.copy() | |||||
for key, val in data.iteritems(): | |||||
if isinstance(val, _ConfigNode): | |||||
data[key] = val._dump() | |||||
return data | |||||
def _load(self, data): | |||||
self.__dict__ = data.copy() | |||||
def _decrypt(self, key, intermediates, item): | |||||
base = self.__dict__ | |||||
try: | |||||
for inter in intermediates: | |||||
base = base[inter] | |||||
except KeyError: | |||||
return | |||||
if item in base: | |||||
base[item] = blowfish.decrypt(key, base[item]) | |||||
def get(self, *args, **kwargs): | |||||
return self.__dict__.get(*args, **kwargs) | |||||
class _BotFormatter(logging.Formatter): | class _BotFormatter(logging.Formatter): | ||||
def __init__(self, color=False): | def __init__(self, color=False): | ||||
self._format = super(_BotFormatter, self).format | self._format = super(_BotFormatter, self).format | ||||
@@ -336,6 +336,3 @@ class _BotFormatter(logging.Formatter): | |||||
if record.levelno == logging.CRITICAL: | if record.levelno == logging.CRITICAL: | ||||
record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | ||||
return record | return record | ||||
config = _BotConfig() |
@@ -22,6 +22,7 @@ | |||||
import socket | import socket | ||||
import threading | import threading | ||||
from time import sleep | |||||
__all__ = ["BrokenSocketException", "IRCConnection"] | __all__ = ["BrokenSocketException", "IRCConnection"] | ||||
@@ -42,7 +43,7 @@ class IRCConnection(object): | |||||
self.ident = ident | self.ident = ident | ||||
self.realname = realname | self.realname = realname | ||||
self.logger = logger | self.logger = logger | ||||
self.is_running = False | |||||
self._is_running = False | |||||
# A lock to prevent us from sending two messages at once: | # A lock to prevent us from sending two messages at once: | ||||
self._lock = threading.Lock() | self._lock = threading.Lock() | ||||
@@ -53,8 +54,9 @@ class IRCConnection(object): | |||||
try: | try: | ||||
self._sock.connect((self.host, self.port)) | self._sock.connect((self.host, self.port)) | ||||
except socket.error: | except socket.error: | ||||
self.logger.critical("Couldn't connect to IRC server", exc_info=1) | |||||
exit(1) | |||||
self.logger.exception("Couldn't connect to IRC server") | |||||
sleep(8) | |||||
self._connect() | |||||
self._send("NICK {0}".format(self.nick)) | self._send("NICK {0}".format(self.nick)) | ||||
self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | ||||
@@ -68,7 +70,7 @@ class IRCConnection(object): | |||||
def _get(self, size=4096): | def _get(self, size=4096): | ||||
"""Receive (i.e. get) data from the server.""" | """Receive (i.e. get) data from the server.""" | ||||
data = self._sock.recv(4096) | |||||
data = self._sock.recv(size) | |||||
if not data: | if not data: | ||||
# Socket isn't giving us any data, so it is dead or broken: | # Socket isn't giving us any data, so it is dead or broken: | ||||
raise BrokenSocketException() | raise BrokenSocketException() | ||||
@@ -121,21 +123,38 @@ class IRCConnection(object): | |||||
msg = "PONG {0}".format(target) | msg = "PONG {0}".format(target) | ||||
self._send(msg) | self._send(msg) | ||||
def quit(self, msg=None): | |||||
"""Issue a quit message to the server.""" | |||||
if msg: | |||||
self._send("QUIT {0}".format(msg)) | |||||
else: | |||||
self._send("QUIT") | |||||
def loop(self): | def loop(self): | ||||
"""Main loop for the IRC connection.""" | """Main loop for the IRC connection.""" | ||||
self.is_running = True | |||||
self._is_running = True | |||||
read_buffer = "" | read_buffer = "" | ||||
while 1: | while 1: | ||||
try: | try: | ||||
read_buffer += self._get() | read_buffer += self._get() | ||||
except BrokenSocketException: | except BrokenSocketException: | ||||
self.is_running = False | |||||
self._is_running = False | |||||
break | break | ||||
lines = read_buffer.split("\n") | lines = read_buffer.split("\n") | ||||
read_buffer = lines.pop() | read_buffer = lines.pop() | ||||
for line in lines: | for line in lines: | ||||
self._process_message(line) | self._process_message(line) | ||||
if not self.is_running: | |||||
if self.is_stopped(): | |||||
self._close() | self._close() | ||||
break | break | ||||
def stop(self): | |||||
"""Request the IRC connection to close at earliest convenience.""" | |||||
if self._is_running: | |||||
self.quit() | |||||
self._is_running = False | |||||
def is_stopped(self): | |||||
"""Return whether the IRC connection has been (or is to be) closed.""" | |||||
return not self._is_running |
@@ -25,7 +25,6 @@ import re | |||||
from earwigbot.commands import command_manager | from earwigbot.commands import command_manager | ||||
from earwigbot.irc import IRCConnection, Data, BrokenSocketException | from earwigbot.irc import IRCConnection, Data, BrokenSocketException | ||||
from earwigbot.config import config | |||||
__all__ = ["Frontend"] | __all__ = ["Frontend"] | ||||
@@ -41,7 +40,8 @@ class Frontend(IRCConnection): | |||||
""" | """ | ||||
sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | ||||
def __init__(self): | |||||
def __init__(self, config): | |||||
self.config = config | |||||
self.logger = logging.getLogger("earwigbot.frontend") | self.logger = logging.getLogger("earwigbot.frontend") | ||||
cf = config.irc["frontend"] | cf = config.irc["frontend"] | ||||
base = super(Frontend, self) | base = super(Frontend, self) | ||||
@@ -66,7 +66,7 @@ class Frontend(IRCConnection): | |||||
data.msg = " ".join(line[3:])[1:] | data.msg = " ".join(line[3:])[1:] | ||||
data.chan = line[2] | data.chan = line[2] | ||||
if data.chan == config.irc["frontend"]["nick"]: | |||||
if data.chan == self.config.irc["frontend"]["nick"]: | |||||
# This is a privmsg to us, so set 'chan' as the nick of the | # This is a privmsg to us, so set 'chan' as the nick of the | ||||
# sender, then check for private-only command hooks: | # sender, then check for private-only command hooks: | ||||
data.chan = data.nick | data.chan = data.nick | ||||
@@ -86,8 +86,8 @@ class Frontend(IRCConnection): | |||||
elif line[1] == "376": | elif line[1] == "376": | ||||
# If we're supposed to auth to NickServ, do that: | # If we're supposed to auth to NickServ, do that: | ||||
try: | try: | ||||
username = config.irc["frontend"]["nickservUsername"] | |||||
password = config.irc["frontend"]["nickservPassword"] | |||||
username = self.config.irc["frontend"]["nickservUsername"] | |||||
password = self.config.irc["frontend"]["nickservPassword"] | |||||
except KeyError: | except KeyError: | ||||
pass | pass | ||||
else: | else: | ||||
@@ -95,5 +95,5 @@ class Frontend(IRCConnection): | |||||
self.say("NickServ", msg) | self.say("NickServ", msg) | ||||
# Join all of our startup channels: | # Join all of our startup channels: | ||||
for chan in config.irc["frontend"]["channels"]: | |||||
for chan in self.config.irc["frontend"]["channels"]: | |||||
self.join(chan) | self.join(chan) |
@@ -24,7 +24,6 @@ import imp | |||||
import logging | import logging | ||||
from earwigbot.irc import IRCConnection, RC, BrokenSocketException | from earwigbot.irc import IRCConnection, RC, BrokenSocketException | ||||
from earwigbot.config import config | |||||
__all__ = ["Watcher"] | __all__ = ["Watcher"] | ||||
@@ -39,7 +38,8 @@ class Watcher(IRCConnection): | |||||
to channels on the IRC frontend. | to channels on the IRC frontend. | ||||
""" | """ | ||||
def __init__(self, frontend=None): | |||||
def __init__(self, config, frontend=None): | |||||
self.config = config | |||||
self.logger = logging.getLogger("earwigbot.watcher") | self.logger = logging.getLogger("earwigbot.watcher") | ||||
cf = config.irc["watcher"] | cf = config.irc["watcher"] | ||||
base = super(Watcher, self) | base = super(Watcher, self) | ||||
@@ -58,7 +58,7 @@ class Watcher(IRCConnection): | |||||
# Ignore messages originating from channels not in our list, to | # Ignore messages originating from channels not in our list, to | ||||
# prevent someone PMing us false data: | # prevent someone PMing us false data: | ||||
if chan not in config.irc["watcher"]["channels"]: | |||||
if chan not in self.config.irc["watcher"]["channels"]: | |||||
return | return | ||||
msg = " ".join(line[3:])[1:] | msg = " ".join(line[3:])[1:] | ||||
@@ -72,7 +72,7 @@ class Watcher(IRCConnection): | |||||
# When we've finished starting up, join all watcher channels: | # When we've finished starting up, join all watcher channels: | ||||
elif line[1] == "376": | elif line[1] == "376": | ||||
for chan in config.irc["watcher"]["channels"]: | |||||
for chan in self.config.irc["watcher"]["channels"]: | |||||
self.join(chan) | self.join(chan) | ||||
def _prepare_process_hook(self): | def _prepare_process_hook(self): | ||||
@@ -84,12 +84,12 @@ class Watcher(IRCConnection): | |||||
# Set a default RC process hook that does nothing: | # Set a default RC process hook that does nothing: | ||||
self._process_hook = lambda rc: () | self._process_hook = lambda rc: () | ||||
try: | try: | ||||
rules = config.data["rules"] | |||||
rules = self.config.data["rules"] | |||||
except KeyError: | except KeyError: | ||||
return | return | ||||
module = imp.new_module("_rc_event_processing_rules") | module = imp.new_module("_rc_event_processing_rules") | ||||
try: | try: | ||||
exec compile(rules, config.path, "exec") in module.__dict__ | |||||
exec compile(rules, self.config.path, "exec") in module.__dict__ | |||||
except Exception: | except Exception: | ||||
e = "Could not compile config file's RC event rules" | e = "Could not compile config file's RC event rules" | ||||
self.logger.exception(e) | self.logger.exception(e) | ||||
@@ -1,132 +0,0 @@ | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
""" | |||||
EarwigBot's Main Module | |||||
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 | |||||
different components follows: | |||||
EarwigBot has three components that can run independently of each other: an IRC | |||||
front-end, an IRC watcher, and a wiki scheduler. | |||||
* The IRC front-end runs on a normal IRC server and expects users to interact | |||||
with it/give it commands. | |||||
* The IRC watcher runs on a wiki recent-changes server and listens for edits. | |||||
Users cannot interact with this part of the bot. | |||||
* The wiki scheduler runs wiki-editing bot tasks in separate threads at | |||||
user-defined times through a cron-like interface. | |||||
There is a "priority" system here: | |||||
1. If the IRC frontend is enabled, it will run on the main thread, and the IRC | |||||
watcher and wiki scheduler (if enabled) will run on separate threads. | |||||
2. If the wiki scheduler is enabled, it will run on the main thread, and the | |||||
IRC watcher (if enabled) will run on a separate thread. | |||||
3. If the IRC watcher is enabled, it will run on the main (and only) thread. | |||||
Else, the bot will stop, as no components are enabled. | |||||
""" | |||||
import logging | |||||
import threading | |||||
import time | |||||
from earwigbot.config import config | |||||
from earwigbot.irc import Frontend, Watcher | |||||
from earwigbot.tasks import task_manager | |||||
logger = logging.getLogger("earwigbot") | |||||
def irc_watcher(frontend=None): | |||||
"""Function to handle the IRC watcher as another thread (if frontend and/or | |||||
scheduler is enabled), otherwise run as the main thread.""" | |||||
while 1: # Restart the watcher component if it breaks (and nothing else) | |||||
watcher = Watcher(frontend) | |||||
try: | |||||
watcher.loop() | |||||
except: | |||||
logger.exception("Watcher had an error") | |||||
time.sleep(5) # Sleep a bit before restarting watcher | |||||
logger.warn("Watcher has stopped; restarting component") | |||||
def wiki_scheduler(): | |||||
"""Function to handle the wiki scheduler as another thread, or as the | |||||
primary thread if the IRC frontend is not enabled.""" | |||||
while 1: | |||||
time_start = time.time() | |||||
task_manager.schedule() | |||||
time_end = time.time() | |||||
time_diff = time_start - time_end | |||||
if time_diff < 60: # Sleep until the next minute | |||||
time.sleep(60 - time_diff) | |||||
def irc_frontend(): | |||||
"""If the IRC frontend is enabled, make it run on our primary thread, and | |||||
enable the wiki scheduler and IRC watcher on new threads if they are | |||||
enabled.""" | |||||
logger.info("Starting IRC frontend") | |||||
frontend = Frontend() | |||||
if config.components.get("wiki_schedule"): | |||||
logger.info("Starting wiki scheduler") | |||||
task_manager.load() | |||||
t_scheduler = threading.Thread(target=wiki_scheduler) | |||||
t_scheduler.name = "wiki-scheduler" | |||||
t_scheduler.daemon = True | |||||
t_scheduler.start() | |||||
if config.components.get("irc_watcher"): | |||||
logger.info("Starting IRC watcher") | |||||
t_watcher = threading.Thread(target=irc_watcher, args=(frontend,)) | |||||
t_watcher.name = "irc-watcher" | |||||
t_watcher.daemon = True | |||||
t_watcher.start() | |||||
frontend.loop() | |||||
def main(): | |||||
if config.components.get("irc_frontend"): | |||||
# Make the frontend run on our primary thread if enabled, and enable | |||||
# additional components through that function: | |||||
irc_frontend() | |||||
elif config.components.get("wiki_schedule"): | |||||
# Run the scheduler on the main thread, but also run the IRC watcher on | |||||
# another thread iff it is enabled: | |||||
logger.info("Starting wiki scheduler") | |||||
task_manager.load() | |||||
if "irc_watcher" in enabled: | |||||
logger.info("Starting IRC watcher") | |||||
t_watcher = threading.Thread(target=irc_watcher) | |||||
t_watcher.name = "irc-watcher" | |||||
t_watcher.daemon = True | |||||
t_watcher.start() | |||||
wiki_scheduler() | |||||
elif config.components.get("irc_watcher"): | |||||
# The IRC watcher is our only enabled component, so run its function | |||||
# only and don't worry about anything else: | |||||
logger.info("Starting IRC watcher") | |||||
irc_watcher() | |||||
else: # Nothing is enabled! | |||||
logger.critical("No bot parts are enabled; stopping") | |||||
exit(1) |
@@ -1,65 +0,0 @@ | |||||
#! /usr/bin/env python | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
""" | |||||
EarwigBot Runner | |||||
This is a very simple script that can be run from anywhere. It will add the | |||||
'earwigbot' package to sys.path if it's not already in there (i.e., it hasn't | |||||
been "installed"), accept a root_dir (the directory in which bot.py is located) | |||||
and a decryption key from raw_input (if passwords are encrypted), then call | |||||
config.load() and decrypt any passwords, and finally call the main() function | |||||
of earwigbot.main. | |||||
""" | |||||
from os import path | |||||
import sys | |||||
def run(): | |||||
pkg_dir = path.split(path.dirname(path.abspath(__file__)))[0] | |||||
if pkg_dir not in sys.path: | |||||
sys.path.insert(0, pkg_dir) | |||||
from earwigbot.config import config | |||||
from earwigbot import main | |||||
root_dir = raw_input() | |||||
config_path = path.join(root_dir, "config.yml") | |||||
log_dir = path.join(root_dir, "logs") | |||||
is_encrypted = config.load(config_path, log_dir) | |||||
if is_encrypted: | |||||
config._decryption_key = raw_input() | |||||
config.decrypt(config.wiki, "password") | |||||
config.decrypt(config.wiki, "search", "credentials", "key") | |||||
config.decrypt(config.wiki, "search", "credentials", "secret") | |||||
config.decrypt(config.irc, "frontend", "nickservPassword") | |||||
config.decrypt(config.irc, "watcher", "nickservPassword") | |||||
try: | |||||
main.main() | |||||
except KeyboardInterrupt: | |||||
main.logger.critical("KeyboardInterrupt: stopping main bot loop") | |||||
exit(1) | |||||
if __name__ == "__main__": | |||||
run() |
@@ -213,10 +213,6 @@ class _TaskManager(object): | |||||
task_thread = threading.Thread(target=func) | task_thread = threading.Thread(target=func) | ||||
start_time = time.strftime("%b %d %H:%M:%S") | start_time = time.strftime("%b %d %H:%M:%S") | ||||
task_thread.name = "{0} ({1})".format(task_name, start_time) | task_thread.name = "{0} ({1})".format(task_name, start_time) | ||||
# Stop bot task threads automagically if the main bot stops: | |||||
task_thread.daemon = True | |||||
task_thread.start() | task_thread.start() | ||||
def get(self, task_name): | def get(self, task_name): | ||||
@@ -0,0 +1,50 @@ | |||||
#! /usr/bin/env python | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
import argparse | |||||
from os import path | |||||
from earwigbot import __version__ | |||||
from earwigbot.bot import Bot | |||||
class BotUtility(object): | |||||
""" | |||||
DOCSTRING NEEDED | |||||
""" | |||||
def version(self): | |||||
return __version__ | |||||
def run(self): | |||||
print "EarwigBot v{0}\n".format(self.version()) | |||||
def main(self): | |||||
root_dir = path.abspath(path.curdir()) | |||||
bot = Bot(root_dir) | |||||
bot.run() | |||||
main = BotUtility().main | |||||
if __name__ == "__main__": | |||||
main() |
@@ -0,0 +1,40 @@ | |||||
#! /usr/bin/env python | |||||
# -*- coding: utf-8 -*- | |||||
# | |||||
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
# | |||||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
# of this software and associated documentation files (the "Software"), to deal | |||||
# in the Software without restriction, including without limitation the rights | |||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
# copies of the Software, and to permit persons to whom the Software is | |||||
# furnished to do so, subject to the following conditions: | |||||
# | |||||
# The above copyright notice and this permission notice shall be included in | |||||
# all copies or substantial portions of the Software. | |||||
# | |||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
# SOFTWARE. | |||||
""" | |||||
DOCSTRING NEEDED | |||||
""" | |||||
from setuptools import setup | |||||
setup( | |||||
name = "earwigbot", | |||||
entry_points = {"console_scripts": ["earwigbot = earwigbot.util:main"]}, | |||||
install_requires = ["PyYAML>=3.10", "oursql>=0.9.3", "oauth2>=1.5.211", | |||||
"numpy>=1.6.1", "matplotlib>=1.1.0"], | |||||
version = "0.1.dev", | |||||
author = "Ben Kurtovic", | |||||
author_email = "ben.kurtovic@verizon.net", | |||||
license = "MIT License", | |||||
url = "https://github.com/earwig/earwigbot", | |||||
) |
@@ -23,7 +23,7 @@ | |||||
""" | """ | ||||
EarwigBot's Unit Tests | EarwigBot's Unit Tests | ||||
This module __init__ file provides some support code for unit tests. | |||||
This package __init__ file provides some support code for unit tests. | |||||
CommandTestCase is a subclass of unittest.TestCase that provides setUp() for | CommandTestCase is a subclass of unittest.TestCase that provides setUp() for | ||||
creating a fake connection and some other helpful methods. It uses | creating a fake connection and some other helpful methods. It uses | ||||
@@ -92,6 +92,7 @@ class CommandTestCase(TestCase): | |||||
line = ":Foo!bar@example.com JOIN :#channel".strip().split() | line = ":Foo!bar@example.com JOIN :#channel".strip().split() | ||||
return self.maker(line, line[2][1:]) | return self.maker(line, line[2][1:]) | ||||
class FakeConnection(IRCConnection): | class FakeConnection(IRCConnection): | ||||
def __init__(self): | def __init__(self): | ||||
pass | pass |