def signedOn(self): """Called once we've connected to a network""" super().signedOn() self.logger.info("Signed on as %s" % self.nickname) # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Setup PluginManager self.plugin_manager = PluginManager(self, self.factory.plugins, self.factory.blacklist) if self.factory.server_commands: self.logger.info("Sending server commands") for command in self.factory.server_commands: self.send(command) # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password, )) # For servers that support it, set the bot mode self.send("MODE {} +B".format(self.nickname)) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted
def signedOn(self): """Called once we've connected to a network""" # Give the factory access to the bot if self.factory is None: raise InternalError("Factory must be set on CardinalBot instance") self.logger.info("Signed on as %s" % self.nickname) # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Setup PluginManager self.plugin_manager = PluginManager(self, self.factory.plugins, self.factory.blacklist) # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password, )) # For servers that support it, set the bot mode # # FIXME: is it possible that the server renames us before this? if so, # the mode registration would fail erroneously self.send("MODE {} +B".format(self.nickname)) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted
def signedOn(self): """Called once we've connected to a network""" self.logger.info("Signed on as %s" % self.nickname) # Give the factory access to the bot if self.factory is None: raise InternalError("Factory must be set on CardinalBot instance") # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Set the currently connected network self.network = self.factory.network # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password, )) # Creates an instance of EventManager self.logger.debug("Creating new EventManager instance") self.event_manager = EventManager(self) # Register events try: self.event_manager.register("irc.invite", 2) self.event_manager.register("irc.privmsg", 3) self.event_manager.register("irc.notice", 3) self.event_manager.register("irc.nick", 2) self.event_manager.register("irc.mode", 3) self.event_manager.register("irc.topic", 3) self.event_manager.register("irc.join", 2) self.event_manager.register("irc.part", 3) self.event_manager.register("irc.kick", 4) self.event_manager.register("irc.quit", 2) except EventAlreadyExistsError: self.logger.error("Could not register core IRC events", exc_info=True) # Create an instance of PluginManager, giving it an instance of ourself # to pass to plugins, as well as a list of initial plugins to load. self.logger.debug("Creating new PluginManager instance") self.plugin_manager = PluginManager(self, self.factory.plugins) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted
def setup_method(self): mock_cardinal = self.cardinal = Mock(spec=CardinalBot) mock_cardinal.nickname = 'Cardinal' mock_cardinal.event_manager = self.event_manager = \ EventManager(mock_cardinal) self.blacklist = {} self.plugin_manager = PluginManager( mock_cardinal, [], self.blacklist, _plugin_module_import_prefix='fake_plugins', _plugin_module_directory_suffix='cardinal/fixtures/fake_plugins')
def setup_method(self): mock_cardinal = self.cardinal = Mock(spec=CardinalBot) mock_cardinal.nickname = 'Cardinal' mock_cardinal.event_manager = self.event_manager = \ EventManager(mock_cardinal) self.blacklist = {} plugins_directory = os.path.abspath(os.path.join( os.path.dirname(os.path.realpath(os.path.abspath(__file__))), '..', 'cardinal/fixtures/fake_plugins', )) self.plugin_manager = PluginManager( mock_cardinal, [], self.blacklist, _plugin_module_import_prefix='fake_plugins', _plugin_module_directory=plugins_directory, )
def signedOn(self): """Called once we've connected to a network""" self.logger.info("Signed on as %s" % self.nickname) # Give the factory access to the bot if self.factory is None: raise InternalError("Factory must be set on CardinalBot instance") # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Set the currently connected network self.network = self.factory.network # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password,)) # Creates an instance of EventManager self.logger.debug("Creating new EventManager instance") self.event_manager = EventManager(self) # Register events try: self.event_manager.register("irc.invite", 2) self.event_manager.register("irc.privmsg", 3) self.event_manager.register("irc.notice", 3) self.event_manager.register("irc.nick", 2) self.event_manager.register("irc.mode", 3) self.event_manager.register("irc.topic", 3) self.event_manager.register("irc.join", 2) self.event_manager.register("irc.part", 3) self.event_manager.register("irc.kick", 4) self.event_manager.register("irc.quit", 2) except EventAlreadyExistsError: self.logger.error( "Could not register core IRC events", exc_info=True) # Create an instance of PluginManager, giving it an instance of ourself # to pass to plugins, as well as a list of initial plugins to load. self.logger.debug("Creating new PluginManager instance") self.plugin_manager = PluginManager(self, self.factory.plugins) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted
class CardinalBot(irc.IRCClient, object): """Cardinal, in all its glory""" logger = None """Logging object for CardinalBot""" factory = None """Should contain an instance of CardinalBotFactory""" network = None """Currently connected network (e.g. irc.freenode.net)""" user_regex = re.compile(r'^(.*?)!(.*?)@(.*?)$') """Regex for identifying a user's nick, ident, and vhost""" plugin_manager = None """Instance of PluginManager""" event_manager = None """Instance of EventManager""" storage_path = None """Location of storage directory""" uptime = None """Time that Cardinal connected to the network""" booted = None """Time that Cardinal was first launched""" @property def nickname(self): return self.factory.nickname @nickname.setter def nickname(self, value): self.factory.nickname = value @property def reloads(self): return self.factory.reloads @reloads.setter def reloads(self, value): self.factory.reloads = value def __init__(self): """Initializes the logging and sets storage directory""" self.logger = logging.getLogger(__name__) self.storage_path = os.path.join( os.path.dirname(os.path.realpath(sys.argv[0])), 'storage' ) # State variables for the WHO command self.who_lock = {} self.who_cache = {} self.who_callbacks = {} def signedOn(self): """Called once we've connected to a network""" self.logger.info("Signed on as %s" % self.nickname) # Give the factory access to the bot if self.factory is None: raise InternalError("Factory must be set on CardinalBot instance") # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Set the currently connected network self.network = self.factory.network # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password,)) # Creates an instance of EventManager self.logger.debug("Creating new EventManager instance") self.event_manager = EventManager(self) # Register events self.event_manager.register("irc.invite", 2) self.event_manager.register("irc.privmsg", 3) self.event_manager.register("irc.notice", 3) self.event_manager.register("irc.nick", 2) self.event_manager.register("irc.mode", 3) self.event_manager.register("irc.topic", 3) self.event_manager.register("irc.join", 2) self.event_manager.register("irc.part", 3) self.event_manager.register("irc.kick", 4) self.event_manager.register("irc.quit", 2) # Create an instance of PluginManager, giving it an instance of ourself # to pass to plugins, as well as a list of initial plugins to load. self.logger.debug("Creating new PluginManager instance") self.plugin_manager = PluginManager(self, self.factory.plugins) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted def joined(self, channel): """Called when we join a channel. channel -- Channel joined. Provided by Twisted. """ self.logger.info("Joined %s" % channel) def irc_PRIVMSG(self, prefix, params): """Called when we receive a message in a channel or PM.""" # Break down the user into usable groups user = re.match(self.user_regex, prefix) channel = params[0] message = params[1] self.logger.debug( "%s!%s@%s to %s: %s" % (user.group(1), user.group(2), user.group(3), channel, message) ) self.event_manager.fire("irc.privmsg", user, channel, message) # If the channel is ourselves, this is actually a PM to us, and so # we'll update the channel variable to the sender's username to make # replying a little easier. if channel == self.nickname: channel = user.group(1) # Attempt to call a command. If it doesn't appear to PluginManager to # be a command, this will just fall through. If it matches command # syntax but there is no matching command, then we should catch the # exception. try: self.plugin_manager.call_command(user, channel, message) except CommandNotFoundError: # This is just an info, since anyone can trigger it, not really a # bad thing. self.logger.info( "Unable to find a matching command", exc_info=True) def who(self, channel, callback): """Lists the users in a channel. Keyword arguments: channel -- Channel to list users of. callback -- A callback that will receive the list of users. Returns: None. However, the callback will receive a single argument, which is the list of users. """ if channel not in self.who_callbacks: self.who_callbacks[channel] = [] self.who_callbacks[channel].append(callback) self.logger.info("WHO list requested for %s" % channel) if channel not in self.who_lock: self.logger.info("Making WHO request to server") # Set a lock to prevent trying to track responses from the server self.who_lock[channel] = True # Empty the cache to ensure no old users show up. # TODO: Add actual caching and user tracking. self.who_cache[channel] = [] # Send the actual WHO command to the server. irc_RPL_WHOREPLY will # receive a response when the server sends one. self.sendLine("WHO %s" % channel) def irc_RPL_WHOREPLY(self, *nargs): "Receives reply from WHO command and sends to caller" response = nargs[1] # Same format as other events (nickname!ident@hostname) user = ( response[5], # nickname response[2], # ident response[3], # hostname ) channel = response[1] self.who_cache[channel].append(user) def irc_RPL_ENDOFWHO(self, *nargs): "Called when WHO output is complete" response = nargs[1] channel = response[1] self.logger.info("Calling WHO callbacks for %s" % channel) for callback in self.who_callbacks[channel]: callback(self.who_cache[channel]) def irc_NOTICE(self, prefix, params): """Called when a notice is sent to a channel or privately""" user = re.match(self.user_regex, prefix) channel = params[0] message = params[1] # Sent by network, not a real user if not user: self.logger.debug( "%s sent notice to %s: %s" % (prefix, channel, message) ) return self.logger.debug( "%s!%s@%s sent notice to %s: %s" % (user.group(1), user.group(2), user.group(3), channel, message) ) # Lots of NOTICE messages when connecting, and event manager may not be # initialized yet. if self.event_manager: self.event_manager.fire("irc.notice", user, channel, message) def irc_NICK(self, prefix, params): """Called when a user changes their nick""" user = re.match(self.user_regex, prefix) new_nick = params[0] self.logger.debug( "%s!%s@%s changed nick to %s" % (user.group(1), user.group(2), user.group(3), new_nick) ) self.event_manager.fire("irc.nick", user, new_nick) def irc_TOPIC(self, prefix, params): """Called when a new topic is set""" user = re.match(self.user_regex, prefix) channel = params[0] topic = params[1] self.logger.debug( "%s!%s@%s changed topic in %s to %s" % (user.group(1), user.group(2), user.group(3), channel, topic) ) self.event_manager.fire("irc.topic", user, channel, topic) def irc_MODE(self, prefix, params): """Called when a mode is set on a channel""" user = re.match(self.user_regex, prefix) channel = params[0] mode = ' '.join(params[1:]) # Sent by network, not a real user if not user: self.logger.debug( "%s set mode on %s (%s)" % (prefix, channel, mode) ) return self.logger.debug( "%s!%s@%s set mode on %s (%s)" % (user.group(1), user.group(2), user.group(3), channel, mode) ) # Can get called during connection, in which case EventManager won't be # initialized yet if self.event_manager: self.event_manager.fire("irc.mode", user, channel, mode) def irc_JOIN(self, prefix, params): """Called when a user joins a channel""" user = re.match(self.user_regex, prefix) channel = params[0] self.logger.debug( "%s!%s@%s joined %s" % (user.group(1), user.group(2), user.group(3), channel) ) self.event_manager.fire("irc.join", user, channel) def irc_PART(self, prefix, params): """Called when a user parts a channel""" user = re.match(self.user_regex, prefix) channel = params[0] if len(params) == 1: reason = "No Message" else: reason = params[1] self.logger.debug( "%s!%s@%s parted %s (%s)" % (user.group(1), user.group(2), user.group(3), channel, reason) ) self.event_manager.fire("irc.part", user, channel, reason) def irc_KICK(self, prefix, params): """Called when a user is kicked from a channel""" user = re.match(self.user_regex, prefix) nick = params[0] channel = params[1] if len(params) == 2: reason = "No Message" else: reason = params[2] self.logger.debug( "%s!%s@%s kicked %s from %s (%s)" % (user.group(1), user.group(2), user.group(3), nick, channel, reason) ) self.event_manager.fire("irc.kick", user, channel, nick, reason) def irc_QUIT(self, prefix, params): """Called when a user quits the network""" user = re.match(self.user_regex, prefix) if len(params) == 0: reason = "No Message" else: reason = params[0] self.logger.debug( "%s!%s@%s quit (%s)" % (user.group(1), user.group(2), user.group(3), reason) ) self.event_manager.fire("irc.quit", user, reason) def irc_unknown(self, prefix, command, params): """Called when Twisted doesn't understand an IRC command. Keyword arguments: prefix -- User sending command. Provided by Twisted. command -- Command that wasn't recognized. Provided by Twisted. params -- Params for command. Provided by Twisted. """ # A user has invited us to a channel if command == "INVITE": # Break down the user into usable groups user = re.match(self.user_regex, prefix) channel = params[1] self.logger.debug("%s invited us to %s" % (user.group(1), channel)) # Fire invite event, so plugins can hook into it self.event_manager.fire("irc.invite", user, channel) # TODO: Call matching plugin events def config(self, plugin): """Returns a given loaded plugin's config. Keyword arguments: plugin -- String containing a plugin name. Returns: dict -- Dictionary containing plugin config. Raises: ConfigNotFoundError - When config can't be found for the plugin. """ if self.plugin_manager is None: self.logger.error( "PluginManager has not been initialized! Can't return config " "for plugin: %s" % plugin ) raise PluginError("PluginManager has not yet been initialized") try: config = self.plugin_manager.get_config(plugin) except ConfigNotFoundError: # Log and raise the exception again self.logger.exception( "Couldn't find config for plugin: %s" % plugin ) raise return config def sendMsg(self, channel, message, length=None): """Wrapper command to send messages. Keyword arguments: channel -- Channel to send message to. message -- Message to send. length -- Length of message. Twisted will calculate if None given. """ self.logger.info("Sending in %s: %s" % (channel, message)) self.msg(channel, message, length) def disconnect(self, message=''): """Wrapper command to quit Cardinal. Keyword arguments: message -- Message to insert into QUIT, if any. """ self.logger.info("Disconnecting from network") self.plugin_manager.unload_all() self.factory.disconnect = True self.quit(message)
class CardinalBot(irc.IRCClient, object): """Cardinal, in all its glory""" @property def network(self): return self.factory.network @network.setter def network(self, value): self.factory.network = value @property def nickname(self): return self.factory.nickname @nickname.setter def nickname(self, value): self.factory.nickname = value @property def password(self): """Twisted.irc.IRCClient server password setting""" return self.factory.server_password @password.setter def password(self, value): self.factory.server_password = value @property def username(self): return self.factory.username @username.setter def username(self, value): self.factory.username = value @property def realname(self): return self.factory.realname @realname.setter def realname(self, value): self.factory.realname = value @property def storage_path(self): return self.factory.storage_path def __init__(self): """Initializes the logging""" self.logger = logging.getLogger(__name__) self.irc_logger = logging.getLogger("%s.irc" % __name__) # Will get set by Twisted before signedOn is called self.factory = None # PluginManager gets created in signedOn self.plugin_manager = None # Setup EventManager self.event_manager = EventManager(self) # Register events self.event_manager.register("irc.raw", 2) self.event_manager.register("irc.invite", 2) self.event_manager.register("irc.privmsg", 3) self.event_manager.register("irc.notice", 3) self.event_manager.register("irc.nick", 2) self.event_manager.register("irc.mode", 3) self.event_manager.register("irc.topic", 3) self.event_manager.register("irc.join", 2) self.event_manager.register("irc.part", 3) self.event_manager.register("irc.kick", 4) self.event_manager.register("irc.quit", 2) # State variables for the WHO command self._who_cache = {} self._who_deferreds = {} # Database file locks self.db_locks = {} def signedOn(self): """Called once we've connected to a network""" super().signedOn() self.logger.info("Signed on as %s" % self.nickname) # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Setup PluginManager self.plugin_manager = PluginManager(self, self.factory.plugins, self.factory.blacklist) if self.factory.server_commands: self.logger.info("Sending server commands") for command in self.factory.server_commands: self.send(command) # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password, )) # For servers that support it, set the bot mode self.send("MODE {} +B".format(self.nickname)) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted def joined(self, channel): """Called when we join a channel. channel -- Channel joined. Provided by Twisted. """ self.logger.info("Joined %s" % channel) def lineReceived(self, line): """Called for every line received from the server.""" # The IRC spec does not specify a message encoding, meaning that some # messages may fail to decode into a UTF-8 string. While we must be # aware of the issue and choose to replace "invalid" characters (which # are technically valid per the IRC RFC, hence us warning about this # behavior), we must also ensure that the Twisted IRCClient's # implementation of lineReceived does not receive these characters, as # it will not replace them. try: line = line.decode('utf-8') except UnicodeDecodeError: self.logger.warning( "Stripping non-UTF-8 data from received line: {}".format(line)) line = line.decode('utf-8', 'replace') # Log raw output self.irc_logger.info(line) # Log if the command received is in the error range _, command, _ = irc.parsemsg(line) if command.isnumeric() and 400 <= int(command) <= 599: self.logger.warning( "Received an error from the server: {}".format(line)) self.event_manager.fire("irc.raw", command, line) # Send IRCClient the version of the line that has had non-UTF-8 # characters replaced. # # Bug: https://twistedmatrix.com/trac/ticket/9443 super().lineReceived(line.encode('utf-8')) def irc_PRIVMSG(self, prefix, params): """Called when we receive a message in a channel or PM.""" super().irc_PRIVMSG(prefix, params) # Break down the user into usable groups user = self.get_user_tuple(prefix) nick = user[0] channel = params[0] message = params[1] self.logger.debug("%s!%s@%s to %s: %s" % (user + (channel, message))) self.event_manager.fire("irc.privmsg", user, channel, message) # If the channel is ourselves, this is actually a PM to us, and so # we'll update the channel variable to the sender's username to make # replying a little easier. if channel == self.nickname: channel = nick else: # If the message is directed at us, strip the prefix telling us so. # This allows us to target a specific Cardinal bot if multiple are # in a channel, and it works for plugins that use a regex as well. # If a plugin needs the original message, they can use the # irc.privmsg event. nick_prefix = "{}: ".format(self.nickname) if message.startswith(nick_prefix): message = message[len(nick_prefix):] # Attempt to call a command. If it doesn't appear to PluginManager to # be a command, this will just fall through. If it matches command # syntax but there is no matching command, then we should catch the # exception. try: self.plugin_manager.call_command(user, channel, message) except CommandNotFoundError: self.logger.debug("Unable to find a matching command", exc_info=True) def irc_NOTICE(self, prefix, params): """Called when a notice is sent to a channel or privately""" super().irc_NOTICE(prefix, params) user = self.get_user_tuple(prefix) channel = params[0] message = params[1] # Sent by network, not a real user if not user: self.logger.debug("%s sent notice to %s: %s" % (prefix, channel, message)) return self.logger.debug("%s!%s@%s sent notice to %s: %s" % (user + (channel, message))) self.event_manager.fire("irc.notice", user, channel, message) def irc_NICK(self, prefix, params): """Called when a user changes their nick""" super().irc_NICK(prefix, params) user = self.get_user_tuple(prefix) new_nick = params[0] self.logger.debug("%s!%s@%s changed nick to %s" % (user + (new_nick, ))) self.event_manager.fire("irc.nick", user, new_nick) def irc_TOPIC(self, prefix, params): """Called when a new topic is set""" super().irc_TOPIC(prefix, params) user = self.get_user_tuple(prefix) channel = params[0] topic = params[1] self.logger.debug("%s!%s@%s changed topic in %s to %s" % (user + (channel, topic))) self.event_manager.fire("irc.topic", user, channel, topic) def irc_MODE(self, prefix, params): """Called when a mode is set on a channel""" super().irc_MODE(prefix, params) user = self.get_user_tuple(prefix) channel = params[0] mode = ' '.join(params[1:]) # Sent by network, not a real user if not user: self.logger.debug("%s set mode on %s (%s)" % (prefix, channel, mode)) return self.logger.debug("%s!%s@%s set mode on %s (%s)" % (user + (channel, mode))) self.event_manager.fire("irc.mode", user, channel, mode) def irc_JOIN(self, prefix, params): """Called when a user joins a channel""" super().irc_JOIN(prefix, params) user = self.get_user_tuple(prefix) channel = params[0] self.logger.debug("%s!%s@%s joined %s" % (user + (channel, ))) self.event_manager.fire("irc.join", user, channel) def irc_PART(self, prefix, params): """Called when a user parts a channel""" super().irc_PART(prefix, params) user = self.get_user_tuple(prefix) channel = params[0] if len(params) == 1: reason = None else: reason = params[1] self.logger.debug("%s!%s@%s parted %s (%s)" % (user + (channel, reason if reason else "No Message"))) self.event_manager.fire("irc.part", user, channel, reason) def irc_KICK(self, prefix, params): """Called when a user is kicked from a channel""" super().irc_KICK(prefix, params) user = self.get_user_tuple(prefix) nick = params[1] channel = params[0] if len(params) == 2: reason = None else: reason = params[2] self.logger.debug( "%s!%s@%s kicked %s from %s (%s)" % (user + (nick, channel, reason if reason else "No Message"))) self.event_manager.fire("irc.kick", user, channel, nick, reason) def irc_QUIT(self, prefix, params): """Called when a user quits the network""" super().irc_QUIT(prefix, params) user = self.get_user_tuple(prefix) if len(params[0]) == 0: reason = None else: reason = params[0] self.logger.debug("%s!%s@%s quit (%s)" % (user + (reason if reason else "No Message", ))) self.event_manager.fire("irc.quit", user, reason) def irc_RPL_WHOREPLY(self, prefix, params): """Called for each user in the WHO reply. This is the second piece of the `who()` method call. We will add each user listed in the reply to a list for the channel that will be sent to the channel's Deferred once all users have been listed. """ # Same format as other events (nickname!ident@hostname) self.logger.info(params) user = user_info( params[5], # nickname params[2], # ident params[3], # hostname ) channel = params[1] self._who_cache[channel].append(user) def irc_RPL_ENDOFWHO(self, prefix, params): """Called when WHO reply is complete. This is the final piece of the `who()` method call. This indicates we can consider the WHO listing complete, and resolve the Deferred for the given channel. """ self.logger.info(params) channel = params[1] self.logger.info("WHO reply received for %s" % channel) for d in self._who_deferreds[channel]: d.callback(self._who_cache[channel]) del self._who_deferreds[channel] def irc_unknown(self, prefix, command, params): """Called when Twisted doesn't understand an IRC command. Keyword arguments: prefix -- User sending command. Provided by Twisted. command -- Command that wasn't recognized. Provided by Twisted. params -- Params for command. Provided by Twisted. """ super().irc_unknown(prefix, command, params) # A user has invited us to a channel if command == "INVITE": # Break down the user into usable groups user = self.get_user_tuple(prefix) nick = user[0] channel = params[1] self.logger.debug("%s invited us to %s" % (nick, channel)) # Fire invite event, so plugins can hook into it self.event_manager.fire("irc.invite", user, channel) def who(self, channel): """Lists the users in a channel. Keyword arguments: channel -- Channel to list users of. Returns: Deferred -- A Deferred which will have its callbacks called when the WHO response comes back from the server. """ self.logger.info("WHO list requested for %s" % channel) d = defer.Deferred() if channel not in self._who_deferreds: self._who_cache[channel] = [] self._who_deferreds[channel] = [d] # Send the actual WHO command to the server. irc_RPL_WHOREPLY will # receive a response when the server sends one. self.logger.info("Making WHO request to server") self.sendLine("WHO %s" % channel) else: self._who_deferreds[channel].append(d) return d def config(self, plugin): """Returns a given loaded plugin's config. Keyword arguments: plugin -- String containing a plugin name. Returns: dict -- Dictionary containing plugin config. Raises: ConfigNotFoundError - When config can't be found for the plugin. """ if self.plugin_manager is None: self.logger.error( "PluginManager has not been initialized! Can't return config " "for plugin: %s" % plugin) raise PluginError("PluginManager has not yet been initialized") try: config = self.plugin_manager.get_config(plugin) except ConfigNotFoundError: # Log and raise the exception again self.logger.exception("Couldn't find config for plugin: %s" % plugin) raise return config def sendMsg(self, channel, message, length=None): """Wrapper command to send messages. Keyword arguments: channel -- Channel to send message to. message -- Message to send. length -- Length of message. Twisted will calculate if None given. """ self.logger.info("Sending in %s: %s" % (channel, message)) self.msg(channel, message, length) def send(self, message): """Send a raw message to the server. Keyword arguments: message -- Message to send. """ self.logger.info("Sending to server: %s" % message) self.sendLine(message) def disconnect(self, message=''): """Wrapper command to quit Cardinal. Keyword arguments: message -- Message to insert into QUIT, if any. """ self.logger.info("Disconnecting from network") self.plugin_manager.unload_all() self.factory.disconnect = True self.quit(message) def get_db(self, name, network_specific=True, default=None): if default is None: default = {} db_path = os.path.join( self.storage_path, 'database', name + ('-{}'.format(self.network) if network_specific else '') + '.json') if db_path not in self.db_locks: self.db_locks[db_path] = UNLOCKED @contextmanager def db(): if self.db_locks[db_path] == LOCKED: raise LockInUseError('DB {} locked'.format(db_path)) self.db_locks[db_path] = LOCKED try: if not os.path.exists(db_path): with open(db_path, 'w') as f: json.dump(default, f) # Load the DB as JSON, use it, then save the result with open(db_path, 'r+') as f: database = json.load(f) yield database f.seek(0) f.truncate() json.dump(database, f) finally: self.db_locks[db_path] = UNLOCKED return db @staticmethod def get_user_tuple(string): user = re.match(USER_REGEX, string) if user: return user_info(user.group(1), user.group(2), user.group(3)) return user
class CardinalBot(irc.IRCClient, object): """Cardinal, in all its glory""" logger = None """Logging object for CardinalBot""" factory = None """Should contain an instance of CardinalBotFactory""" network = None """Currently connected network (e.g. irc.freenode.net)""" user_regex = re.compile(r'^(.*?)!(.*?)@(.*?)$') """Regex for identifying a user's nick, ident, and vhost""" plugin_manager = None """Instance of PluginManager""" event_manager = None """Instance of EventManager""" storage_path = None """Location of storage directory""" uptime = None """Time that Cardinal connected to the network""" booted = None """Time that Cardinal was first launched""" @property def nickname(self): return self.factory.nickname @nickname.setter def nickname(self, value): self.factory.nickname = value @property def reloads(self): return self.factory.reloads @reloads.setter def reloads(self, value): self.factory.reloads = value def __init__(self): """Initializes the logging and sets storage directory""" self.logger = logging.getLogger(__name__) self.storage_path = os.path.join( os.path.dirname(os.path.realpath(sys.argv[0])), 'storage') # State variables for the WHO command self.who_lock = {} self.who_cache = {} self.who_callbacks = {} def signedOn(self): """Called once we've connected to a network""" self.logger.info("Signed on as %s" % self.nickname) # Give the factory access to the bot if self.factory is None: raise InternalError("Factory must be set on CardinalBot instance") # Give the factory the instance it created in case it needs to # interface for error handling or metadata retention. self.factory.cardinal = self # Set the currently connected network self.network = self.factory.network # Attempt to identify with NickServ, if a password was given if self.factory.password: self.logger.info("Attempting to identify with NickServ") self.msg("NickServ", "IDENTIFY %s" % (self.factory.password, )) # Creates an instance of EventManager self.logger.debug("Creating new EventManager instance") self.event_manager = EventManager(self) # Register events try: self.event_manager.register("irc.invite", 2) self.event_manager.register("irc.privmsg", 3) self.event_manager.register("irc.notice", 3) self.event_manager.register("irc.nick", 2) self.event_manager.register("irc.mode", 3) self.event_manager.register("irc.topic", 3) self.event_manager.register("irc.join", 2) self.event_manager.register("irc.part", 3) self.event_manager.register("irc.kick", 4) self.event_manager.register("irc.quit", 2) except EventAlreadyExistsError: self.logger.error("Could not register core IRC events", exc_info=True) # Create an instance of PluginManager, giving it an instance of ourself # to pass to plugins, as well as a list of initial plugins to load. self.logger.debug("Creating new PluginManager instance") self.plugin_manager = PluginManager(self, self.factory.plugins) # Attempt to join channels for channel in self.factory.channels: self.join(channel) # Set the uptime as now and grab the boot time from the factory self.uptime = datetime.now() self.booted = self.factory.booted def joined(self, channel): """Called when we join a channel. channel -- Channel joined. Provided by Twisted. """ self.logger.info("Joined %s" % channel) def irc_PRIVMSG(self, prefix, params): """Called when we receive a message in a channel or PM.""" # Break down the user into usable groups user = re.match(self.user_regex, prefix) channel = params[0] message = params[1] self.logger.debug( "%s!%s@%s to %s: %s" % (user.group(1), user.group(2), user.group(3), channel, message)) self.event_manager.fire("irc.privmsg", user, channel, message) # If the channel is ourselves, this is actually a PM to us, and so # we'll update the channel variable to the sender's username to make # replying a little easier. if channel == self.nickname: channel = user.group(1) # Attempt to call a command. If it doesn't appear to PluginManager to # be a command, this will just fall through. If it matches command # syntax but there is no matching command, then we should catch the # exception. try: self.plugin_manager.call_command(user, channel, message) except CommandNotFoundError: # This is just an info, since anyone can trigger it, not really a # bad thing. self.logger.info("Unable to find a matching command", exc_info=True) def who(self, channel, callback): """Lists the users in a channel. Keyword arguments: channel -- Channel to list users of. callback -- A callback that will receive the list of users. Returns: None. However, the callback will receive a single argument, which is the list of users. """ if channel not in self.who_callbacks: self.who_callbacks[channel] = [] self.who_callbacks[channel].append(callback) self.logger.info("WHO list requested for %s" % channel) if channel not in self.who_lock: self.logger.info("Making WHO request to server") # Set a lock to prevent trying to track responses from the server self.who_lock[channel] = True # Empty the cache to ensure no old users show up. # TODO: Add actual caching and user tracking. self.who_cache[channel] = [] # Send the actual WHO command to the server. irc_RPL_WHOREPLY will # receive a response when the server sends one. self.sendLine("WHO %s" % channel) def irc_RPL_WHOREPLY(self, *nargs): "Receives reply from WHO command and sends to caller" response = nargs[1] # Same format as other events (nickname!ident@hostname) user = ( response[5], # nickname response[2], # ident response[3], # hostname ) channel = response[1] self.who_cache[channel].append(user) def irc_RPL_ENDOFWHO(self, *nargs): "Called when WHO output is complete" response = nargs[1] channel = response[1] self.logger.info("Calling WHO callbacks for %s" % channel) for callback in self.who_callbacks[channel]: callback(self.who_cache[channel]) def irc_NOTICE(self, prefix, params): """Called when a notice is sent to a channel or privately""" user = re.match(self.user_regex, prefix) channel = params[0] message = params[1] # Sent by network, not a real user if not user: self.logger.debug("%s sent notice to %s: %s" % (prefix, channel, message)) return self.logger.debug( "%s!%s@%s sent notice to %s: %s" % (user.group(1), user.group(2), user.group(3), channel, message)) # Lots of NOTICE messages when connecting, and event manager may not be # initialized yet. if self.event_manager: self.event_manager.fire("irc.notice", user, channel, message) def irc_NICK(self, prefix, params): """Called when a user changes their nick""" user = re.match(self.user_regex, prefix) new_nick = params[0] self.logger.debug( "%s!%s@%s changed nick to %s" % (user.group(1), user.group(2), user.group(3), new_nick)) self.event_manager.fire("irc.nick", user, new_nick) def irc_TOPIC(self, prefix, params): """Called when a new topic is set""" user = re.match(self.user_regex, prefix) channel = params[0] topic = params[1] self.logger.debug( "%s!%s@%s changed topic in %s to %s" % (user.group(1), user.group(2), user.group(3), channel, topic)) self.event_manager.fire("irc.topic", user, channel, topic) def irc_MODE(self, prefix, params): """Called when a mode is set on a channel""" user = re.match(self.user_regex, prefix) channel = params[0] mode = ' '.join(params[1:]) # Sent by network, not a real user if not user: self.logger.debug("%s set mode on %s (%s)" % (prefix, channel, mode)) return self.logger.debug( "%s!%s@%s set mode on %s (%s)" % (user.group(1), user.group(2), user.group(3), channel, mode)) # Can get called during connection, in which case EventManager won't be # initialized yet if self.event_manager: self.event_manager.fire("irc.mode", user, channel, mode) def irc_JOIN(self, prefix, params): """Called when a user joins a channel""" user = re.match(self.user_regex, prefix) channel = params[0] self.logger.debug( "%s!%s@%s joined %s" % (user.group(1), user.group(2), user.group(3), channel)) self.event_manager.fire("irc.join", user, channel) def irc_PART(self, prefix, params): """Called when a user parts a channel""" user = re.match(self.user_regex, prefix) channel = params[0] if len(params) == 1: reason = "No Message" else: reason = params[1] self.logger.debug( "%s!%s@%s parted %s (%s)" % (user.group(1), user.group(2), user.group(3), channel, reason)) self.event_manager.fire("irc.part", user, channel, reason) def irc_KICK(self, prefix, params): """Called when a user is kicked from a channel""" user = re.match(self.user_regex, prefix) nick = params[0] channel = params[1] if len(params) == 2: reason = "No Message" else: reason = params[2] self.logger.debug("%s!%s@%s kicked %s from %s (%s)" % (user.group(1), user.group(2), user.group(3), nick, channel, reason)) self.event_manager.fire("irc.kick", user, channel, nick, reason) def irc_QUIT(self, prefix, params): """Called when a user quits the network""" user = re.match(self.user_regex, prefix) if len(params) == 0: reason = "No Message" else: reason = params[0] self.logger.debug( "%s!%s@%s quit (%s)" % (user.group(1), user.group(2), user.group(3), reason)) self.event_manager.fire("irc.quit", user, reason) def irc_unknown(self, prefix, command, params): """Called when Twisted doesn't understand an IRC command. Keyword arguments: prefix -- User sending command. Provided by Twisted. command -- Command that wasn't recognized. Provided by Twisted. params -- Params for command. Provided by Twisted. """ # A user has invited us to a channel if command == "INVITE": # Break down the user into usable groups user = re.match(self.user_regex, prefix) channel = params[1] self.logger.debug("%s invited us to %s" % (user.group(1), channel)) # Fire invite event, so plugins can hook into it self.event_manager.fire("irc.invite", user, channel) # TODO: Call matching plugin events def config(self, plugin): """Returns a given loaded plugin's config. Keyword arguments: plugin -- String containing a plugin name. Returns: dict -- Dictionary containing plugin config. Raises: ConfigNotFoundError - When config can't be found for the plugin. """ if self.plugin_manager is None: self.logger.error( "PluginManager has not been initialized! Can't return config " "for plugin: %s" % plugin) raise PluginError("PluginManager has not yet been initialized") try: config = self.plugin_manager.get_config(plugin) except ConfigNotFoundError: # Log and raise the exception again self.logger.exception("Couldn't find config for plugin: %s" % plugin) raise return config def sendMsg(self, channel, message, length=None): """Wrapper command to send messages. Keyword arguments: channel -- Channel to send message to. message -- Message to send. length -- Length of message. Twisted will calculate if None given. """ self.logger.info("Sending in %s: %s" % (channel, message)) self.msg(channel, message, length) def disconnect(self, message=''): """Wrapper command to quit Cardinal. Keyword arguments: message -- Message to insert into QUIT, if any. """ self.logger.info("Disconnecting from network") self.plugin_manager.unload_all() self.factory.disconnect = True self.quit(message)
def test_constructor(self): manager = PluginManager(Mock(), [], []) assert len(manager.plugins) == 0
def test_reload_for_error_in_constructor_succeeds(self): """Cardinal has a bug where constructor exceptions brick a plugin. Basically, the plugin won't be reloadable without a restart of the bot itself. This is obviously problematic for plugin development. This test aims to solve the problem and act as a regression test. """ plugin = 'valid' with tempdir('cardinal_fixtures') as fixture_dir: with open(os.path.join(fixture_dir, '__init__.py'), 'w') as f: pass plugins_dir = os.path.join(fixture_dir, 'test_plugins') os.mkdir(plugins_dir) with open(os.path.join(plugins_dir, '__init__.py'), 'w') as f: pass plugin_dir = os.path.join( plugins_dir, plugin, ) os.mkdir(plugin_dir) with open(os.path.join(plugin_dir, '__init__.py'), 'w') as f: pass # Write a plugin that will load successfully with open( os.path.join( FIXTURE_PATH, 'fake_plugins', 'valid', 'plugin.py', ), 'r' ) as f: valid_plugin = f.read() with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(valid_plugin) # Make sure the fake_plugins dir is in the import path sys.path.insert(0, os.path.join(fixture_dir)) try: # Load the plugin successfully plugin_manager = PluginManager( self.cardinal, [], self.blacklist, _plugin_module_import_prefix='test_plugins', _plugin_module_directory=plugins_dir) self.assert_load_success( [plugin], plugin_manager=plugin_manager, ) # Now overwrite with a plugin that will fail during load with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(""" class TestPlugin: def __init__(self): self.x = y # this should raise def setup(): return TestPlugin() """) # Verify the load failed self.assert_load_failed([plugin], plugin_manager) # Now back to a valid version with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(valid_plugin) # and then make sure the valid version does load successfully self.assert_load_success( [plugin], plugin_manager=plugin_manager, ) finally: sys.path.pop(0)
class TestPluginManager: def setup_method(self): mock_cardinal = self.cardinal = Mock(spec=CardinalBot) mock_cardinal.nickname = 'Cardinal' mock_cardinal.event_manager = self.event_manager = \ EventManager(mock_cardinal) self.blacklist = {} plugins_directory = os.path.abspath(os.path.join( os.path.dirname(os.path.realpath(os.path.abspath(__file__))), '..', 'cardinal/fixtures/fake_plugins', )) self.plugin_manager = PluginManager( mock_cardinal, [], self.blacklist, _plugin_module_import_prefix='fake_plugins', _plugin_module_directory=plugins_directory, ) def assert_load_success(self, plugins, assert_callbacks_is_empty=True, assert_commands_is_empty=True, assert_blacklist_is_empty=True, assert_config_is_none=True, plugin_manager=None): plugin_manager = plugin_manager or self.plugin_manager failed_plugins = plugin_manager.load(plugins) # regardless of the whether plugins was a string or list, the rest of # this function requires it to be a list if not isinstance(plugins, list): plugins = [plugins] assert failed_plugins == [] assert len(list(plugin_manager.plugins.keys())) == len(plugins) for name in plugins: # class name for plugins must be Test(CamelCaseName)Plugin # e.g. an_example -> TestAnExamplePlugin class_ = 'Test' name_pieces = name.split('_') for name_piece in name_pieces: class_ += name_piece[0].upper() + name_piece[1:].lower() class_ += 'Plugin' # check that everything was set correctly assert name in list(plugin_manager.plugins.keys()) assert plugin_manager.plugins[name]['name'] == name assert isinstance( plugin_manager.plugins[name]['instance'], object, ) if assert_commands_is_empty: assert plugin_manager.plugins[name]['commands'] == [] if assert_callbacks_is_empty: assert plugin_manager.plugins[name]['callbacks'] == [] assert plugin_manager.plugins[name]['callback_ids'] == {} if assert_config_is_none: assert plugin_manager.plugins[name]['config'] is None if assert_blacklist_is_empty: assert plugin_manager.plugins[name]['blacklist'] == [] def assert_load_failed(self, plugins, plugin_manager=None): plugin_manager = plugin_manager or self.plugin_manager failed_plugins = plugin_manager.load(plugins) # regardless of the whether plugins was a string or list, the rest of # this function requires it to be a list if not isinstance(plugins, list): plugins = [plugins] assert failed_plugins == plugins assert plugin_manager.plugins == {} def test_constructor(self): manager = PluginManager(Mock(), [], []) assert len(manager.plugins) == 0 @patch.object(PluginManager, 'load') def test_constructor_loads_plugins(self, load): plugins = ['foo', 'bar'] PluginManager(Mock(), plugins, []) load.assert_called_with(plugins) @pytest.mark.parametrize("plugins", [ 'a string', 12345, 0.0, object(), ]) def test_constructor_plugins_not_a_list_typeerror(self, plugins): with pytest.raises(TypeError): PluginManager(Mock(), plugins) @pytest.mark.parametrize("plugins", [ 12345, 0.0, object(), ]) def test_load_plugins_not_a_list_or_string_typeerror(self, plugins): with pytest.raises(TypeError): self.plugin_manager.load(plugins) @pytest.mark.parametrize("plugins", [ # This plugin won't be found in the plugins directory 'nonexistent', # This plugin is missing a setup() function 'setup_missing', # This plugin's setup() function takes three arguments 'setup_too_many_arguments', # This plugin's entrypoint isn't callable 'entrypoint_invalid', ]) def test_load_invalid(self, plugins): self.assert_load_failed(plugins) @pytest.mark.parametrize("plugins", [ 'valid', 'entrypoint', ['valid'], # test list format # This plugin has both a config.yaml and config.json which used to be # disallowed, but now config.yaml is ignored 'config_ambiguous', ]) def test_load_valid(self, plugins): self.assert_load_success(plugins, assert_config_is_none=False) def test_load_cardinal_passed(self): name = 'setup_one_argument' self.assert_load_success(name) assert self.plugin_manager.plugins[name]['instance'].cardinal is \ self.cardinal def test_load_config_passed(self): name = 'setup_two_arguments' self.assert_load_success(name, assert_config_is_none=False) assert self.plugin_manager.plugins[name]['instance'].cardinal is \ self.cardinal assert self.plugin_manager.plugins[name]['instance'].config == \ {'test': True} def test_load_config_passed_opposite_order(self): name = 'setup_config_cardinal_order' self.assert_load_success(name, assert_config_is_none=False) assert self.plugin_manager.plugins[name]['instance'].cardinal is \ self.cardinal assert self.plugin_manager.plugins[name]['instance'].config == \ {'test': True} def test_load_invalid_json_config(self): name = 'config_invalid_json' self.assert_load_success(name) # no error for some reason # invalid json should be ignored assert self.plugin_manager.plugins[name]['config'] is None def test_get_config_unloaded_plugin(self): name = 'nonexistent_plugin' with pytest.raises(exceptions.ConfigNotFoundError): self.plugin_manager.get_config(name) def test_get_config_plugin_without_config(self): name = 'valid' self.assert_load_success(name) with pytest.raises(exceptions.ConfigNotFoundError): self.plugin_manager.get_config(name) def test_get_config_json(self): name = 'config_valid_json' self.assert_load_success(name, assert_config_is_none=False) assert self.plugin_manager.get_config(name) == {'test': True} def test_get_config_yaml_ignored(self): name = 'config_valid_yaml' self.assert_load_success(name, assert_config_is_none=False) with pytest.raises(exceptions.ConfigNotFoundError): self.plugin_manager.get_config(name) def test_plugin_iteration(self): plugins = [ 'setup_one_argument', 'setup_two_arguments', 'valid', ] self.assert_load_success(plugins, assert_config_is_none=False) for plugin in self.plugin_manager: assert plugin == self.plugin_manager.plugins[plugin['name']] def test_reload_valid_succeeds(self): name = 'valid' plugins = [name] # first load is not a reload self.assert_load_success(plugins) # second load is self.assert_load_success(plugins) # and so on... self.assert_load_success(plugins) def test_reload_exception_in_close_succeeds(self): name = 'close_raises_exception' plugins = [name] self.assert_load_success(plugins) # should reload successfully despite bad close() self.assert_load_success(plugins) def test_reload_reregisters_event(self): name = 'registers_event' plugins = [name] assert 'test.event' not in self.event_manager.registered_events self.assert_load_success(plugins) assert 'test.event' in self.event_manager.registered_events self.assert_load_success(plugins) assert 'test.event' in self.event_manager.registered_events def test_reload_for_error_in_constructor_succeeds(self): """Cardinal has a bug where constructor exceptions brick a plugin. Basically, the plugin won't be reloadable without a restart of the bot itself. This is obviously problematic for plugin development. This test aims to solve the problem and act as a regression test. """ plugin = 'valid' with tempdir('cardinal_fixtures') as fixture_dir: with open(os.path.join(fixture_dir, '__init__.py'), 'w') as f: pass plugins_dir = os.path.join(fixture_dir, 'test_plugins') os.mkdir(plugins_dir) with open(os.path.join(plugins_dir, '__init__.py'), 'w') as f: pass plugin_dir = os.path.join( plugins_dir, plugin, ) os.mkdir(plugin_dir) with open(os.path.join(plugin_dir, '__init__.py'), 'w') as f: pass # Write a plugin that will load successfully with open( os.path.join( FIXTURE_PATH, 'fake_plugins', 'valid', 'plugin.py', ), 'r' ) as f: valid_plugin = f.read() with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(valid_plugin) # Make sure the fake_plugins dir is in the import path sys.path.insert(0, os.path.join(fixture_dir)) try: # Load the plugin successfully plugin_manager = PluginManager( self.cardinal, [], self.blacklist, _plugin_module_import_prefix='test_plugins', _plugin_module_directory=plugins_dir) self.assert_load_success( [plugin], plugin_manager=plugin_manager, ) # Now overwrite with a plugin that will fail during load with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(""" class TestPlugin: def __init__(self): self.x = y # this should raise def setup(): return TestPlugin() """) # Verify the load failed self.assert_load_failed([plugin], plugin_manager) # Now back to a valid version with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f: f.write(valid_plugin) # and then make sure the valid version does load successfully self.assert_load_success( [plugin], plugin_manager=plugin_manager, ) finally: sys.path.pop(0) @pytest.mark.parametrize("plugins", [ 12345, 0.0, object(), ]) def test_unload_plugins_not_a_list_or_string_typeerror(self, plugins): with pytest.raises(TypeError): self.plugin_manager.unload(plugins) def test_unload_plugins_never_loaded_plugin_fails(self): name = 'test_never_loaded_plugin' plugins = [name] assert self.plugin_manager.plugins == {} failed_plugins = self.plugin_manager.unload(plugins) assert failed_plugins == plugins assert self.plugin_manager.plugins == {} def test_unload_exception_in_close_fails(self): name = 'close_raises_exception' plugins = [name] self.assert_load_success(plugins) failed_plugins = self.plugin_manager.unload(plugins) # Failed plugins represents a list of plugins that errored on unload # but the plugin should still be removed from the PluginManager. # # FIXME Unfortunately, this means that a plugin might fail to remove # its callbacks from an event, and then be inaccessible by Cardinal. assert failed_plugins == plugins assert self.plugin_manager.plugins == {} @pytest.mark.parametrize("plugins", [ # This plugin contains no close() method 'valid', ['valid'], # test list format # This plugin has a no-op close() method 'close_no_arguments', ]) def test_unload_valid_succeeds(self, plugins): self.assert_load_success(plugins) failed_plugins = self.plugin_manager.unload(plugins) assert failed_plugins == [] assert list(self.plugin_manager.plugins.keys()) == [] def test_unload_passes_cardinal(self): plugin = 'close_one_argument' self.assert_load_success(plugin) instance = self.plugin_manager.plugins[plugin]['instance'] failed_plugins = self.plugin_manager.unload(plugin) assert failed_plugins == [] assert list(self.plugin_manager.plugins.keys()) == [] # Our close() method will set module.cardinal for us to inspect assert instance.cardinal is self.cardinal def test_unload_too_many_arguments_in_close(self): plugin = 'close_too_many_arguments' self.assert_load_success(plugin) instance = self.plugin_manager.plugins[plugin]['instance'] failed_plugins = self.plugin_manager.unload(plugin) assert failed_plugins == [plugin] assert list(self.plugin_manager.plugins.keys()) == [] # Our close() method will set module.called to True if called assert instance.called is False def test_unload_all(self): self.assert_load_success([ 'valid', 'close_no_arguments', 'close_too_many_arguments', ]) assert len(self.plugin_manager.plugins) == 3 # Doesn't return what failed to unload cleanly, but should unload # everything regardless self.plugin_manager.unload_all() assert self.plugin_manager.plugins == {} def test_event_callback_registered(self): name = 'event_callback' event = 'irc.raw' self.assert_load_success(name, assert_callbacks_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] # test that plugin manager is tracking the callback assert len(self.plugin_manager.plugins[name]['callback_ids']) == 1 assert self.plugin_manager.plugins[name]['callbacks'] == [ { 'event_names': [event], 'method': instance.irc_raw_callback, } ] # test that event manager had callback registered self.event_manager.register(event, 1) message = 'this is a test message' self.event_manager.fire(event, message) assert instance.cardinal is self.cardinal assert instance.messages == [message] self.event_manager.fire(event, message) assert instance.messages == [message, message] def test_event_callback_unregistered(self): name = 'event_callback' event = 'irc.raw' self.assert_load_success(name, assert_callbacks_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] # make sure an event is sent to the callback self.event_manager.register(event, 1) message = 'this is a test message' self.event_manager.fire(event, message) assert instance.messages == [message] # unload and make sure no more events are sent self.plugin_manager.unload(name) self.event_manager.fire(event, message) assert instance.messages == [message] def test_load_bad_callback_fails(self): name = 'event_callback' event = 'irc.raw' # this will cause registration to fail, as our callback only takes 1 # param other than cardinal self.event_manager.register(event, 2) self.assert_load_failed([name]) @defer.inlineCallbacks def test_load_multiple_callbacks_one_fails(self): name = 'multiple_event_callbacks_one_fails' event = 'foo' self.event_manager.register(event, 0) self.assert_load_failed([name]) # if this actually fires the one good callback we should get an # exception (we don't expect one) res = yield self.event_manager.fire(event) # this should return False if no callbacks fired assert res is False def test_load_command_registration(self): name = 'commands' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] assert self.plugin_manager.plugins[name]['commands'] == [ instance.command1, instance.command2, instance.regex_command, ] def test_itercommands(self): name = 'commands' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] commands = [ instance.command1, instance.command2, instance.regex_command, ] for command in self.plugin_manager.itercommands(): commands.remove(command) assert commands == [] @defer.inlineCallbacks def test_call_command_no_regex_match(self): yield self.plugin_manager.call_command(('nick', 'ident', 'host'), "#channel", "this isn't a command") @defer.inlineCallbacks def test_call_command_no_command_match(self): # FIXME: this isn't really an error... with pytest.raises(exceptions.CommandNotFoundError): yield self.plugin_manager.call_command(('nick', 'ident', 'host'), "#channel", ".command") @defer.inlineCallbacks def test_command_called(self): name = 'commands' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] user = ('user', 'ident', 'vhost') channel = '#channel' message = '.command1 foobar' expected_calls = [] assert instance.command1_calls == expected_calls assert instance.command2_calls == [] assert instance.regex_command_calls == [] expected_calls.append((self.cardinal, user, channel, message)) yield self.plugin_manager.call_command(user, channel, message) assert instance.command1_calls == expected_calls assert instance.command2_calls == [] assert instance.regex_command_calls == [] message = '.command1_alias bar bar bar' expected_calls.append((self.cardinal, user, channel, message)) yield self.plugin_manager.call_command(user, channel, message) assert instance.command1_calls == expected_calls assert instance.command2_calls == [] assert instance.regex_command_calls == [] message = 'this shouldnt trigger' yield self.plugin_manager.call_command(user, channel, message) assert instance.command1_calls == expected_calls assert instance.command2_calls == [] assert instance.regex_command_calls == [] @defer.inlineCallbacks def test_regex_command_called(self): name = 'commands' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] user = ('user', 'ident', 'vhost') channel = '#channel' message = 'regex foobar' expected_calls = [] assert instance.regex_command_calls == expected_calls assert instance.command1_calls == [] assert instance.command2_calls == [] expected_calls.append((self.cardinal, user, channel, message)) yield self.plugin_manager.call_command(user, channel, message) assert instance.regex_command_calls == expected_calls assert instance.command1_calls == [] assert instance.command2_calls == [] message = 'regex bar bar bar' expected_calls.append((self.cardinal, user, channel, message)) yield self.plugin_manager.call_command(user, channel, message) assert instance.regex_command_calls == expected_calls assert instance.command1_calls == [] assert instance.command2_calls == [] message = 'this shouldnt trigger' yield self.plugin_manager.call_command(user, channel, message) assert instance.regex_command_calls == expected_calls assert instance.command1_calls == [] assert instance.command2_calls == [] @defer.inlineCallbacks def test_command_raises_exception_caught(self): name = 'command_raises_exception' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] user = ('user', 'ident', 'vhost') channel = '#channel' message = '.command foobar' expected_calls = [] assert instance.command_calls == expected_calls expected_calls.append((self.cardinal, user, channel, message)) yield self.plugin_manager.call_command(user, channel, message) assert instance.command_calls == expected_calls def test_blacklist_unloaded_plugin(self): name = 'commands' channel = '#channel' assert self.plugin_manager.blacklist(name, channel) is False def test_blacklist_invalid_channel(self): name = 'commands' channel = 321 with pytest.raises(TypeError): self.plugin_manager.blacklist(name, channel) def test_blacklist(self): name = 'commands' channel = '#channel' self.assert_load_success(name, assert_commands_is_empty=False) assert self.plugin_manager.blacklist(name, channel) is True assert self.plugin_manager.plugins[name]['blacklist'] == [channel] def test_blacklist_from_config(self): name = 'commands' channel = '#channel' # this works since dict is by-reference in Python self.blacklist[name] = [channel] self.assert_load_success(name, assert_commands_is_empty=False, assert_blacklist_is_empty=False) assert self.plugin_manager.plugins[name]['blacklist'] == [channel] def test_blacklist_multiple_channels(self): name = 'commands' channels = ['#channel1', '#channel2'] self.assert_load_success(name, assert_commands_is_empty=False) assert self.plugin_manager.blacklist(name, channels) is True assert self.plugin_manager.plugins[name]['blacklist'] == channels def test_unblacklist_invalid_channel(self): name = 'commands' channel = 321 with pytest.raises(TypeError): self.plugin_manager.unblacklist(name, channel) def test_unblacklist_unloaded_plugin(self): name = 'commands' channel = '#channel' assert self.plugin_manager.unblacklist(name, channel) is False def test_unblacklist(self): name = 'commands' channel = '#channel' self.assert_load_success(name, assert_commands_is_empty=False) assert self.plugin_manager.blacklist(name, channel) is True assert self.plugin_manager.plugins[name]['blacklist'] == [channel] assert self.plugin_manager.unblacklist(name, channel) == [] assert self.plugin_manager.plugins[name]['blacklist'] == [] def test_unblacklist_multiple_channels(self): name = 'commands' channels = ['#channel1', '#channel2'] self.assert_load_success(name, assert_commands_is_empty=False) assert self.plugin_manager.blacklist(name, channels) is True assert self.plugin_manager.plugins[name]['blacklist'] == channels assert self.plugin_manager.unblacklist(name, channels) == [] assert self.plugin_manager.plugins[name]['blacklist'] == [] def test_unblacklist_non_blacklisted_channels(self): name = 'commands' channel = '#channel' self.assert_load_success(name, assert_commands_is_empty=False) assert self.plugin_manager.blacklist(name, channel) is True assert self.plugin_manager.plugins[name]['blacklist'] == [channel] assert self.plugin_manager.unblacklist( name, [channel, '#notblacklisted']) == ['#notblacklisted'] assert self.plugin_manager.plugins[name]['blacklist'] == [] def test_itercommands_adheres_to_blacklist(self): name = 'commands' channel = '#channel' self.assert_load_success(name, assert_commands_is_empty=False) self.plugin_manager.blacklist(name, channel) commands = [command for command in self.plugin_manager.itercommands()] assert len(commands) == 3 commands = [command for command in self.plugin_manager.itercommands(channel)] assert len(commands) == 0 @defer.inlineCallbacks def test_call_command_adheres_to_blacklist(self): name = 'commands' channel = '#channel' self.assert_load_success(name, assert_commands_is_empty=False) instance = self.plugin_manager.plugins[name]['instance'] self.plugin_manager.blacklist(name, channel) user = ('user', 'ident', 'vhost') message = '.command1 foobar' expected_calls = [] assert instance.command1_calls == expected_calls # FIXME this should maybe not fire if we find a command but it is # blacklisted with pytest.raises(exceptions.CommandNotFoundError): yield self.plugin_manager.call_command(user, channel, message) assert instance.command1_calls == expected_calls
def test_constructor_plugins_not_a_list_typeerror(self, plugins): with pytest.raises(TypeError): PluginManager(Mock(), plugins)
def test_constructor_loads_plugins(self, load): plugins = ['foo', 'bar'] PluginManager(Mock(), plugins, []) load.assert_called_with(plugins)