def __init__(self, server, language='aml'): """ @type server: firefly.containers.Server @type language: C{str} @param language: The language engine to use for this instance. """ # Set up logging self._log = logging.getLogger('firefly') self.language = None """@type : firefly.languages.interface.LanguageInterface""" self._load_language_interface(language) # Set up our registry and server containers, then run setup self.registry = _Registry(self) self.server_info = ServerInfo() self.server = server self._setup() self.load_language_files() # Set up our authentication manager self.auth = Auth(self) # Finally, now that everything is set up, load our plugins self.plugins = pkg_resources.get_entry_map('firefly_irc', 'firefly.plugins') scanner = venusian.Scanner(firefly=self) scanner.scan(plugins)
class FireflyIRC(IRCClient): """ Firefly IRC client. """ # Default nick nickname = "Firefly" # Path constants CONFIG_DIR = os.path.join(appdirs.user_config_dir('firefly'), 'irc') DATA_DIR = os.path.join(appdirs.user_data_dir('firefly'), 'irc') LOG_DIR = os.path.join(appdirs.user_log_dir('firefly'), 'irc') def __init__(self, server, language='aml'): """ @type server: firefly.containers.Server @type language: C{str} @param language: The language engine to use for this instance. """ # Set up logging self._log = logging.getLogger('firefly') self.language = None """@type : firefly.languages.interface.LanguageInterface""" self._load_language_interface(language) # Set up our registry and server containers, then run setup self.registry = _Registry(self) self.server_info = ServerInfo() self.server = server self._setup() self.load_language_files() # Set up our authentication manager self.auth = Auth(self) # Finally, now that everything is set up, load our plugins self.plugins = pkg_resources.get_entry_map('firefly_irc', 'firefly.plugins') scanner = venusian.Scanner(firefly=self) scanner.scan(plugins) @staticmethod def load_configuration(name, plugin=None, basedir=None, default=None, ext='.cfg'): """ Load a single configuration file. @type name: str @param name: Name of the configuration file *WITHOUT* the .cfg file extension @type plugin: PluginAbstract or None @param plugin: Plugin to load a configuration file from, or None to load a system configuration file. @type basedir: str or None @param basedir: Optional config path prefix @type default: str or None @param default: Name of the default configuration file. Defaults to the name argument. @type ext: str or None @param ext: Configuration file extension. None for no extension (e.g. server configuration files). @raise ValueError: Raised if the supplied configuration file does not exist @rtype: ConfigParser """ log = logging.getLogger('firefly') ext = ext or '' # If None, we need to convert the extension to an empty string paths = [] ################################ # Default Configuration # ################################ app_path = plugin.plugin_path if plugin else os.path.dirname(os.path.realpath(__file__)) app_path = os.path.join(app_path, 'config') if basedir: app_path = os.path.join(app_path, basedir) app_path = os.path.join(app_path, '{fn}{ext}'.format(fn=default or name, ext='.cfg' if default else ext)) # Make sure the configuration file actually exists if not os.path.isfile(app_path): raise ValueError('Default configuration file %s does not exist', app_path) paths.append(app_path) ################################ # User Configuration # ################################ user_path = os.path.join(FireflyIRC.CONFIG_DIR, 'config') if plugin: user_path = os.path.join(user_path, 'plugins', plugin.name) if basedir: user_path = os.path.join(user_path, basedir) # Make sure the directory exists if not os.path.isdir(user_path): log.info('Creating user configuration directory: %s', user_path) os.makedirs(user_path, 0o755) user_path = os.path.join(user_path, '{fn}{ext}'.format(fn=name, ext=ext)) log.debug('Attempting to load user configuration file: %s', user_path) if not os.path.isfile(user_path): if default: raise ValueError('User configuration file %s does not exist', app_path) # TODO: Ambiguous exceptions log.debug('User configuration file does not exist, attempting to create it') # Load the base system path shutil.copyfile(app_path, user_path) paths.append(user_path) # Load the configuration files config = ConfigParser() log.debug('Attempting to load configuration files: %s', str(paths)) result = config.read(paths) log.debug('Configuration files loaded: %s', str(result)) return config def _load_language_interface(self, language): """ Load and instantiate the specific language engine. @type language: C{str} """ self._log.info('Loading language interface: {lang}'.format(lang=language)) try: module = importlib.import_module('firefly.languages.{module}'.format(module=language)) self.language = module.__LANGUAGE_CLASS__() except ImportError as e: self._log.error('ImportError raised when loading language') raise LanguageImportError('Unable to import language engine "{lang}": {err}' .format(lang=language, err=e.message)) except AttributeError: self._log.error('Language module does not contain a specified language class, load failed') raise LanguageImportError('Language "{lang}" does not have a defined __LANGUAGE_CLASS__' .format(lang=language)) def load_language_files(self, plugin=None, basedir=None, container=None): """ Load language files. @type plugin: PluginAbstract or None @param plugin: Plugin to load language files from, or None to load user language files. @type basedir: str or None @param basedir: Language basedir. Defaults to lang @type container: str or None @param container: Name of the identity container. Defaults to the system configuration. """ basedir = basedir or 'lang' container = container or self.server.identity.container lang_path = plugin.plugin_path if plugin else FireflyIRC.CONFIG_DIR lang_path = os.path.join(lang_path, basedir, container) # Make sure the configuration file actually exists if not os.path.exists(lang_path): self._log.warn('Language directory %s does not exist', lang_path) return self.language.load_directory(lang_path) def _setup(self): """ Run generic setup tasks. """ # Load language files lang_dir = os.path.join(self.CONFIG_DIR, 'language') if os.path.isdir(lang_dir): self.language.load_directory(lang_dir) # Make sure our configuration and data directories exist if not os.path.isdir(self.CONFIG_DIR): os.makedirs(self.CONFIG_DIR, 0o755) if not os.path.isdir(self.DATA_DIR): os.makedirs(self.DATA_DIR, 0o755) def _fire_event(self, event_name, has_reply=False, is_command=False, **kwargs): """ Fire an IRC event. @type event_name: C{str} @param event_name: Name of the event to fire, see firefly.irc for a list of event constants @type has_reply: bool @param has_reply: Indicates that this event triggered a language response before firing @type is_command: bool @param is_command: Indicates that this event triggered a command before firing @param kwargs: Arbitrary event arguments """ self._log.info('Firing event: %s', event_name) events = self.registry.get_events(event_name) for cls, func, params in events: # Commands ok? if is_command and not params['command_ok']: self._log.info('Event is not responding to command triggers, skipping') continue # Replies ok? if has_reply and not params['reply_ok']: self._log.info('Event is not responding to language triggers, skipping') continue self._log.info('Firing event: %s (%s); Params: %s', str(cls), str(func), str(params)) # response = func(cls, **kwargs) message = kwargs.get('message') response = Response( self, message, message.source if message else None, message.destination if message and message.destination.is_channel else None ) func(cls, response, **kwargs) response.send() def _fire_command(self, plugin, name, cmd_args, message): """ Fire an IRC command. @type plugin: str @param plugin: Name of the command plugin @type name: str @param name: Name of the command @type cmd_args: list of str @param cmd_args: List of command arguments @type message: Message @param message: Command message container """ self._log.info('Firing command: %s %s (%s)', plugin, name, str(cmd_args)) cls, func, argparse, params = self.registry.get_command(plugin, name) # Make sure we have permission perm = params['permission'] user = self.auth.check(message.source) if (perm != 'guest') and not user: error = 'You must be registered and authenticated in order to use this command.' if self.server.public_errors: self.msg(message.destination, error) else: self.notice(message.source, error) return if (perm == 'admin') and not user.is_admin: error = 'You do not have permission to use this command.' if self.server.public_errors: self.msg(message.destination, error) else: self.notice(message.source, error) return # Execute the command try: response = Response( self, message, message.source, message.destination if message.destination.is_channel else None ) func(argparse.parse_args(cmd_args), response) response.send() except ArgumentParserError as e: self._log.info('Argument parser error: %s', e.message) usage = style(argparse.format_usage().strip(), bold=True) desc = ' -- {desc}'.format(desc=argparse.description.strip()) if argparse.description else '' help_msg = '({usage}){desc}'.format(usage=usage, desc=desc) # If this command was sent in a query, return the error now if message.destination.is_user: self.msg(message.source, e.message) self.msg(message.source, help_msg) return # Otherwise, check if we should send the messages as a notice or channel message if self.server.public_errors: self.msg(message.destination, e.message) self.msg(message.destination, help_msg) else: self.notice(message.source, e.message) self.notice(message.source, help_msg) def msg(self, user, message, length=None): """ Send a message to a user or channel. The message will be split into multiple commands to the server if: - The message contains any newline characters - Any span between newline characters is longer than the given line-length. @type user: Destination, Hostmask or str @param user: The user or channel to send a notice to. @type message: str @param message: The contents of the notice to send. @type length: int @param length: Maximum number of octets to send in a single command, including the IRC protocol framing. If None is given then IRCClient._safeMaximumLineLength is used to determine a value. """ if isinstance(user, Destination): self._log.debug('Implicitly converting Destination to raw format for message delivery: %s --> %s', repr(user), user.raw) user = user.raw if isinstance(user, Hostmask): self._log.debug('Implicitly converting Hostmask to nick format for message delivery: %s --> %s', repr(user), user.nick) user = user.nick self._log.debug('Delivering message to %s : %s', user, (message[:35] + '..') if len(message) > 35 else message) IRCClient.msg(self, user, message.encode('utf-8'), length) def notice(self, user, message): """ Send a notice to a user. Notices are like normal message, but should never get automated replies. @type user: Destination, Hostmask or str @param user: The user or channel to send a notice to. @type message: str @param message: The contents of the notice to send. """ if isinstance(user, Destination): self._log.debug('Implicitly converting Destination to raw format for notice delivery: %s --> %s', repr(user), user.raw) user = user.raw if isinstance(user, Hostmask): self._log.debug('Implicitly converting Hostmask to nick format for notice delivery: %s --> %s', repr(user), user.nick) user = user.nick IRCClient.notice(self, user, message) def describe(self, channel, action): """ Strike a pose. @type channel: Destination, Hostmask or str @param channel: The user or channel to perform an action in. @type action: str @param action: The action to preform. """ if isinstance(channel, Destination): self._log.debug('Implicitly converting Destination to raw format for action performance: %s --> %s', repr(channel), channel.raw) channel = channel.raw if isinstance(channel, Hostmask): self._log.debug('Implicitly converting Hostmask to nick format for action performance: %s --> %s', repr(channel), channel.nick) channel = channel.nick IRCClient.describe(self, channel, action) ################################ # High-level IRC Events # ################################ def created(self, when): """ Called with creation date information about the server, usually at logon. @type when: C{str} @param when: A string describing when the server was created, probably. """ self._fire_event(irc.on_created, when=when) def yourHost(self, info): """ Called with daemon information about the server, usually at logon. # TODO: Consider integrating into ServerInfo container @param info: A string describing what software the server is running, probably. @type info: C{str} """ self._fire_event(irc.on_server_host, info=info) def myInfo(self, server_name, version, umodes, cmodes): """ Called with information about the server, usually at logon. @type server_name: C{str} @param server_name: The hostname of this server. @type version: C{str} @param version: A description of what software this server runs. @type umodes: C{str} @param umodes: All the available user modes. @type cmodes: C{str} @param cmodes: All the available channel modes. """ self._fire_event(irc.on_client_info, server_name=server_name, version=version, umodes=umodes, cmodes=cmodes) def luserClient(self, info): """ Called with information about the number of connections, usually at logon. @type info: C{str} @param info: A description of the number of clients and servers connected to the network, probably. """ self._fire_event(irc.on_luser_client, info=info) def bounce(self, info): """ Called with information about where the client should reconnect. @type info: C{str} @param info: A plaintext description of the address that should be connected to. """ self._fire_event(irc.on_bounce, info=info) def isupport(self, options): """ Called with various information about what the server supports. @type options: C{list} of C{str} @param options: Descriptions of features or limits of the server, possibly in the form "NAME=VALUE". """ self.server_info.parse_supports(options) self._fire_event(irc.on_server_supports, options=options) def luserChannels(self, channels): """ Called with the number of channels existent on the server. @type channels: C{int} """ self._fire_event(irc.on_luser_channels, channels=channels) def luserOp(self, ops): """ Called with the number of ops logged on to the server. @type ops: C{int} """ self._fire_event(irc.on_luser_ops, ops=ops) def luserMe(self, info): """ Called with information about the server connected to. @type info: C{str} @param info: A plaintext string describing the number of users and servers connected to this server. """ self._fire_event(irc.on_luser_connection, info=info) def privmsg(self, user, channel, message): """ Called when I have a message from a user to me or a channel. @type user: C{str} @param user: Hostmask of the sender. @type channel: C{str} @param channel: Name of the source channel or user nick. @type message: C{str} """ hostmask = Hostmask(user) destination = Destination(self, channel) message = Message(message, destination, hostmask) is_command = False has_reply = False reply_dest = destination groups = set() # Log the message self.server.get_or_create_channel(channel).message_log.add_message(message) # Is the message a command? if message.is_command: self._log.debug('Message registered as a command: %s', repr(message)) is_command = True groups.add('command') command_plugin, command_name, command_args = message.command_parts self._fire_command(command_plugin, command_name, command_args, message) # Is this a private message? if message.destination.is_user: groups.add(None) groups.add('private') reply_dest = hostmask # Have we been mentioned in this message? nicks = self.server.identity.nicks try: nick, raw_message, match = message.get_mentions(nicks) groups.add(None) except TypeError: self._log.debug('Message has no mentions at the beginning') try: nick, raw_message, match = message.get_mentions(nicks, message.MENTION_END) groups.add(None) except TypeError: self._log.debug('Message has no mentions at the end') nick = self.nickname raw_message = message.stripped match = False if message.destination.is_channel: groups.add('public') # Do we have a language response? reply = self.language.get_reply(raw_message, groups=groups) if reply: self._log.debug('Reply matched: %s', reply) has_reply = True self.msg(reply_dest, reply) # Fire global event self._fire_event(irc.on_message, has_reply, is_command, message=message) # Fire custom events if message.destination.is_channel: self.channelMessage(message, has_reply, is_command) elif message.destination.is_user: self.privateMessage(message, has_reply, is_command) def joined(self, channel): """ Called when I finish joining a channel. @type channel: C{str} @param channel: Name of the channel. """ self.server.add_channel(channel) self._fire_event(irc.on_client_join, channel=Destination(self, channel)) def left(self, channel): """ Called when I have left a channel. @type channel: C{str} @param channel: Name of the channel. """ self.server.remove_channel(channel) self._fire_event(irc.on_client_part, channel=Destination(self, channel)) def noticed(self, user, channel, message): """ Called when I have a notice from a user to me or a channel. If the client makes any automated replies, it must not do so in response to a NOTICE message, per the RFC:: The difference between NOTICE and PRIVMSG is that automatic replies MUST NEVER be sent in response to a NOTICE message. [...] The object of this rule is to avoid loops between clients automatically sending something in response to something it received. @type user: C{str} @param user: Hostmask of the sender. @type channel: C{str} @param channel: Name of the source channel or user nick. @type message: C{str} """ notice = Message(message, Destination(self, channel), Hostmask(user), Message.NOTICE) self._fire_event(irc.on_notice, notice=notice) # Fire custom events if notice.destination.is_channel: self.channelNotice(notice) elif notice.destination.is_user: self.privateNotice(notice) def modeChanged(self, user, channel, set, modes, args): """ Called when users or channel's modes are changed. TODO @type user: C{str} @param user: The user and hostmask which instigated this change. @type channel: C{str} @param channel: The channel where the modes are changed. If args is empty the channel for which the modes are changing. If the changes are at server level it could be equal to C{user}. @type set: C{bool} or C{int} @param set: True if the mode(s) is being added, False if it is being removed. If some modes are added and others removed at the same time this function will be called twice, the first time with all the added modes, the second with the removed ones. (To change this behaviour override the irc_MODE method) @type modes: C{str} @param modes: The mode or modes which are being changed. @type args: C{tuple} @param args: Any additional information required for the mode change. """ self._fire_event(irc.on_mode_changed, user=Hostmask(user), source=Destination(self, channel), set=set, modes=modes, args=args) def pong(self, user, secs): """ Called with the results of a CTCP PING query. @type user: C{str} @param user: The user and hostmask. @type secs: C{float} @param secs: Ping latency """ self._fire_event(irc.on_pong, user=Hostmask(user), secs=secs) def signedOn(self): """ Called after successfully signing on to the server. """ # Connect to our autojoin channels channels = self.server.autojoin_channels for name, channel in channels.iteritems(): if channel.autojoin: self.join(channel.name) self._fire_event(irc.on_client_signed_on) def kickedFrom(self, channel, kicker, message): """ Called when I am kicked from a channel. @type channel: C{str} @param channel: The channel we were kicked from. @type kicker: C{str} @param kicker: The user that kicked us. @type message: C{str} @param message: The kick message. """ self._fire_event(irc.on_client_kicked, channel=channel, kicker=kicker, message=message) def nickChanged(self, nick): """ Called when my nick has been changed. @type nick: C{str} @param nick: Our new nick """ self.nickname = nick self._fire_event(irc.on_client_nick, nick=nick) def userJoined(self, user, channel): """ Called when I see another user joining a channel. @type user: C{str} @param user: The user joining the channel. @type channel: C{str} @param channel: The channel being joined. """ self._fire_event(irc.on_channel_join, user=Hostmask(user), channel=channel) def userLeft(self, user, channel): """ Called when I see another user leaving a channel. @type user: C{str} @param user: The user parting the channel. @type channel: C{str} @param channel: The channel being parted. """ self._fire_event(irc.on_channel_part, user=Hostmask(user), channel=channel) def userQuit(self, user, quitMessage): """ Called when I see another user disconnect from the network. @type user: C{str} @param user: The user quitting the network. @type quitMessage: C{str} @param quitMessage: The quit message. """ message = Message(quitMessage, None, Hostmask(user)) self._fire_event(irc.on_user_quit, user=Hostmask(user), quit_message=quitMessage, message=message) def userKicked(self, kickee, channel, kicker, message): """ Called when I observe someone else being kicked from a channel. @type kickee: C{str} @param kickee: The user being kicked. @type channel: C{str} @param channel: The channel the user is being kicked from. @type kicker: C{str} @param kicker: The user that is kicking. @type message: C{str} @param message: The kick message. """ message = Message(message, Destination(self, channel), Hostmask(kicker)) self._fire_event(irc.on_channel_kick, kickee=kickee, channel=channel, kicker=kicker, message=message) def action(self, user, channel, data): """ Called when I see a user perform an ACTION on a channel. @type user: C{str} @param user: The user performing the action. @type channel: C{str} @param channel: The user or channel. @type data: C{str} @param data: The action being performed. """ action = Message(data, Destination(self, channel), Hostmask(user), Message.ACTION) self._fire_event(irc.on_action, action=action) if action.destination.is_channel: self.channelAction(action, False) elif action.destination.is_user: self.privateAction(action, False) def topicUpdated(self, user, channel, newTopic): """ In channel, user changed the topic to newTopic. Also called when first joining a channel. @type user: C{str} or C{None} @param user: The user updating the topic, if relevant. @type channel: C{str} @param channel: The channel the topic is being changed on. @type newTopic: C{str} @param newTopic: The updated topic. """ self._fire_event(irc.on_channel_topic_updated, user=Hostmask(user), channel=channel, new_topic=newTopic) def userRenamed(self, oldname, newname): """ A user changed their name from oldname to newname. @type oldname: C{str} @param oldname: The users old nick. @type newname: C{str} @param newname: The users new nick. """ self._fire_event(irc.on_user_nick_changed, old_nick=oldname, new_nick=newname) def receivedMOTD(self, motd): """ I received a message-of-the-day banner from the server. motd is a list of strings, where each string was sent as a separate message from the server. To display, you might want to use:: '\\n'.join(motd) to get a nicely formatted string. @type motd: C{list} """ self._fire_event(irc.on_server_motd, motd=motd) ################################ # Custom IRC Events # ################################ def channelMessage(self, message, has_reply, is_command): """ Called when I have a message from a user to a channel. @type message: Message @param message: The message container object. @type has_reply: bool @param has_reply: Indicates that this event triggered a language response before firing @type is_command: bool @param is_command: Indicates that this event triggered a command before firing """ self._fire_event(irc.on_channel_message, has_reply, is_command, message=message) def privateMessage(self, message, has_reply, is_command): """ Called when I have a message from a user to me. @type message: Message @param message: The message container object. @type has_reply: bool @param has_reply: Indicates that this event triggered a language response before firing @type is_command: bool @param is_command: Indicates that this event triggered a command before firing """ self._fire_event(irc.on_private_message, has_reply, is_command, message=message) def channelNotice(self, notice): """ Called when I have a notice from a user to a channel. @type notice: Message @param notice: The notice (message) container object. """ self._fire_event(irc.on_channel_notice, notice=notice) def privateNotice(self, notice): """ Called when I have a notice from a user to me. @type notice: Message @param notice: The notice (message) container object. """ self._fire_event(irc.on_private_notice, notice=notice) def channelAction(self, action, has_reply): """ Called when I see a user perform an ACTION on a channel. @type action: Message @param action: The action (message) container object. @type has_reply: bool @param has_reply: Indicates that this event triggered a language response before firing """ self._fire_event(irc.on_channel_action, has_reply, action=action) def privateAction(self, action, has_reply): """ Called when I see a user perform an ACTION to me. @type action: Message @param action: The action (message) container object. @type has_reply: bool @param has_reply: Indicates that this event triggered a language response before firing """ self._fire_event(irc.on_private_action, has_reply, action=action) ################################ # Low-level IRC Events # ################################ def irc_ERR_NICKNAMEINUSE(self, prefix, params): """ Called when we try to register or change to a nickname that is already taken. """ IRCClient.irc_ERR_NICKNAMEINUSE(self, prefix, params) self._fire_event(irc.on_err_nick_in_use, prefix=prefix, params=params) def irc_ERR_ERRONEUSNICKNAME(self, prefix, params): """ Called when we try to register or change to an illegal nickname. The server should send this reply when the nickname contains any disallowed characters. The bot will stall, waiting for RPL_WELCOME, if we don't handle this during sign-on. @note: The method uses the spelling I{erroneus}, as it appears in the RFC, section 6.1. """ IRCClient.irc_ERR_ERRONEUSNICKNAME(self, prefix, params) self._fire_event(irc.on_err_nick_in_use, prefix=prefix, params=params) def irc_ERR_PASSWDMISMATCH(self, prefix, params): """ Called when the login was incorrect. """ IRCClient.irc_ERR_PASSWDMISMATCH(self, prefix, params) self._fire_event(irc.on_err_bad_password, prefix=prefix, params=params) def irc_RPL_WELCOME(self, prefix, params): """ Called when we have received the welcome from the server. """ IRCClient.irc_RPL_WELCOME(self, prefix, params) self._fire_event(irc.on_server_welcome, prefix=prefix, params=params) def irc_unknown(self, prefix, command, params): self._log.debug('Unknown IRC event: ({pr} {c} {pa})'.format(pr=str(prefix), c=str(command), pa=str(params))) self._fire_event(irc.on_unknown, prefix=prefix, command=command, params=params) def ctcpQuery(self, user, channel, messages): """ Dispatch method for any CTCP queries received. Duplicated CTCP queries are ignored and no dispatch is made. Unrecognized CTCP queries invoke L{IRCClient.ctcpUnknownQuery}. """ self._fire_event(irc.on_ctcp, user=Hostmask(user), channel=channel, messages=messages) IRCClient.ctcpQuery(self, user, channel, messages) def ctcpQuery_PING(self, user, channel, data): self._fire_event(irc.on_ctcp_ping, user=Hostmask(user), channel=channel, data=data) IRCClient.ctcpQuery_PING(self, user, channel, data) def ctcpQuery_FINGER(self, user, channel, data): self._fire_event(irc.on_ctcp_finger, user=Hostmask(user), channel=channel, data=data) IRCClient.ctcpQuery_FINGER(self, user, channel, data) def ctcpQuery_VERSION(self, user, channel, data): self._fire_event(irc.on_ctcp_version, user=Hostmask(user), channel=channel, data=data) def ctcpQuery_SOURCE(self, user, channel, data): self._fire_event(irc.on_ctcp_source, user=Hostmask(user), channel=channel, data=data) def ctcpQuery_USERINFO(self, user, channel, data): self._fire_event(irc.on_ctcp_userinfo, user=Hostmask(user), channel=channel, data=data) IRCClient.ctcpQuery_USERINFO(self, user, channel, data) def ctcpQuery_TIME(self, user, channel, data): self._fire_event(irc.on_ctcp_time, user=Hostmask(user), channel=channel, data=data) IRCClient.ctcpQuery_TIME(self, user, channel, data)