From 01d3c7c80cce690ac11a3ffabe6101470f1ce601 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Fri, 7 Jan 2011 17:38:26 -0600 Subject: [PATCH] migrate some code that became pivotal to the bot into DrBotIRC. this is a big change. DrBotIrc is now in charge of module loading and unloading, aliases, and recursion. the Alias module is no more, and a bunch of functionality was moved out of IrcAdmin, including also config file saving, the sigint handler, and quitting the bot. additionally, a lot of stuff got caught in the wake. dr.botzo.py is simpler now, and lets DrBotIRC do the dynamic loading stuff. Module.__init__ changed, modules no longer get modlist and instead get a reference to the DrBotIRC object. IrcAdmin still has the same exposed methods, but now calls out to DrBotIRC to achieve some of them. naturally, a recursion/alias rewrite was included with this change. it is clearer now (i think), but probably brittle somewhere. additionally, currently any module that has registered a pubmsg handler can potentially fire more than once on one input (without recursion). this may be the next thing to fix. do() may need to be split, or maybe it's time to stop having modules deal with pubmsg/privmsg entirely. need to decide. WEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE --- DrBotIRC.py | 250 ++++++++++++++++++++++++++++++++++++++++++- Module.py | 12 +-- dr.botzo.py | 12 +-- modules/Alias.py | 137 ------------------------ modules/EightBall.py | 4 +- modules/IrcAdmin.py | 95 ++-------------- modules/Karma.py | 4 +- modules/MegaHAL.py | 4 +- modules/Twitter.py | 4 +- 9 files changed, 264 insertions(+), 258 deletions(-) delete mode 100644 modules/Alias.py 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'