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""" 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)
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 TestEventManager: def setup_method(self): mock_cardinal = self.cardinal = Mock(spec=CardinalBot) self.event_manager = EventManager(mock_cardinal) def _callback(self, cardinal): """Used as a test callback.""" def assert_register_success(self, name, params=0): self.event_manager.register(name, params) assert name in self.event_manager.registered_events assert name in self.event_manager.registered_callbacks assert self.event_manager.registered_events[name] == params def assert_register_callback_success(self, name, callback=None): callback = callback or self._callback callback_count = len(self.event_manager.registered_callbacks[name]) \ if name in self.event_manager.registered_callbacks else 0 # callback id is used for removal callback_id = self.event_manager.register_callback(name, callback) assert isinstance(callback_id, str) assert len(self.event_manager.registered_callbacks[name]) == \ callback_count + 1 return callback_id def assert_remove_callback_success(self, name, callback_id): callback_count = len(self.event_manager.registered_callbacks[name]) self.event_manager.remove_callback(name, callback_id) assert len(self.event_manager.registered_callbacks[name]) == \ callback_count - 1 def test_constructor(self): assert self.event_manager.cardinal == self.cardinal assert isinstance(self.event_manager.logger, logging.Logger) assert isinstance(self.event_manager.registered_events, dict) assert isinstance(self.event_manager.registered_callbacks, dict) def test_register(self): name = 'test_event' self.assert_register_success(name) assert len(self.event_manager.registered_callbacks[name]) == 0 def test_register_duplicate_event(self): name = 'test_event' self.assert_register_success(name) with pytest.raises(exceptions.EventAlreadyExistsError): self.event_manager.register(name, 1) @pytest.mark.parametrize("param_count", [ 3.14, 'foobar', object(), ]) def test_register_invalid_param_count(self, param_count): with pytest.raises(TypeError): self.event_manager.register('test_event', param_count) def test_remove(self): name = 'test_event' self.assert_register_success(name) self.event_manager.remove(name) assert name not in self.event_manager.registered_events assert name not in self.event_manager.registered_callbacks def test_remove_event_doesnt_exist(self): name = 'test_event' with pytest.raises(exceptions.EventDoesNotExistError): self.event_manager.remove(name) def test_add_callback(self): def callback(cardinal): pass name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, callback) def test_add_callback_registered_varargs(self): def callback(*args): pass name = 'test_event' self.assert_register_success(name, params=3) self.assert_register_callback_success(name, callback) def test_add_callback_non_callable(self): callback = 'this is not callable' name = 'test_event' self.assert_register_success(name) with pytest.raises(exceptions.EventCallbackError): self.event_manager.register_callback(name, callback) def test_add_callback_without_registered_event(self): # only accepts cardinal def callback(cardinal): pass # accepts cardinal and another param def callback2(cardinal, _): pass name = 'test_event' # should not validate params aside from at least accepting cardinal self.assert_register_callback_success(name, callback) self.assert_register_callback_success(name, callback2) def test_add_callback_method_ignores_self(self): name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, self._callback) def test_add_callback_validates_required_args(self): def callback(cardinal, one_too_many_args): pass name = 'test_event' self.assert_register_success(name) with pytest.raises(exceptions.EventCallbackError): self.event_manager.register_callback(name, callback) def test_add_callback_requires_cardinal_arg(self): def callback(): pass name = 'test_event' with pytest.raises(exceptions.EventCallbackError): self.event_manager.register_callback(name, callback) def test_add_callback_varargs_success(self): def callback(*args, **kwargs): pass name = 'test_event' self.event_manager.register_callback(name, callback) def test_add_callback_requires_keyword_fails(self): def callback(cardinal, *args, foobar): pass name = 'test_event' with pytest.raises(exceptions.EventCallbackError): self.event_manager.register_callback(name, callback) def test_add_callback_deferred_signature(self): @defer.inlineCallbacks def callback(cardinal): pass name = 'test_event' self.event_manager.register_callback(name, callback) def test_remove_callback(self): name = 'test_event' cb_id = self.assert_register_callback_success(name) self.assert_remove_callback_success(name, cb_id) def test_remove_callback_nonexistent_event_silent(self): name = 'test_event' callback_id = 'nonexistent' assert name not in self.event_manager.registered_callbacks self.event_manager.remove_callback(name, callback_id) def test_remove_callback_nonexistent_callback_silent(self): name = 'test_event' callback_id = 'nonexistent' self.assert_register_success(name) assert len(self.event_manager.registered_callbacks[name]) == 0 self.event_manager.remove_callback(name, callback_id) assert len(self.event_manager.registered_callbacks[name]) == 0 def test_fire_event_does_not_exist(self): name = 'test_event' with pytest.raises(exceptions.EventDoesNotExistError): self.event_manager.fire(name) @defer.inlineCallbacks def test_fire(self): args = [] def callback(*fargs): for arg in fargs: args.append(arg) name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, callback) accepted = yield self.event_manager.fire(name) assert accepted is True assert args == [self.cardinal] @defer.inlineCallbacks def test_fire_callback_rejects(self): args = [] def callback(*fargs): for arg in fargs: args.append(arg) raise exceptions.EventRejectedMessage() name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, callback) accepted = yield self.event_manager.fire(name) assert accepted is False assert args == [self.cardinal] @defer.inlineCallbacks def test_fire_multiple_callbacks(self): args = [] def generate_cb(reject): def callback(*fargs): for arg in fargs: args.append(arg) if reject: raise exceptions.EventRejectedMessage() return callback name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, generate_cb(True)) self.assert_register_callback_success(name, generate_cb(False)) self.assert_register_callback_success(name, generate_cb(False)) accepted = yield self.event_manager.fire(name) assert accepted is True assert args == [self.cardinal, self.cardinal, self.cardinal] @defer.inlineCallbacks def test_fire_multiple_callbacks_all_reject(self): def generate_cb(reject): def callback(*fargs): if reject: raise exceptions.EventRejectedMessage() return callback name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, generate_cb(True)) self.assert_register_callback_success(name, generate_cb(True)) accepted = yield self.event_manager.fire(name) assert accepted is False @defer.inlineCallbacks def test_fire_multiple_callbacks_one_errors(self): def generate_cb(error): def callback(*fargs): if error: raise Exception() return callback name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, generate_cb(False)) self.assert_register_callback_success(name, generate_cb(True)) accepted = yield self.event_manager.fire(name) assert accepted is True @defer.inlineCallbacks def test_fire_multiple_callbacks_all_error(self): def generate_cb(error): def callback(*fargs): if error: raise Exception() return callback name = 'test_event' self.assert_register_success(name) self.assert_register_callback_success(name, generate_cb(True)) self.assert_register_callback_success(name, generate_cb(True)) self.assert_register_callback_success(name, generate_cb(True)) accepted = yield self.event_manager.fire(name) assert accepted is False def test_add_callback_wont_duplicate_id(self): name = 'test_event' with patch.object(self.event_manager, '_generate_id') as mock_gen_id: mock_gen_id.side_effect = ['ABC123', 'ABC123', 'DEF456'] event_id1 = self.assert_register_callback_success(name) event_id2 = self.assert_register_callback_success(name) assert event_id1 == 'ABC123' assert event_id2 != 'ABC123'