diff --git a/DrBotIRC.py b/DrBotIRC.py index 5b4523c..af7e44d 100644 --- a/DrBotIRC.py +++ b/DrBotIRC.py @@ -16,7 +16,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from ConfigParser import NoOptionError, NoSectionError +import re +import signal import socket +import sys from extlib import irclib @@ -55,12 +59,250 @@ class DrBotServerConnection(irclib.ServerConnection): class DrBotIRC(irclib.IRC): - """Subclass irclib's IRC, in order to create a DrBotServerConnection.""" + """Implement a customized irclib IRC.""" + + modlist = [] + config = None + server = None + + def __init__(self, config): + irclib.IRC.__init__(self) + + self.config = config + + # handle SIGINT + signal.signal(signal.SIGINT, self.sigint_handler) def server(self): - c = DrBotServerConnection(self) - self.connections.append(c) - return c + """Create a DrBotServerConnection.""" + self.server = DrBotServerConnection(self) + self.connections.append(self.server) + + self.server.add_global_handler('pubmsg', self.on_pubmsg, 1) + self.server.add_global_handler('privmsg', self.on_pubmsg, 1) + + return self.server + + def on_pubmsg(self, connection, event): + """See if there is an alias ("!command") in the text, and if so, translate it into + an internal bot command and run it. + """ + + nick = irclib.nm_to_n(event.source()) + userhost = irclib.nm_to_uh(event.source()) + replypath = event.target() + what = event.arguments()[0] + admin_unlocked = False + + try: + if userhost == self.config.get('dr.botzo', 'admin_userhost'): + admin_unlocked = True + except NoOptionError: pass + + return self.try_recursion(connection, event, nick, userhost, replypath, what, admin_unlocked) + + def try_alias(self, connection, event, nick, userhost, replypath, what, admin_unlocked): + # first see if the aliases are being directly manipulated via add/remove + whats = what.split(' ') + try: + if whats[0] == '!alias' and whats[1] == 'add' and len(whats) >= 4: + if not self.config.has_section('Alias'): + self.config.add_section('Alias') + + self.config.set('Alias', whats[2], ' '.join(whats[3:])) + replystr = 'Added alias ' + whats[2] + '.' + return self.reply(connection, replypath, replystr) + if whats[0] == '!alias' and whats[1] == 'remove' and len(whats) >= 3: + if not self.config.has_section('Alias'): + self.config.add_section('Alias') + + if self.config.remove_option('Alias', whats[2]): + replystr = 'Removed alias ' + whats[2] + '.' + return self.reply(connection, replypath, replystr) + elif whats[0] == '!alias' and whats[1] == 'list': + try: + if len(whats) > 2: + alias = self.config.get('Alias', whats[2]) + return self.reply(connection, replypath, alias) + else: + alist = self.config.options('Alias') + alist.sort() + liststr = ', '.join(alist) + return self.reply(connection, replypath, liststr) + except NoSectionError: pass + except NoOptionError: pass + except NoSectionError: pass + + # done searching for recursions in this level. we will now operate on + # whatever recursion-satisfied string we have, checking for alias and + # running module commands + + # now that that's over, try doing alias work + try: + alias_list = self.config.options('Alias') + + for alias in alias_list: + alias_re = re.compile(alias) + if alias_re.search(what): + # we found an alias for our given string, doing a replace + command = re.sub(alias, self.config.get('Alias', alias), what) + + # need to do another recursion scan + ret = self.try_recursion(connection, event, nick, userhost, None, command, admin_unlocked) + return self.reply(connection, replypath, ret) + except NoOptionError: pass + except NoSectionError: pass + + # if we got here, there are no matching aliases, so return what we got + return self.reply(connection, replypath, what) + + def reply(self, connection, replypath, replystr, stop_responding=False): + """ + Reply over IRC to replypath or return a string with the reply. + Utility method to do the proper type of reply (either to IRC, or as a return + to caller) depending on the target. Pretty simple, and included in the base + class for convenience. It should be the last step for callers: + + return self.reply(connection, replypath, 'hello') + """ + + if replystr is not None: + if replypath is None: + return replystr + else: + connection.privmsg(replypath, replystr) + if stop_responding: + return "NO MORE" + + def try_recursion(self, connection, event, nick, userhost, replypath, what, admin_unlocked): + """Scan message for subcommands to execute and use as part of this command. + + Upon seeing a line intended for this module, see if there are subcommands + that we should do what is basically a text replacement on. The intent is to + allow things like the following: + + command arg1 [anothercommand arg1 arg2] + + where the output of anothercommand is command's arg2..n. + """ + + attempt = what + start_idx = attempt.find('[') + subcmd = attempt[start_idx+1:] + end_idx = subcmd.rfind(']') + subcmd = subcmd[:end_idx] + + if start_idx == -1 or end_idx == -1 or len(subcmd) == 0: + # no recursion, now let's look for aliases + command = self.try_alias(connection, event, nick, userhost, None, attempt, admin_unlocked) + + # aliases resolved. run result against each module + for module in self.modlist: + ret = module.do(connection, event, nick, userhost, None, command, admin_unlocked) + if ret is not None: + # a module had a result for us, post-alias, so return it + # TODO: scan all modules with compounding results + return self.reply(connection, replypath, ret) + + # if we got here, text isn't a command, so pass it back, but ONLY if the output + # is different from the input + if replypath is None or what != command: + return self.reply(connection, replypath, command) + else: + ret = self.try_recursion(connection, event, nick, userhost, None, subcmd, admin_unlocked) + if ret is not None: + return self.try_alias(connection, event, nick, userhost, replypath, attempt.replace('['+subcmd+']', ret), admin_unlocked) + else: + return self.try_alias(connection, event, nick, userhost, replypath, attempt, admin_unlocked) + + def quit_irc(self, connection, msg): + for module in self.modlist: + module.shutdown() + + connection.quit(msg) + self.save_config() + sys.exit() + + def save_config(self): + with open('dr.botzo.cfg', 'w') as cfg: + self.config.write(cfg) + + for module in self.modlist: + module.save() + + return 'Saved.' + + def load_module(self, modname): + """Load a module (in both the python and dr.botzo sense) if not + already loaded. + """ + + for module in self.modlist: + if modname == module.__class__.__name__: + return 'Module ' + modname + ' is already loaded.' + + # not loaded, let's get to work + try: + modstr = 'modules.'+modname + __import__(modstr) + module = sys.modules[modstr] + botmod = eval('module.' + modname + '(self, self.config, self.server)') + self.modlist.append(botmod) + botmod.register_handlers(self.server) + + # might as well add it to the list + modset = set(self.config.get('dr.botzo', 'module_list').split(',')) + modset.add(modname) + self.config.set('dr.botzo', 'module_list', ','.join(modset)) + + return 'Module ' + modname + ' loaded.' + except ImportError: + return 'Module ' + modname + ' not found.' + + def unload_module(self, modname): + """Attempt to unload and del a module if it's loaded.""" + + modstr = 'modules.'+modname + for module in self.modlist: + if modname == module.__class__.__name__: + # do anything the module needs to do to clean up + module.shutdown() + + # remove module references + self.modlist.remove(module) + module.unregister_handlers() + + # del it + del(sys.modules[modstr]) + del(module) + + # might as well remove it from the list + modset = set(self.config.get('dr.botzo', 'module_list').split(',')) + modset.remove(modname) + self.config.set('dr.botzo', 'module_list', ','.join(modset)) + + return 'Module ' + modname + ' unloaded.' + + # guess it was never loaded + return 'Module ' + modname + ' is not loaded.' + + def reload_module(self, modname): + """Attempt to reload a module, by removing it from memory and then + re-initializing it. + """ + + ret = self.unload_module(modname) + if ret == 'Module ' + modname + ' unloaded.': + ret = self.load_module(modname) + if ret == 'Module ' + modname + ' loaded.': + return 'Module ' + modname + ' reloaded.' + + return 'Module ' + modname + ' reload failed. Check the console.' + + # SIGINT signal handler + def sigint_handler(self, signal, frame): + print(self.save_config()) + sys.exit() # vi:tabstop=4:expandtab:autoindent # kate: indent-mode python;indent-width 4;replace-tabs on; diff --git a/Module.py b/Module.py index 26599d9..82f1a7d 100644 --- a/Module.py +++ b/Module.py @@ -31,21 +31,16 @@ class Module(object): def priority(self): return 50 - def __init__(self, config, server, modlist): + def __init__(self, irc, config, server): """ Construct a feature module. Inheritors should not do anything special here, instead they should implement register_handlers and do, or else this will be a very uneventful affair. - - Classes that are interested in allowing an indirect call to their do routine - should add themselves to modlist inside their __init__. This will allow other - modules to call do and see if anything can handle text they may have seen (such - as in recursive commands). """ + self.irc = irc self.config = config self.server = server - self.modlist = modlist # open database connection dbfile = self.config.get('dr.botzo', 'database') @@ -61,9 +56,6 @@ class Module(object): # set up database for this module self.db_init() - # add self to the object list - modlist.append(self) - # print what was loaded, for debugging print("loaded " + self.__class__.__name__) diff --git a/dr.botzo.py b/dr.botzo.py index 8582db7..9ede43e 100644 --- a/dr.botzo.py +++ b/dr.botzo.py @@ -26,7 +26,6 @@ import sqlite3 import DrBotIRC from extlib import irclib -modlist = [] moduleList = [ "Countdown", "Dice", "IrcAdmin", "GoogleTranslate", "Seen", "FactFile" ] modObjs = [] @@ -86,7 +85,7 @@ except NoSectionError: pass # the database doesn't need to exist # start up the IRC bot # create IRC and server objects and connect -irc = DrBotIRC.DrBotIRC() +irc = DrBotIRC.DrBotIRC(config) server = irc.server().connect(botserver, botport, botnick, botpass, botuser, botircname) # load features @@ -95,14 +94,7 @@ try: mods = cfgmodlist.split(',') for mod in mods: - # try to load each module - mod = mod.strip() - modstr = 'modules.'+mod - print "DEBUG: attempting to load module %s" % (modstr) - __import__(modstr) - module = sys.modules[modstr] - botmod = eval('module.' + mod + '(config, server, modlist)') - botmod.register_handlers(server) + irc.load_module(mod) except NoSectionError as e: print("You seem to be missing a modules config section, which you probably wanted.") except NoOptionError as e: diff --git a/modules/Alias.py b/modules/Alias.py deleted file mode 100644 index 02f0185..0000000 --- a/modules/Alias.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Alias - have internal shortcuts to commands -Copyright (C) 2010 Brian S. Stephan - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -from ConfigParser import NoOptionError, NoSectionError -import re - -from extlib import irclib - -from Module import Module - -class Alias(Module): - - """Alias commands as !command, circumventing bot addressing stuff.""" - - def priority(self): - return 1 - - def do(self, connection, event, nick, userhost, replypath, what, admin_unlocked): - """See if there is an alias ("!command") in the text, and if so, translate it into - an internal bot command and run it. - """ - - # first see if the aliases are being directly manipulated via add/remove - whats = what.split(' ') - try: - if whats[0] == '!alias' and whats[1] == 'add' and len(whats) >= 4: - if not self.config.has_section(self.__class__.__name__): - self.config.add_section(self.__class__.__name__) - - self.config.set(self.__class__.__name__, whats[2], ' '.join(whats[3:])) - replystr = 'Added alias ' + whats[2] + '.' - return self.reply(connection, replypath, replystr) - if whats[0] == '!alias' and whats[1] == 'remove' and len(whats) >= 3: - if not self.config.has_section(self.__class__.__name__): - self.config.add_section(self.__class__.__name__) - - if self.config.remove_option(self.__class__.__name__, whats[2]): - replystr = 'Removed alias ' + whats[2] + '.' - return self.reply(connection, replypath, replystr) - elif whats[0] == '!alias' and whats[1] == 'list': - try: - if len(whats) > 2: - alias = self.config.get(self.__class__.__name__, whats[2]) - return self.reply(connection, replypath, alias) - else: - alist = self.config.options(self.__class__.__name__) - self.remove_metaoptions(alist) - alist.sort() - liststr = ', '.join(alist) - return self.reply(connection, replypath, liststr) - except NoSectionError: pass - except NoOptionError: pass - except NoSectionError: pass - - # search for recursions, which will search for recursions, which ... - what = self.try_recursion(connection, event, nick, userhost, None, what, admin_unlocked) - - # done searching for recursions in this level. we will now operate on - # whatever recursion-satisfied string we have, checking for alias and - # running module commands - - # now that that's over, try doing alias work - try: - alias_list = self.config.options(self.__class__.__name__) - self.remove_metaoptions(alias_list) - - for alias in alias_list: - alias_re = re.compile(alias) - if alias_re.search(what): - command = re.sub(alias, self.config.get(self.__class__.__name__, alias), what) - # we found an alias for our given string, doing a replace - - # need to do another recursion scan - command = self.try_recursion(connection, event, nick, userhost, None, command, admin_unlocked) - - # running it against each module - for module in self.modlist: - ret = module.do(connection, event, nick, userhost, None, command, admin_unlocked) - if ret is not None: - # a module had a result for us, post-alias, so return it - # TODO: scan all modules with compounding results - return self.reply(connection, replypath, ret) - - except NoOptionError: pass - except NoSectionError: pass - - def try_recursion(self, connection, event, nick, userhost, replypath, what, admin_unlocked): - """Scan message for subcommands to execute and use as part of this command. - - Upon seeing a line intended for this module, see if there are subcommands - that we should do what is basically a text replacement on. The intent is to - allow things like the following: - - command arg1 [anothercommand arg1 arg2] - - where the output of anothercommand is command's arg2..n. - """ - - start_idx = what.find('[') - subcmd = what[start_idx+1:] - end_idx = subcmd.rfind(']') - subcmd = subcmd[:end_idx] - - attempt = what - - if start_idx == -1 or end_idx == -1 or len(subcmd) == 0: - # no alias, just returning what we got - return attempt - else: - # we have a subcmd, see if there's another one nested - # this will include more calls to Alias, which will try recursing again - for module in self.modlist: - ret = module.do(connection, event, nick, userhost, None, subcmd, admin_unlocked) - if ret is not None: - # some module had a change, so we replace [subcmd] with ret and return it - return attempt.replace('['+subcmd+']', ret) - - # we got here, no one had a replacement. return what we had - return attempt - -# vi:tabstop=4:expandtab:autoindent -# kate: indent-mode python;indent-width 4;replace-tabs on; diff --git a/modules/EightBall.py b/modules/EightBall.py index 151dc64..43e5c34 100644 --- a/modules/EightBall.py +++ b/modules/EightBall.py @@ -26,10 +26,10 @@ class EightBall(Module): """Return a random answer when asked a question.""" - def __init__(self, config, server, modlist): + def __init__(self, irc, config, server): """Initialize the list of self.responses.""" - Module.__init__(self, config, server, modlist) + Module.__init__(self, irc, config, server) self.responses = [] diff --git a/modules/IrcAdmin.py b/modules/IrcAdmin.py index f0d471e..a3f9153 100644 --- a/modules/IrcAdmin.py +++ b/modules/IrcAdmin.py @@ -33,9 +33,6 @@ class IrcAdmin(Module): server.add_global_handler('pubmsg', self.on_pubmsg, self.priority()) server.add_global_handler('privmsg', self.on_privmsg, self.priority()) - # we define save, so we'll bite the bullet and take SIGINT - signal.signal(signal.SIGINT, self.sigint_handler) - def unregister_handlers(self): self.server.remove_global_handler('welcome', self.on_connect) self.server.remove_global_handler('pubmsg', self.on_pubmsg) @@ -75,7 +72,7 @@ class IrcAdmin(Module): elif whats[0] == 'autojoin' and admin_unlocked and len(whats) >= 3: return self.sub_autojoin_manipulate(connection, event, nick, userhost, replypath, what, admin_unlocked) elif whats[0] == 'config' and whats[1] == 'save' and admin_unlocked: - return self.sub_save_config(connection, event, nick, userhost, replypath, what, admin_unlocked) + return self.reply(connection, replypath, self.irc.save_config()) elif whats[0] == 'nick' and admin_unlocked and len(whats) >= 2: return self.sub_change_nick(connection, event, nick, userhost, replypath, what, admin_unlocked) elif whats[0] == 'load' and admin_unlocked and len(whats) >= 2: @@ -106,14 +103,10 @@ class IrcAdmin(Module): def sub_quit_irc(self, connection, event, nick, userhost, replypath, what, admin_unlocked): whats = what.split(' ') - for module in self.modlist: - module.shutdown() - if replypath is not None: connection.privmsg(replypath, 'Quitting...') - connection.quit(' '.join(whats[1:])) - self.save_config() - sys.exit() + + self.irc.quit_irc(connection, ' '.join(whats[1:])) def sub_autojoin_manipulate(self, connection, event, nick, userhost, replypath, what, admin_unlocked): whats = what.split(' ') @@ -141,16 +134,6 @@ class IrcAdmin(Module): return self.reply(connection, replypath, replystr) except NoOptionError: pass - def sub_save_config(self, connection, event, nick, userhost, replypath, what, admin_unlocked): - with open('dr.botzo.cfg', 'w') as cfg: - self.config.write(cfg) - - for module in self.modlist: - module.save() - - replystr = 'Saved.' - return self.reply(connection, replypath, replystr) - def sub_change_nick(self, connection, event, nick, userhost, replypath, what, admin_unlocked): whats = what.split(' ') @@ -166,58 +149,13 @@ class IrcAdmin(Module): """ whats = what.split(' ') - - modname = whats[1] - for module in self.modlist: - if modname == module.__class__.__name__: - return self.reply(connection, replypath, 'Module ' + modname + ' is already loaded.') - - # not loaded, let's get to work - try: - modstr = 'modules.'+modname - __import__(modstr) - module = sys.modules[modstr] - botmod = eval('module.' + modname + '(self.config, self.server, self.modlist)') - botmod.register_handlers(self.server) - - # might as well add it to the list - modset = set(self.config.get('dr.botzo', 'module_list').split(',')) - modset.add(modname) - self.config.set('dr.botzo', 'module_list', ','.join(modset)) - - return self.reply(connection, replypath, 'Module ' + modname + ' loaded.') - except ImportError: - return self.reply(connection, replypath, 'Module ' + modname + ' not found.') + return self.reply(connection, replypath, self.irc.load_module(whats[1])) def sub_unload_module(self, connection, event, nick, userhost, replypath, what, admin_unlocked): """Attempt to unload and del a module if it's loaded.""" whats = what.split(' ') - modname = whats[1] - modstr = 'modules.'+modname - - for module in self.modlist: - if modname == module.__class__.__name__: - # do anything the module needs to do to clean up - module.shutdown() - - # remove module references - self.modlist.remove(module) - module.unregister_handlers() - - # del it - del(sys.modules[modstr]) - del(module) - - # might as well remove it from the list - modset = set(self.config.get('dr.botzo', 'module_list').split(',')) - modset.remove(modname) - self.config.set('dr.botzo', 'module_list', ','.join(modset)) - - return self.reply(connection, replypath, 'Module ' + modname + ' unloaded.') - - # guess it was never loaded - return self.reply(connection, replypath, 'Module ' + modname + ' is not loaded.') + return self.reply(connection, replypath, self.irc.unload_module(whats[1])) def sub_reload_module(self, connection, event, nick, userhost, replypath, what, admin_unlocked): """Attempt to reload a module, by removing it from memory and then @@ -225,28 +163,7 @@ class IrcAdmin(Module): """ whats = what.split(' ') - modname = whats[1] - ret = self.sub_unload_module(connection, event, nick, userhost, None, what, admin_unlocked) - if ret == 'Module ' + modname + ' unloaded.': - ret = self.sub_load_module(connection, event, nick, userhost, None, what, admin_unlocked) - if ret == 'Module ' + modname + ' loaded.': - return self.reply(connection, replypath, 'Module ' + modname + ' reloaded.') - - return self.reply(connection, replypath, 'Module ' + modname + ' reload failed. Check the console.') - - # Save the config file. - def save_config(self): - with open('dr.botzo.cfg', 'w') as cfg: - self.config.write(cfg) - - # SIGINT signal handler - def sigint_handler(self, signal, frame): - for module in self.modlist: - module.shutdown() - - self.save_config() - print('saved config') - sys.exit() + return self.reply(connection, replypath, self.irc.unload_module(whats[1])) # vi:tabstop=4:expandtab:autoindent # kate: indent-mode python;indent-width 4;replace-tabs on; diff --git a/modules/Karma.py b/modules/Karma.py index 13d1647..122f10d 100644 --- a/modules/Karma.py +++ b/modules/Karma.py @@ -26,12 +26,12 @@ __date__ = "$Oct 23, 2010 11:12:33 AM$" class Karma(Module): - def __init__(self, config, server, modlist): + def __init__(self, irc, config, server): """ Upon creation, determine the save file location """ - Module.__init__(self, config, server, modlist) + Module.__init__(self, irc, config, server) pattern = "(?:(\S+)|\((.+)\))" karmapattern = pattern + '(\+\+|--|\+-|-\+)' + '(\s+|$)' diff --git a/modules/MegaHAL.py b/modules/MegaHAL.py index c96d012..e8e55b3 100644 --- a/modules/MegaHAL.py +++ b/modules/MegaHAL.py @@ -32,10 +32,10 @@ class MegaHAL(Module): def priority(self): return 95 - def __init__(self, config, server, modlist): + def __init__(self, irc, config, server): """Upon creation, open the MegaHAL brain and get ready for doing actual stuff.""" - Module.__init__(self, config, server, modlist) + Module.__init__(self, irc, config, server) mh_python.initbrain() diff --git a/modules/Twitter.py b/modules/Twitter.py index 25977bd..7490f6d 100644 --- a/modules/Twitter.py +++ b/modules/Twitter.py @@ -29,14 +29,14 @@ class Twitter(Module): Access Twitter via the bot as an authenticated client. """ - def __init__(self, config, server, modlist): + def __init__(self, irc, config, server): """ Prompt the user for oauth stuff when starting up. TODO: make this optional, and have API calls log if they need auth. """ - Module.__init__(self, config, server, modlist) + Module.__init__(self, irc, config, server) # begin oauth magic self.consumer_key = 'N2aSGxBP8t3cCgWyF1B2Aw'