""" .. module:: PyIRCBot :synopsis: Main IRC bot class .. moduleauthor:: Dave Pedu """ import socket import asynchat import logging import traceback import time import sys from socket import SHUT_RDWR from core.rpc import BotRPC import os.path try: from cStringIO import StringIO except: from io import BytesIO as StringIO class PyIRCBot(asynchat.async_chat): """:param coreconfig: The core configuration of the bot. Passed by main.py. :type coreconfig: dict :param botconfig: The configuration of this instance of the bot. Passed by main.py. :type botconfig: dict """ version = "1.0a1-git" """ PyIRCBot version """ def __init__(self, coreconfig, botconfig): asynchat.async_chat.__init__(self) self.connected=False """If we're connected or not""" self.log = logging.getLogger('PyIRCBot') """Reference to logger object""" self.coreconfig = coreconfig """saved copy of the core config""" self.botconfig = botconfig """saved copy of the instance config""" self.rpc = BotRPC(self) """Reference to BotRPC thread""" self.buffer = StringIO() """cSTringIO used as a buffer""" # IRC Messages are terminated with \r\n self.set_terminator(b"\r\n") # Set up hooks for modules self.initHooks() # Load modules self.initModules() # Connect to IRC self._connect() def kill(self): """Shut down the bot violently""" #TODO: have rpc thread be daemonized so it just dies #try: # self.rpc.server._Server__transport.shutdown(SHUT_RDWR) #except Exception as e: # self.log.error(str(e)) try: self.rpc.server._Server__transport.close() except Exception as e: self.log.error(str(e)) #Kill RPC thread self.rpc._stop() #Close all modules self.closeAllModules() " Net related code here on down " def getBuf(self): """Return the network buffer and clear it""" self.buffer.seek(0) data = self.buffer.read() self.buffer = StringIO() return data def collect_incoming_data(self, data): """Recieve data from the IRC server, append it to the buffer :param data: the data that was recieved :type data: str""" self.log.debug("<< %(message)s", {"message":repr(data)}) self.buffer.write(data) def found_terminator(self): """A complete command was pushed through, so clear the buffer and process it.""" line = None buf = self.getBuf() try: line = buf.decode("UTF-8") except UnicodeDecodeError as ude: self.log.error("found_terminator(): could not decode input as UTF-8") self.log.error("found_terminator(): data: %s" % line) self.log.error("found_terminator(): repr(data): %s" % repr(line)) self.log.error("found_terminator(): error: %s" % str(ude)) return self.process_data(line) def handle_close(self): """Called when the socket is disconnected. We will want to reconnect. """ self.log.debug("handle_close") self.connected=False self.close() self.log.warning("Connection was lost. Reconnecting in 5 seconds.") time.sleep(5) self._connect() def handle_error(self, *args, **kwargs): """Called on fatal network errors.""" self.log.warning("Connection failed.") def _connect(self): """Connect to IRC""" self.log.debug("Connecting to %(server)s:%(port)i", {"server":self.botconfig["connection"]["server"], "port":self.botconfig["connection"]["port"]}) socket_type = socket.AF_INET if self.botconfig["connection"]["ipv6"]: self.log.info("IPv6 is enabled.") socket_type = socket.AF_INET6 socketInfo = socket.getaddrinfo(self.botconfig["connection"]["server"], self.botconfig["connection"]["port"], socket_type) self.create_socket(socket_type, socket.SOCK_STREAM) if "bindaddr" in self.botconfig["connection"]: self.bind((self.botconfig["connection"]["bindaddr"], 0)) self.connect(socketInfo[0][4]) def handle_connect(self): """When asynchat indicates our socket is connected, fire the connect hook""" self.connected=True self.log.debug("handle_connect: setting USER and NICK") self.fire_hook("_CONNECT") self.log.debug("handle_connect: complete") def sendRaw(self, text): """Send a raw string to the IRC server :param text: the string to send :type text: str""" if self.connected: self.log.debug(">> "+text) self.send( (text+"\r\n").encode("ascii")) else: self.log.warning("Send attempted while disconnected. >> "+text) def process_data(self, data): """Process one line of tet irc sent us :param data: the data to process :type data: str""" if data.strip() == "": return prefix = None command = None args=[] trailing=None if data[0]==":": prefix=data.split(" ")[0][1:] data=data[data.find(" ")+1:] command = data.split(" ")[0] data=data[data.find(" ")+1:] if(data[0]==":"): # no args trailing = data[1:].strip() else: trailing = data[data.find(" :")+2:].strip() data = data[:data.find(" :")] args = data.split(" ") for index,arg in enumerate(args): args[index]=arg.strip() if not command in self.hookcalls: self.log.warning("Unknown command: cmd='%s' prefix='%s' args='%s' trailing='%s'" % (command, prefix, args, trailing)) else: self.fire_hook(command, args=args, prefix=prefix, trailing=trailing) " Module related code " def initHooks(self): """Defines hooks that modules can listen for events of""" self.hooks = [ '_CONNECT', # Called when the bot connects to IRC on the socket level 'NOTICE', # :irc.129irc.com NOTICE AUTH :*** Looking up your hostname... 'MODE', # :CloneABCD MODE CloneABCD :+iwx 'PING', # PING :irc.129irc.com 'JOIN', # :CloneA!dave@hidden-B4F6B1AA.rit.edu JOIN :#clonea 'QUIT', # :HCSMPBot!~HCSMPBot@108.170.48.18 QUIT :Quit: Disconnecting! 'NICK', # :foxiAway!foxi@irc.hcsmp.com NICK :foxi 'PART', # :CloneA!dave@hidden-B4F6B1AA.rit.edu PART #clonea 'PRIVMSG', # :CloneA!dave@hidden-B4F6B1AA.rit.edu PRIVMSG #clonea :aaa 'KICK', # :xMopxShell!~rduser@host KICK #xMopx2 xBotxShellTest :xBotxShellTest 'INVITE', # :gmx!~gmxgeek@irc.hcsmp.com INVITE Tyrone :#hcsmp' '001', # :irc.129irc.com 001 CloneABCD :Welcome to the 129irc IRC Network CloneABCD!CloneABCD@djptwc-laptop1.rit.edu '002', # :irc.129irc.com 002 CloneABCD :Your host is irc.129irc.com, running version Unreal3.2.8.1 '003', # :irc.129irc.com 003 CloneABCD :This server was created Mon Jul 19 2010 at 03:12:01 EDT '004', # :irc.129irc.com 004 CloneABCD irc.129irc.com Unreal3.2.8.1 iowghraAsORTVSxNCWqBzvdHtGp lvhopsmntikrRcaqOALQbSeIKVfMCuzNTGj '005', # :irc.129irc.com 005 CloneABCD CMDS=KNOCK,MAP,DCCALLOW,USERIP UHNAMES NAMESX SAFELIST HCN MAXCHANNELS=10 CHANLIMIT=#:10 MAXLIST=b:60,e:60,I:60 NICKLEN=30 CHANNELLEN=32 TOPICLEN=307 KICKLEN=307 AWAYLEN=307 :are supported by this server '250', # :chaos.esper.net 250 xBotxShellTest :Highest connection count: 1633 (1632 clients) (186588 connections received) '251', # :irc.129irc.com 251 CloneABCD :There are 1 users and 48 invisible on 2 servers '252', # :irc.129irc.com 252 CloneABCD 9 :operator(s) online '254', # :irc.129irc.com 254 CloneABCD 6 :channels formed '255', # :irc.129irc.com 255 CloneABCD :I have 42 clients and 1 servers '265', # :irc.129irc.com 265 CloneABCD :Current Local Users: 42 Max: 47 '266', # :irc.129irc.com 266 CloneABCD :Current Global Users: 49 Max: 53 '332', # :chaos.esper.net 332 xBotxShellTest #xMopx2 :/ #XMOPX2 / https://code.google.com/p/pyircbot/ (Channel Topic) '333', # :chaos.esper.net 333 xBotxShellTest #xMopx2 xMopxShell!~rduser@108.170.60.242 1344370109 '353', # :irc.129irc.com 353 CloneABCD = #clonea :CloneABCD CloneABC '366', # :irc.129irc.com 366 CloneABCD #clonea :End of /NAMES list. '372', # :chaos.esper.net 372 xBotxShell :motd text here '375', # :chaos.esper.net 375 xBotxShellTest :- chaos.esper.net Message of the Day - '376', # :chaos.esper.net 376 xBotxShell :End of /MOTD command. '422', # :irc.129irc.com 422 CloneABCD :MOTD File is missing '433', # :nova.esper.net 433 * pyircbot3 :Nickname is already in use. ] " mapping of hooks to methods " self.hookcalls = {} for command in self.hooks: self.hookcalls[command]=[] def fire_hook(self, command, args=None, prefix=None, trailing=None): """Run any listeners for a specific hook :param command: the hook to fire :type command: str :param args: the list of arguments, if any, the command was passed :type args: list :param prefix: prefix of the sender of this command :type prefix: str :param trailing: data payload of the command :type trailing: str""" for hook in self.hookcalls[command]: try: hook(args, prefix, trailing) except: self.log.warning("Error processing hook: \n%s"% self.trace()) def initModules(self): """load modules specified in instance config""" " storage of imported modules " self.modules = {} " instances of modules " self.moduleInstances = {} " append module location to path " sys.path.append(self.coreconfig["moduledir"]) " append bot directory to path " sys.path.append(self.coreconfig["botdir"]+"core/") for modulename in self.botconfig["modules"]: self.loadmodule(modulename) def importmodule(self, name): """Import a module :param moduleName: Name of the module to import :type moduleName: str""" " check if already exists " if not name in self.modules: " attempt to load " try: moduleref = __import__(name) self.modules[name]=moduleref return (True, None) except Exception as e: " on failure (usually syntax error in Module code) print an error " self.log.error("Module %s failed to load: " % name) self.log.error("Module load failure reason: " + str(e)) return (False, str(e)) else: self.log.warning("Module %s already imported" % name) return (False, "Module already imported") def deportmodule(self, name): """Remove a module's code from memory. If the module is loaded it will be unloaded silently. :param moduleName: Name of the module to import :type moduleName: str""" " unload if necessary " if name in self.moduleInstances: self.unloadmodule(name) " delete all references to the module" if name in self.modules: item = self.modules[name] del self.modules[name] del item " delete copy that python stores in sys.modules " if name in sys.modules: del sys.modules[name] def loadmodule(self, name): """Activate a module. :param moduleName: Name of the module to activate :type moduleName: str""" " check if already loaded " if name in self.moduleInstances: self.log.warning( "Module %s already loaded" % name ) return False " check if needs to be imported, and verify it was " if not name in self.modules: importResult = self.importmodule(name) if not importResult[0]: return importResult " init the module " self.moduleInstances[name] = getattr(self.modules[name], name)(self, name) " load hooks " self.loadModuleHooks(self.moduleInstances[name]) def unloadmodule(self, name): """Deactivate a module. :param moduleName: Name of the module to deactivate :type moduleName: str""" if name in self.moduleInstances: " notify the module of disabling " self.moduleInstances[name].ondisable() " unload all hooks " self.unloadModuleHooks(self.moduleInstances[name]) " remove the instance " item = self.moduleInstances.pop(name) " delete the instance" del item self.log.info( "Module %s unloaded" % name ) return (True, None) else: self.log.info("Module %s not loaded" % name) return (False, "Module not loaded") def reloadmodule(self, name): """Deactivate and activate a module. :param moduleName: Name of the target module :type moduleName: str""" " make sure it's imporeted" if name in self.modules: " remember if it was loaded before" loadedbefore = name in self.moduleInstances self.log.info("Reloading %s" % self.modules[name]) " unload " self.unloadmodule(name) " load " if loadedbefore: self.loadmodule(name) return (True, None) return (False, "Module is not loaded") def redomodule(self, name): """Reload a running module from disk :param moduleName: Name of the target module :type moduleName: str""" " remember if it was loaded before " loadedbefore = name in self.moduleInstances " unload/deport " self.deportmodule(name) " import " importResult = self.importmodule(name) if not importResult[0]: return importResult " load " if loadedbefore: self.loadmodule(name) return (True, None) def loadModuleHooks(self, module): """**Internal.** Enable (connect) hooks of a module :param module: module object to hook in :type module: object""" " activate a module's hooks " for hook in module.hooks: self.addHook(hook.hook, hook.method) def unloadModuleHooks(self, module): """**Internal.** Disable (disconnect) hooks of a module :param module: module object to unhook :type module: object""" " remove a modules hooks " for hook in module.hooks: self.removeHook(hook.hook, hook.method) def addHook(self, command, method): """**Internal.** Enable (connect) a single hook of a module :param command: command this hook will trigger on :type command: str :param method: callable method object to hook in :type method: object""" " add a single hook " if command in self.hooks: self.hookcalls[command].append(method) else: self.log.warning("Invalid hook - %s" % command) return False def removeHook(self, command, method): """**Internal.** Disable (disconnect) a single hook of a module :param command: command this hook triggers on :type command: str :param method: callable method that should be removed :type method: object""" " remove a single hook " if command in self.hooks: for hookedMethod in self.hookcalls[command]: if hookedMethod == method: self.hookcalls[command].remove(hookedMethod) else: self.log.warning("Invalid hook - %s" % command) return False def getmodulebyname(self, name): """Get a module object by name :param name: name of the module to return :type name: str :returns: object -- the module object""" if not name in self.moduleInstances: return None return self.moduleInstances[name] def getmodulesbyservice(self, service): """Get a list of modules that provide the specified service :param service: name of the service searched for :type service: str :returns: list -- a list of module objects""" validModules = [] for module in self.moduleInstances: if service in self.moduleInstances[module].services: validModules.append(self.moduleInstances[module]) return validModules def getBestModuleForService(self, service): """Get the first module that provides the specified service :param service: name of the service searched for :type service: str :returns: object -- the module object, if found. None if not found.""" m = self.getmodulesbyservice(service) if len(m)>0: return m[0] return None def closeAllModules(self): """ Deport all modules (for shutdown). Modules are unloaded in the opposite order listed in the config. """ loaded = list(self.moduleInstances.keys()) loadOrder = self.botconfig["modules"] loadOrder.reverse() for key in loadOrder: if key in loaded: loaded.remove(key) self.deportmodule(key) for key in loaded: self.deportmodule(key) " Filesystem Methods " def getDataPath(self, moduleName): """Return the absolute path for a module's data dir :param moduleName: the module who's data dir we want :type moduleName: str""" if not os.path.exists("%s/data/%s" % (self.botconfig["bot"]["datadir"], moduleName)): os.mkdir("%s/data/%s/" % (self.botconfig["bot"]["datadir"], moduleName)) return "%s/data/%s/" % (self.botconfig["bot"]["datadir"], moduleName) def getConfigPath(self, moduleName): """Return the absolute path for a module's config file :param moduleName: the module who's config file we want :type moduleName: str""" return "%s/config/%s.yml" % (self.botconfig["bot"]["datadir"], moduleName) " Utility methods " @staticmethod def decodePrefix(prefix): """Given a prefix like nick!username@hostname, return an object with these properties :param prefix: the prefix to disassemble :type prefix: str :returns: object -- an UserPrefix object with the properties `nick`, `username`, `hostname` or a ServerPrefix object with the property `hostname`""" if "!" in prefix: ob = type('UserPrefix', (object,), {}) ob.nick, prefix = prefix.split("!") ob.username, ob.hostname = prefix.split("@") return ob else: ob = type('ServerPrefix', (object,), {}) ob.hostname = prefix return ob @staticmethod def trace(): """Return the stack trace of the bot as a string""" return traceback.format_exc() @staticmethod def messageHasCommand(command, message, requireArgs=False): """Check if a message has a command with or without args in it :param command: the command string to look for, like !ban :type command: str :param message: the message string to look in, like "!ban Lil_Mac" :type message: str :param requireArgs: if true, only validate if the command use has any amount of trailing text :type requireArgs: bool""" # Check if the message at least starts with the command messageBeginning = message[0:len(command)] if messageBeginning!=command: return False # Make sure it's not a subset of a longer command (ie .meme being set off by .memes) subsetCheck = message[len(command):len(command)+1] if subsetCheck!=" " and subsetCheck!="": return False # We've got the command! Do we need args? argsStart = len(command) args = "" if argsStart > 0: args = message[argsStart+1:] if requireArgs and args.strip() == '': return False # Verified! Return the set. ob = type('ParsedCommand', (object,), {}) ob.command = command ob.args = [] if args=="" else args.split(" ") ob.args_str = args ob.message = message return ob # return (True, command, args, message) " Data Methods " def get_nick(self): """Get the bot's current nick :returns: str - the bot's current nickname""" return self.config["nick"] " Action Methods " def act_PONG(self, data): """Use the `/pong` command - respond to server pings :param data: the string or number the server sent with it's ping :type data: str""" self.sendRaw("PONG :%s" % data) def act_USER(self, username, hostname, realname): """Use the USER protocol command. Used during connection :param username: the bot's username :type username: str :param hostname: the bot's hostname :type hostname: str :param realname: the bot's realname :type realname: str""" self.sendRaw("USER %s %s %s :%s" % (username, hostname, self.botconfig["connection"]["server"], realname)) def act_NICK(self, newNick): """Use the `/nick` command :param newNick: new nick for the bot :type newNick: str""" self.sendRaw("NICK %s" % newNick) def act_JOIN(self, channel): """Use the `/join` command :param channel: the channel to attempt to join :type channel: str""" self.sendRaw("JOIN %s"%channel) def act_PRIVMSG(self, towho, message): """Use the `/msg` command :param towho: the target #channel or user's name :type towho: str :param message: the message to send :type message: str""" self.sendRaw("PRIVMSG %s :%s"%(towho,message)) def act_MODE(self, channel, mode, extra=None): """Use the `/mode` command :param channel: the channel this mode is for :type channel: str :param mode: the mode string. Example: +b :type mode: str :param extra: additional argument if the mode needs it. Example: user@*!* :type extra: str""" if extra != None: self.sendRaw("MODE %s %s %s" % (channel,mode,extra)) else: self.sendRaw("MODE %s %s" % (channel,mode)) def act_ACTION(self, channel, action): """Use the `/me ` command :param channel: the channel name or target's name the message is sent to :type channel: str :param action: the text to send :type action: str""" self.sendRaw("PRIVMSG %s :\x01ACTION %s"%(channel,action)) def act_KICK(self, channel, who, comment=""): """Use the `/kick ` command :param channel: the channel from which the user will be kicked :type channel: str :param who: the nickname of the user to kick :type action: str :param comment: the kick message :type comment: str""" self.sendRaw("KICK %s %s :%s" % (channel, who, comment))