Example #1
0
    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot

        if BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = BOT_ALT_PREFIXES
Example #2
0
    def __init__(self, config):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """

        if config.BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            log.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot
        self.re_commands = {}  # the dynamically populated list of regex-based commands available on the bot
        self.MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' \
                                   'Type "' + config.BOT_PREFIX + 'help" for available commands.'
        if config.BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in config.BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = config.BOT_ALT_PREFIXES
Example #3
0
File: base.py Project: poirier/err
 def __init__(self, *args, **kwargs):
     """ Those arguments will be directly those put in BOT_IDENTITY
     """
     if BOT_ASYNC:
         self.thread_pool = ThreadPool(3)
         logging.debug('created the thread pool' + str(self.thread_pool))
     self.commands = {} # the dynamically populated list of commands available on the bot
Example #4
0
File: base.py Project: kunaldeo/err
    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot

        if BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = BOT_ALT_PREFIXES
Example #5
0
File: base.py Project: revcozmo/err
    def __init__(self, config):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        self._reconnection_count = 0          # Increments with each failed (re)connection
        self._reconnection_delay = 1          # Amount of seconds the bot will sleep on the
        #                                     # next reconnection attempt
        self._reconnection_max_delay = 600    # Maximum delay between reconnection attempts
        self._reconnection_multiplier = 1.75  # Delay multiplier
        self._reconnection_jitter = (0, 3)    # Random jitter added to delay (min, max)

        if config.BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            log.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot
        self.re_commands = {}  # the dynamically populated list of regex-based commands available on the bot
        self.MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' \
                                   'Type "' + config.BOT_PREFIX + 'help" for available commands.'
        if config.BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in config.BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = config.BOT_ALT_PREFIXES
Example #6
0
class Backend(object):
    # Implements the basic Bot logic (logic independent from the backend) and leave to you to implement the missing parts

    cmd_history = defaultdict(
        lambda: deque(maxlen=10))  #this will be a per user history

    MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
                         'An unexpected error occurred.'
    MESSAGE_SIZE_LIMIT = MESSAGE_SIZE_LIMIT
    MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
                          'Type "' + BOT_PREFIX + 'help" for available commands.'
    MSG_HELP_TAIL = 'Type help <command name> to get more info '\
                    'about that specific command.'
    MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'

    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {
        }  # the dynamically populated list of commands available on the bot
        self.re_commands = {
        }  # the dynamically populated list of regex-based commands available on the bot

        if BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower()
                                          for prefix in BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = BOT_ALT_PREFIXES

    def send_message(self, mess):
        """Send a message"""
        self.connect().send_message(mess)

    def send_simple_reply(self, mess, text, private=False):
        """Send a simple response to a message"""
        self.send_message(self.build_reply(mess, text, private))

    def build_reply(self, mess, text=None, private=False):
        """Build a message for responding to another message.
        Message is NOT sent"""
        response = self.build_message(text)
        msg_type = mess.getType()

        response.setFrom(self.jid)
        if msg_type == 'groupchat' and not private:
            # getStripped() returns the full [email protected]/chat_username
            # but in case of a groupchat, we should only try to send to the MUC address
            # itself ([email protected])
            response.setTo(mess.getFrom().getStripped().split('/')[0])
        elif str(mess.getTo()) == BOT_IDENTITY['username']:
            # This is a direct private message, not initiated through a MUC. Use
            # getStripped() to remove the resource so that the response goes to the
            # client with the highest priority
            response.setTo(mess.getFrom().getStripped())
        else:
            # This is a private message that was initiated through a MUC. Don't use
            # getStripped() here to retain the resource, else the XMPP server doesn't
            # know which user we're actually responding to.
            response.setTo(mess.getFrom())
        response.setType('chat' if private else msg_type)
        return response

    def callback_message(self, conn, mess):
        """
        Needs to return False if we want to stop further treatment
        """
        # Prepare to handle either private chats or group chats
        type = mess.getType()
        jid = mess.getFrom()
        text = mess.getBody()
        username = get_sender_username(mess)
        user_cmd_history = self.cmd_history[username]

        if mess.isDelayed():
            logging.debug("Message from history, ignore it")
            return False

        if type not in ("groupchat", "chat"):
            logging.debug("unhandled message type %s" % mess)
            return False

        # Ignore messages from ourselves. Because it isn't always possible to get the
        # real JID from a MUC participant (including ourself), matching the JID against
        # ourselves isn't enough (see https://github.com/gbin/err/issues/90 for
        # background discussion on this). Matching against CHATROOM_FN isn't technically
        # correct in all cases because a MUC could give us another nickname, but it
        # covers 99% of the MUC cases, so it should suffice for the time being.
        if (jid.bareMatch(Identifier(self.jid))
                or type == "groupchat" and mess.getMuckNick() == CHATROOM_FN):
            logging.debug("Ignoring message from self")
            return False

        logging.debug("*** jid = %s" % jid)
        logging.debug("*** username = %s" % username)
        logging.debug("*** type = %s" % type)
        logging.debug("*** text = %s" % text)

        # If a message format is not supported (eg. encrypted),
        # txt will be None
        if not text:
            return False

        surpress_cmd_not_found = False

        prefixed = False  # Keeps track whether text was prefixed with a bot prefix
        only_check_re_command = False  # Becomes true if text is determed to not be a regular command
        tomatch = text.lower() if BOT_ALT_PREFIX_CASEINSENSITIVE else text
        if len(BOT_ALT_PREFIXES) > 0 and tomatch.startswith(
                self.bot_alt_prefixes):
            # Yay! We were called by one of our alternate prefixes. Now we just have to find out
            # which one... (And find the longest matching, in case you have 'err' and 'errbot' and
            # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as
            # part of the called command in that case)
            prefixed = True
            longest = 0
            for prefix in self.bot_alt_prefixes:
                l = len(prefix)
                if tomatch.startswith(prefix) and l > longest:
                    longest = l
            logging.debug("Called with alternate prefix '{}'".format(
                text[:longest]))
            text = text[longest:]

            # Now also remove the separator from the text
            for sep in BOT_ALT_PREFIX_SEPARATORS:
                # While unlikely, one may have separators consisting of
                # more than one character
                l = len(sep)
                if text[:l] == sep:
                    text = text[l:]
        elif type == "chat" and BOT_PREFIX_OPTIONAL_ON_CHAT:
            logging.debug(
                "Assuming '%s' to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True"
                % text)
            # In order to keep noise down we surpress messages about the command
            # not being found, because it's possible a plugin will trigger on what
            # was said with trigger_message.
            surpress_cmd_not_found = True
        elif not text.startswith(BOT_PREFIX):
            only_check_re_command = True
        if text.startswith(BOT_PREFIX):
            text = text[len(BOT_PREFIX):]
            prefixed = True

        text = text.strip()
        text_split = text.split(' ')
        cmd = None
        command = None
        args = ''
        if not only_check_re_command:
            if len(text_split) > 1:
                command = (text_split[0] + '_' + text_split[1]).lower()
                if command in self.commands:
                    cmd = command
                    args = ' '.join(text_split[2:])

            if not cmd:
                command = text_split[0].lower()
                args = ' '.join(text_split[1:])
                if command in self.commands:
                    cmd = command
                    if len(text_split) > 1:
                        args = ' '.join(text_split[1:])

            if command == BOT_PREFIX:  # we did "!!" so recall the last command
                if len(user_cmd_history):
                    cmd, args = user_cmd_history[-1]
                else:
                    return False  # no command in history
            elif command.isdigit(
            ):  # we did "!#" so we recall the specified command
                index = int(command)
                if len(user_cmd_history) >= index:
                    cmd, args = user_cmd_history[-index]
                else:
                    return False  # no command in history

        # Try to match one of the regex commands if the regular commands produced no match
        matched_on_re_command = False
        if not cmd:
            if prefixed:
                commands = self.re_commands
            else:
                commands = {
                    k: self.re_commands[k]
                    for k in self.re_commands
                    if not self.re_commands[k]._err_command_prefix_required
                }

            for name, func in commands.items():
                match = func._err_command_re_pattern.search(text)
                if match:
                    logging.debug(
                        "Matching '{}' against '{}' produced a match".format(
                            text, func._err_command_re_pattern.pattern))
                    matched_on_re_command = True
                    self._process_command(mess, name, text, match)
                else:
                    logging.debug(
                        "Matching '{}' against '{}' produced no match".format(
                            text, func._err_command_re_pattern.pattern))
        if matched_on_re_command:
            return True

        if cmd:
            self._process_command(mess, cmd, args, match=None)
        elif not only_check_re_command:
            logging.debug("Command not found")
            if surpress_cmd_not_found:
                logging.debug("Surpressing command not found feedback")
            else:
                reply = self.unknown_command(mess, command, args)
                if reply is None:
                    reply = self.MSG_UNKNOWN_COMMAND % {'command': command}
                if reply:
                    self.send_simple_reply(mess, reply)
        return True

    def _process_command(self, mess, cmd, args, match):
        """Process and execute a bot command"""
        logging.info("Processing command {} with parameters '{}'".format(
            cmd, args))

        jid = mess.getFrom()
        username = get_sender_username(mess)
        user_cmd_history = self.cmd_history[username]

        if (cmd, args) in user_cmd_history:
            user_cmd_history.remove(
                (cmd, args))  # Avoids duplicate history items

        try:
            self.check_command_access(mess, cmd)
        except ACLViolation as e:
            if not HIDE_RESTRICTED_ACCESS:
                self.send_simple_reply(mess, str(e))
            return

        f = self.re_commands[cmd] if match else self.commands[cmd]

        if f._err_command_admin_only and BOT_ASYNC:
            self.thread_pool.wait(
            )  # If it is an admin command, wait that the queue is completely depleted so we don't have strange concurrency issues on load/unload/updates etc ...

        if f._err_command_historize:
            user_cmd_history.append(
                (cmd, args
                 ))  # add it to the history only if it is authorized to be so

        # Don't check for None here as None can be a valid argument to split.
        # '' was chosen as default argument because this isn't a valid argument to split()
        if not match and f._err_command_split_args_with != '':
            args = args.split(f._err_command_split_args_with)
        if BOT_ASYNC:
            wr = WorkRequest(
                self._execute_and_send, [], {
                    'cmd': cmd,
                    'args': args,
                    'match': match,
                    'mess': mess,
                    'jid': jid,
                    'template_name': f._err_command_template
                })
            self.thread_pool.putRequest(wr)
            if f._err_command_admin_only:
                self.thread_pool.wait(
                )  # Again wait for the completion before accepting a new command that could generate weird concurrency issues
        else:
            self._execute_and_send(cmd=cmd,
                                   args=args,
                                   match=match,
                                   mess=mess,
                                   jid=jid,
                                   template_name=f._err_command_template)

    def _execute_and_send(self,
                          cmd,
                          args,
                          match,
                          mess,
                          jid,
                          template_name=None):
        """Execute a bot command and send output back to the caller

        cmd: The command that was given to the bot (after being expanded)
        args: Arguments given along with cmd
        match: A re.MatchObject if command is coming from a regex-based command, else None
        mess: The message object
        jid: The jid of the person executing the command
        template_name: The name of the template which should be used to render
            html-im output, if any

        """
        def process_reply(reply):
            # integrated templating
            if template_name:
                reply = tenv().get_template(template_name +
                                            '.html').render(**reply)

            # Reply should be all text at this point (See https://github.com/gbin/err/issues/96)
            return str(reply)

        def send_reply(reply):
            for part in split_string_after(reply, self.MESSAGE_SIZE_LIMIT):
                self.send_simple_reply(mess, part, cmd in DIVERT_TO_PRIVATE)

        commands = self.re_commands if match else self.commands
        try:
            if inspect.isgeneratorfunction(commands[cmd]):
                replies = commands[cmd](
                    mess, match) if match else commands[cmd](mess, args)
                for reply in replies:
                    if reply: send_reply(process_reply(reply))
            else:
                reply = commands[cmd](mess, match) if match else commands[cmd](
                    mess, args)
                if reply: send_reply(process_reply(reply))
        except Exception as e:
            tb = traceback.format_exc()
            logging.exception('An error happened while processing '
                              'a message ("%s") from %s: %s"' %
                              (mess.getBody(), jid, tb))
            send_reply(self.MSG_ERROR_OCCURRED + ':\n %s' % e)

    def check_command_access(self, mess, cmd):
        """
        Check command against ACL rules

        Raises ACLViolation() if the command may not be executed in the given context
        """
        usr = str(get_jid_from_message(mess))
        typ = mess.getType()

        if cmd not in ACCESS_CONTROLS:
            ACCESS_CONTROLS[cmd] = ACCESS_CONTROLS_DEFAULT

        if 'allowusers' in ACCESS_CONTROLS[cmd] and usr not in ACCESS_CONTROLS[
                cmd]['allowusers']:
            raise ACLViolation(
                "You're not allowed to access this command from this user")
        if 'denyusers' in ACCESS_CONTROLS[cmd] and usr in ACCESS_CONTROLS[cmd][
                'denyusers']:
            raise ACLViolation(
                "You're not allowed to access this command from this user")
        if typ == 'groupchat':
            stripped = mess.getFrom().getStripped()
            if 'allowmuc' in ACCESS_CONTROLS[
                    cmd] and ACCESS_CONTROLS[cmd]['allowmuc'] is False:
                raise ACLViolation(
                    "You're not allowed to access this command from a chatroom"
                )
            if 'allowrooms' in ACCESS_CONTROLS[
                    cmd] and stripped not in ACCESS_CONTROLS[cmd]['allowrooms']:
                raise ACLViolation(
                    "You're not allowed to access this command from this room")
            if 'denyrooms' in ACCESS_CONTROLS[
                    cmd] and stripped in ACCESS_CONTROLS[cmd]['denyrooms']:
                raise ACLViolation(
                    "You're not allowed to access this command from this room")
        else:
            if 'allowprivate' in ACCESS_CONTROLS[
                    cmd] and ACCESS_CONTROLS[cmd]['allowprivate'] is False:
                raise ACLViolation(
                    "You're not allowed to access this command via private message to me"
                )

        f = self.commands[cmd] if cmd in self.commands else self.re_commands[
            cmd]

        if f._err_command_admin_only:
            if typ == 'groupchat':
                raise ACLViolation(
                    "You cannot administer the bot from a chatroom, message the bot directly"
                )
            if usr not in BOT_ADMINS:
                raise ACLViolation(
                    "This command requires bot-admin privileges")

    def unknown_command(self, mess, cmd, args):
        """ Override the default unknown command behavior
        """
        full_cmd = cmd + ' ' + args.split(' ')[0] if args else None
        if full_cmd:
            part1 = 'Command "%s" / "%s" not found.' % (cmd, full_cmd)
        else:
            part1 = 'Command "%s" not found.' % cmd
        ununderscore_keys = [m.replace('_', ' ') for m in self.commands.keys()]
        matches = difflib.get_close_matches(cmd, ununderscore_keys)
        if full_cmd:
            matches.extend(
                difflib.get_close_matches(full_cmd, ununderscore_keys))
        matches = set(matches)
        if matches:
            return part1 + '\n\nDid you mean "' + BOT_PREFIX + (
                '" or "' + BOT_PREFIX).join(matches) + '" ?'
        else:
            return part1

    def inject_commands_from(self, instance_to_inject):
        classname = instance_to_inject.__class__.__name__
        for name, value in inspect.getmembers(instance_to_inject,
                                              inspect.ismethod):
            if getattr(value, '_err_command', False):
                commands = self.re_commands if getattr(
                    value, '_err_re_command') else self.commands
                name = getattr(value, '_err_command_name')

                if name in commands:
                    f = commands[name]
                    new_name = (classname + '-' + name).lower()
                    self.warn_admins(
                        '%s.%s clashes with %s.%s so it has been renamed %s' %
                        (classname, name, type(
                            f.__self__).__name__, f.__name__, new_name))
                    name = new_name
                commands[name] = value

                if getattr(value, '_err_re_command'):
                    logging.debug('Adding regex command : %s -> %s' %
                                  (name, value.__name__))
                    self.re_commands = commands
                else:
                    logging.debug('Adding command : %s -> %s' %
                                  (name, value.__name__))
                    self.commands = commands

    def remove_commands_from(self, instance_to_inject):
        for name, value in inspect.getmembers(instance_to_inject,
                                              inspect.ismethod):
            if getattr(value, '_err_command', False):
                name = getattr(value, '_err_command_name')
                if getattr(value,
                           '_err_re_command') and name in self.re_commands:
                    del (self.re_commands[name])
                elif not getattr(value,
                                 '_err_re_command') and name in self.commands:
                    del (self.commands[name])

    def warn_admins(self, warning):
        for admin in BOT_ADMINS:
            self.send(admin, warning)

    def top_of_help_message(self):
        """Returns a string that forms the top of the help message

        Override this method in derived class if you
        want to add additional help text at the
        beginning of the help message.
        """
        return ""

    def bottom_of_help_message(self):
        """Returns a string that forms the bottom of the help message

        Override this method in derived class if you
        want to add additional help text at the end
        of the help message.
        """
        return ""

    @botcmd
    def help(self, mess, args):
        """   Returns a help string listing available options.

        Automatically assigned to the "help" command."""
        if not args:
            if self.__doc__:
                description = self.__doc__.strip()
            else:
                description = 'Available commands:'

            usage = '\n'.join(sorted([
            BOT_PREFIX + '%s: %s' % (name, (command.__doc__ or
                                            '(undocumented)').strip().split('\n', 1)[0])
            for (name, command) in self.commands.iteritems()\
            if name != 'help'\
            and not command._err_command_hidden
            ]))
            usage = '\n\n' + '\n\n'.join(
                filter(None, [usage, self.MSG_HELP_TAIL]))
        else:
            description = ''
            if args in self.commands:
                usage = (self.commands[args].__doc__ or 'undocumented').strip()
            else:
                usage = self.MSG_HELP_UNDEFINED_COMMAND

        top = self.top_of_help_message()
        bottom = self.bottom_of_help_message()
        return ''.join(filter(None, [top, description, usage, bottom]))

    def send(self, user, text, in_reply_to=None, message_type='chat'):
        """Sends a simple message to the specified user."""
        mess = self.build_message(text)
        if hasattr(user, 'getStripped'):
            mess.setTo(user.getStripped())
        else:
            mess.setTo(user)

        if in_reply_to:
            mess.setType(in_reply_to.getType())
            mess.setFrom(in_reply_to.getTo().getStripped())
        else:
            mess.setType(message_type)
            mess.setFrom(self.jid)

        self.send_message(mess)

    ###### HERE ARE THE SPECIFICS TO IMPLEMENT PER BACKEND

    def build_message(self, text):
        raise NotImplementedError(
            "It should be implemented specifically for your backend")

    def serve_forever(self):
        raise NotImplementedError(
            "It should be implemented specifically for your backend")

    def connect(self):
        """Connects the bot to server or returns current connection
        """
        raise NotImplementedError(
            "It should be implemented specifically for your backend")

    def join_room(self, room, username=None, password=None):
        raise NotImplementedError(
            "It should be implemented specifically for your backend")

    def shutdown(self):
        pass

    def connect_callback(self):
        pass

    def disconnect_callback(self):
        pass

    def callback_contact_online(self, conn, pres):
        pass

    def callback_contact_offline(self, conn, pres):
        pass

    def callback_user_joined_chat(self, conn, pres):
        pass

    def callback_user_left_chat(self, conn, pres):
        pass

    @property
    def mode(self):
        raise NotImplementedError(
            "It should be implemented specifically for your backend")
Example #7
0
File: base.py Project: poirier/err
class Backend(object):
    # Implements the basic Bot logic (logic independent from the backend) and leave to you to implement the missing parts

    cmd_history = deque(maxlen=10)
    MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
                         'An unexpected error occurred.'
    MESSAGE_SIZE_LIMIT = 10000 # the default one from hipchat
    MESSAGE_SIZE_ERROR_MESSAGE = '|<- SNIP ! Message too long.'
    MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
                          'Type "' + BOT_PREFIX + 'help" for available commands.'
    MSG_HELP_TAIL = 'Type help <command name> to get more info '\
                    'about that specific command.'
    MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'


    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {} # the dynamically populated list of commands available on the bot

    def build_text_html_message_pair(self, source):
        node = None
        text_plain = None

        try:
            node = XML2Node(utf8(source))
            text_plain = xhtml2txt(source)
        except ExpatError as ee:
            if source.strip(): # avoids keep alive pollution
                logging.debug('Could not parse [%s] as XHTML-IM, assume pure text Parsing error = [%s]' % (source, ee))
                text_plain = source
        return text_plain, node


    def send_message(self, mess):
        """Send a message"""
        self.connect().send_message(mess)

    def send_simple_reply(self, mess, text, private=False):
        """Send a simple response to a message"""
        self.send_message(self.build_reply(mess, text, private))

    def build_reply(self, mess, text=None, private=False):
        """Build a message for responding to another message.
        Message is NOT sent"""
        response = self.build_message(text)
        if private:
            # Use get_jid_from_message here instead of mess.getFrom because
            # getFrom will return the groupchat id instead of user's jid when
            # sent from a chatroom
            response.setTo(get_jid_from_message(mess))
            response.setType('chat')
            response.setFrom(self.jid)
        else:
            response.setTo(mess.getFrom().getStripped())
            response.setType(mess.getType())
            response.setFrom(self.jid)
        return response

    def callback_message(self, conn, mess):
        """
        Needs to return False if we want to stop further treatment
        """
        # Prepare to handle either private chats or group chats
        type = mess.getType()
        jid = mess.getFrom()
        props = mess.getProperties()
        text = mess.getBody()
        username = get_sender_username(mess)

        if type not in ("groupchat", "chat"):
            logging.debug("unhandled message type %s" % mess)
            return False

        logging.debug("*** props = %s" % props)
        logging.debug("*** jid = %s" % jid)
        logging.debug("*** username = %s" % username)
        logging.debug("*** type = %s" % type)
        logging.debug("*** text = %s" % text)

        # If a message format is not supported (eg. encrypted),
        # txt will be None
        if not text: return False

        if not text.startswith(BOT_PREFIX):
            return True

        text = text[1:]
        text_split = text.strip().split(' ')

        cmd = None
        command = None
        args = ''
        if len(text_split) > 1:
            command = (text_split[0] + '_' + text_split[1]).lower()
            if self.commands.has_key(command):
                cmd = command
                args = ' '.join(text_split[2:])

        if not cmd:
            command = text_split[0].lower()
            args = ' '.join(text_split[1:])
            if self.commands.has_key(command):
                cmd = command
                if len(text_split) > 1:
                    args = ' '.join(text_split[1:])

        if command == BOT_PREFIX: # we did "!!" so recall the last command
            if len(self.cmd_history):
                cmd, args = self.cmd_history[-1]
            else:
                return False # no command in history
        elif command.isdigit(): # we did "!#" so we recall the specified command
            index = int(command)
            if len(self.cmd_history) >= index:
                cmd, args = self.cmd_history[-index]
            else:
                return False # no command in history

        if (cmd, args) in self.cmd_history:
            self.cmd_history.remove((cmd, args)) # we readd it below

        logging.info("received command = %s matching [%s] with parameters [%s]" % (command, cmd, args))

        if cmd:
            def execute_and_send(template_name):
                try:
                    reply = self.commands[cmd](mess, args)

                    # integrated templating
                    if template_name:
                        reply = tenv().get_template(template_name + '.html').render(**reply)

                except Exception, e:
                    logging.exception(u'An error happened while processing '\
                                      u'a message ("%s") from %s: %s"' %
                                      (text, jid, traceback.format_exc(e)))
                    reply = self.MSG_ERROR_OCCURRED + ':\n %s' % e
                if reply:
                    if len(reply) > self.MESSAGE_SIZE_LIMIT:
                        reply = reply[:self.MESSAGE_SIZE_LIMIT - len(self.MESSAGE_SIZE_ERROR_MESSAGE)] + self.MESSAGE_SIZE_ERROR_MESSAGE
                    self.send_simple_reply(mess, reply, cmd in DIVERT_TO_PRIVATE)

            f = self.commands[cmd]

            if f._err_command_admin_only:
                if mess.getType() == 'groupchat':
                    self.send_simple_reply(mess, 'You cannot administer the bot from a chatroom, message the bot directly')
                    return False
                usr = get_jid_from_message(mess)
                if usr not in BOT_ADMINS:
                    self.send_simple_reply(mess, 'You cannot administer the bot from this user %s.' % usr)
                    return False
                if BOT_ASYNC:
                    self.thread_pool.wait() # If it is an admin command, wait that the queue is completely depleted so we don't have strange concurrency issues on load/unload/updates etc ...

            if f._err_command_historize:
                self.cmd_history.append((cmd, args)) # add it to the history only if it is authorized to be so

            if f._err_command_split_args_with:
                args = args.split(f._err_command_split_args_with)
            if BOT_ASYNC:
                wr = WorkRequest(execute_and_send, [f._err_command_template]) #execute_and_send(f._err_command_template)
                self.thread_pool.putRequest(wr)
                if f._err_command_admin_only:
                    self.thread_pool.wait() # Again wait for the completion before accepting a new command that could generate weird concurrency issues
            else:
                execute_and_send(f._err_command_template)

        else:
Example #8
0
File: base.py Project: Dafvid/err
class Backend(object):
    """
    Implements the basic Bot logic (logic independent from the backend) and leaves
    you to implement the missing parts
    """

    cmd_history = defaultdict(lambda: deque(maxlen=10))  # this will be a per user history

    MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. ' \
                         'An unexpected error occurred.'

    MSG_HELP_TAIL = 'Type help <command name> to get more info ' \
                    'about that specific command.'
    MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'

    def __init__(self, config):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """

        if config.BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            log.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot
        self.re_commands = {}  # the dynamically populated list of regex-based commands available on the bot
        self.MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' \
                                   'Type "' + config.BOT_PREFIX + 'help" for available commands.'
        if config.BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in config.BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = config.BOT_ALT_PREFIXES

    def send_message(self, mess):
        """Should be overridden by backends"""

    def send_simple_reply(self, mess, text, private=False):
        """Send a simple response to a message"""
        self.send_message(self.build_reply(mess, text, private))

    def build_reply(self, mess, text=None, private=False):
        """Build a message for responding to another message.
        Message is NOT sent"""
        msg_type = mess.type
        response = self.build_message(text)

        response.frm = self.jid
        if msg_type == 'groupchat' and not private:
            # stripped returns the full [email protected]/chat_username
            # but in case of a groupchat, we should only try to send to the MUC address
            # itself ([email protected])
            response.to = mess.frm.stripped.split('/')[0]
        elif str(mess.to) == self.bot_config.BOT_IDENTITY['username']:
            # This is a direct private message, not initiated through a MUC. Use
            # stripped to remove the resource so that the response goes to the
            # client with the highest priority
            response.to = mess.frm.stripped
        else:
            # This is a private message that was initiated through a MUC. Don't use
            # stripped here to retain the resource, else the XMPP server doesn't
            # know which user we're actually responding to.
            response.to = mess.frm
        response.type = 'chat' if private else msg_type
        return response

    def callback_presence(self, presence):
        """
           Implemented by errBot.
        """
        pass

    def callback_room_joined(self, room):
        """
            See :class:`~errbot.errBot.ErrBot`
        """
        pass

    def callback_room_left(self, room):
        """
            See :class:`~errbot.errBot.ErrBot`
        """
        pass

    def callback_room_topic(self, room):
        """
            See :class:`~errbot.errBot.ErrBot`
        """
        pass

    def callback_message(self, mess):
        """
        Needs to return False if we want to stop further treatment
        """
        # Prepare to handle either private chats or group chats
        type_ = mess.type
        jid = mess.frm
        text = mess.body
        username = get_sender_username(mess)
        user_cmd_history = self.cmd_history[username]

        if mess.delayed:
            log.debug("Message from history, ignore it")
            return False

        if type_ not in ("groupchat", "chat"):
            log.debug("unhandled message type %s" % mess)
            return False

        # Ignore messages from ourselves. Because it isn't always possible to get the
        # real JID from a MUC participant (including ourself), matching the JID against
        # ourselves isn't enough (see https://github.com/gbin/err/issues/90 for
        # background discussion on this). Matching against CHATROOM_FN isn't technically
        # correct in all cases because a MUC could give us another nickname, but it
        # covers 99% of the MUC cases, so it should suffice for the time being.
        if (jid.bare_match(self.jid) or
            type_ == "groupchat" and mess.nick == self.bot_config.CHATROOM_FN):  # noqa
                log.debug("Ignoring message from self")
                return False

        log.debug("*** jid = %s" % jid)
        log.debug("*** username = %s" % username)
        log.debug("*** type = %s" % type_)
        log.debug("*** text = %s" % text)

        # If a message format is not supported (eg. encrypted),
        # txt will be None
        if not text:
            return False

        surpress_cmd_not_found = False

        prefixed = False  # Keeps track whether text was prefixed with a bot prefix
        only_check_re_command = False  # Becomes true if text is determed to not be a regular command
        tomatch = text.lower() if self.bot_config.BOT_ALT_PREFIX_CASEINSENSITIVE else text
        if len(self.bot_config.BOT_ALT_PREFIXES) > 0 and tomatch.startswith(self.bot_alt_prefixes):
            # Yay! We were called by one of our alternate prefixes. Now we just have to find out
            # which one... (And find the longest matching, in case you have 'err' and 'errbot' and
            # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as
            # part of the called command in that case)
            prefixed = True
            longest = 0
            for prefix in self.bot_alt_prefixes:
                l = len(prefix)
                if tomatch.startswith(prefix) and l > longest:
                    longest = l
            log.debug("Called with alternate prefix '{}'".format(text[:longest]))
            text = text[longest:]

            # Now also remove the separator from the text
            for sep in self.bot_config.BOT_ALT_PREFIX_SEPARATORS:
                # While unlikely, one may have separators consisting of
                # more than one character
                l = len(sep)
                if text[:l] == sep:
                    text = text[l:]
        elif type_ == "chat" and self.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT:
            log.debug("Assuming '%s' to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True" % text)
            # In order to keep noise down we surpress messages about the command
            # not being found, because it's possible a plugin will trigger on what
            # was said with trigger_message.
            surpress_cmd_not_found = True
        elif not text.startswith(self.bot_config.BOT_PREFIX):
            only_check_re_command = True
        if text.startswith(self.bot_config.BOT_PREFIX):
            text = text[len(self.bot_config.BOT_PREFIX):]
            prefixed = True

        text = text.strip()
        text_split = text.split(' ')
        cmd = None
        command = None
        args = ''
        if not only_check_re_command:
            if len(text_split) > 1:
                command = (text_split[0] + '_' + text_split[1]).lower()
                if command in self.commands:
                    cmd = command
                    args = ' '.join(text_split[2:])

            if not cmd:
                command = text_split[0].lower()
                args = ' '.join(text_split[1:])
                if command in self.commands:
                    cmd = command
                    if len(text_split) > 1:
                        args = ' '.join(text_split[1:])

            if command == self.bot_config.BOT_PREFIX:  # we did "!!" so recall the last command
                if len(user_cmd_history):
                    cmd, args = user_cmd_history[-1]
                else:
                    return False  # no command in history
            elif command.isdigit():  # we did "!#" so we recall the specified command
                index = int(command)
                if len(user_cmd_history) >= index:
                    cmd, args = user_cmd_history[-index]
                else:
                    return False  # no command in history

        # Try to match one of the regex commands if the regular commands produced no match
        matched_on_re_command = False
        if not cmd:
            if prefixed:
                commands = self.re_commands
            else:
                commands = {k: self.re_commands[k] for k in self.re_commands
                            if not self.re_commands[k]._err_command_prefix_required}

            for name, func in commands.items():
                if func._err_command_matchall:
                    match = list(func._err_command_re_pattern.finditer(text))
                else:
                    match = func._err_command_re_pattern.search(text)
                if match:
                    log.debug("Matching '{}' against '{}' produced a match"
                              .format(text, func._err_command_re_pattern.pattern))
                    matched_on_re_command = True
                    self._process_command(mess, name, text, match)
                else:
                    log.debug("Matching '{}' against '{}' produced no match"
                              .format(text, func._err_command_re_pattern.pattern))
        if matched_on_re_command:
            return True

        if cmd:
            self._process_command(mess, cmd, args, match=None)
        elif not only_check_re_command:
            log.debug("Command not found")
            if surpress_cmd_not_found:
                log.debug("Surpressing command not found feedback")
            else:
                reply = self.unknown_command(mess, command, args)
                if reply is None:
                    reply = self.MSG_UNKNOWN_COMMAND % {'command': command}
                if reply:
                    self.send_simple_reply(mess, reply)
        return True

    def _process_command(self, mess, cmd, args, match):
        """Process and execute a bot command"""

        jid = mess.frm
        username = get_sender_username(mess)
        user_cmd_history = self.cmd_history[username]

        log.info("Processing command '{}' with parameters '{}' from {}/{}".format(cmd, args, jid, mess.nick))

        if (cmd, args) in user_cmd_history:
            user_cmd_history.remove((cmd, args))  # Avoids duplicate history items

        try:
            self.check_command_access(mess, cmd)
        except ACLViolation as e:
            if not self.bot_config.HIDE_RESTRICTED_ACCESS:
                self.send_simple_reply(mess, str(e))
            return

        f = self.re_commands[cmd] if match else self.commands[cmd]

        if f._err_command_admin_only and self.bot_config.BOT_ASYNC:
            # If it is an admin command, wait until the queue is completely depleted so
            # we don't have strange concurrency issues on load/unload/updates etc...
            self.thread_pool.wait()

        if f._err_command_historize:
            user_cmd_history.append((cmd, args))  # add it to the history only if it is authorized to be so

        # Don't check for None here as None can be a valid argument to str.split.
        # '' was chosen as default argument because this isn't a valid argument to str.split()
        if not match and f._err_command_split_args_with != '':
            try:
                if hasattr(f._err_command_split_args_with, "parse_args"):
                    args = f._err_command_split_args_with.parse_args(args)
                elif callable(f._err_command_split_args_with):
                    args = f._err_command_split_args_with(args)
                else:
                    args = args.split(f._err_command_split_args_with)
            except Exception as e:
                self.send_simple_reply(
                    mess,
                    "Sorry, I couldn't parse your arguments. {}".format(e)
                )
                return

        if self.bot_config.BOT_ASYNC:
            wr = WorkRequest(
                self._execute_and_send,
                [],
                {'cmd': cmd, 'args': args, 'match': match, 'mess': mess, 'jid': jid,
                 'template_name': f._err_command_template}
            )
            self.thread_pool.putRequest(wr)
            if f._err_command_admin_only:
                # Again, if it is an admin command, wait until the queue is completely
                # depleted so we don't have strange concurrency issues.
                self.thread_pool.wait()
        else:
            self._execute_and_send(cmd=cmd, args=args, match=match, mess=mess, jid=jid,
                                   template_name=f._err_command_template)

    def _execute_and_send(self, cmd, args, match, mess, jid, template_name=None):
        """Execute a bot command and send output back to the caller

        cmd: The command that was given to the bot (after being expanded)
        args: Arguments given along with cmd
        match: A re.MatchObject if command is coming from a regex-based command, else None
        mess: The message object
        jid: The jid of the person executing the command
        template_name: The name of the template which should be used to render
            html-im output, if any

        """

        def process_reply(reply_):
            # integrated templating
            if template_name:
                reply_ = tenv().get_template(template_name + '.html').render(**reply_)

            # Reply should be all text at this point (See https://github.com/gbin/err/issues/96)
            return str(reply_)

        def send_reply(reply_):
            for part in split_string_after(reply_, self.bot_config.MESSAGE_SIZE_LIMIT):
                self.send_simple_reply(mess, part, cmd in self.bot_config.DIVERT_TO_PRIVATE)

        commands = self.re_commands if match else self.commands
        try:
            if inspect.isgeneratorfunction(commands[cmd]):
                replies = commands[cmd](mess, match) if match else commands[cmd](mess, args)
                for reply in replies:
                    if reply:
                        send_reply(process_reply(reply))
            else:
                reply = commands[cmd](mess, match) if match else commands[cmd](mess, args)
                if reply:
                    send_reply(process_reply(reply))
        except Exception as e:
            tb = traceback.format_exc()
            log.exception('An error happened while processing '
                          'a message ("%s") from %s: %s"' %
                          (mess.body, jid, tb))
            send_reply(self.MSG_ERROR_OCCURRED + ':\n %s' % e)

    def is_admin(self, usr):
        """
        an overridable check to see if a user is an administrator
        """
        return usr in self.bot_config.BOT_ADMINS

    def check_command_access(self, mess, cmd):
        """
        Check command against ACL rules

        Raises ACLViolation() if the command may not be executed in the given context
        """
        usr = str(get_jid_from_message(mess))
        typ = mess.type

        if cmd not in self.bot_config.ACCESS_CONTROLS:
            self.bot_config.ACCESS_CONTROLS[cmd] = self.bot_config.ACCESS_CONTROLS_DEFAULT

        if ('allowusers' in self.bot_config.ACCESS_CONTROLS[cmd] and
           usr not in self.bot_config.ACCESS_CONTROLS[cmd]['allowusers']):
            raise ACLViolation("You're not allowed to access this command from this user")
        if ('denyusers' in self.bot_config.ACCESS_CONTROLS[cmd] and
           usr in self.bot_config.ACCESS_CONTROLS[cmd]['denyusers']):
            raise ACLViolation("You're not allowed to access this command from this user")
        if typ == 'groupchat':
            stripped = mess.frm.stripped
            if ('allowmuc' in self.bot_config.ACCESS_CONTROLS[cmd] and
               self.bot_config.ACCESS_CONTROLS[cmd]['allowmuc'] is False):
                raise ACLViolation("You're not allowed to access this command from a chatroom")
            if ('allowrooms' in self.bot_config.ACCESS_CONTROLS[cmd] and
               stripped not in self.bot_config.ACCESS_CONTROLS[cmd]['allowrooms']):
                raise ACLViolation("You're not allowed to access this command from this room")
            if ('denyrooms' in self.bot_config.ACCESS_CONTROLS[cmd] and
               stripped in self.bot_config.ACCESS_CONTROLS[cmd]['denyrooms']):
                raise ACLViolation("You're not allowed to access this command from this room")
        else:
            if ('allowprivate' in self.bot_config.ACCESS_CONTROLS[cmd] and
               self.bot_config.ACCESS_CONTROLS[cmd]['allowprivate'] is False):
                raise ACLViolation("You're not allowed to access this command via private message to me")

        f = self.commands[cmd] if cmd in self.commands else self.re_commands[cmd]

        if f._err_command_admin_only:
            if typ == 'groupchat':
                raise ACLViolation("You cannot administer the bot from a chatroom, message the bot directly")
            if not self.is_admin(usr):
                raise ACLViolation("This command requires bot-admin privileges")

    def unknown_command(self, _, cmd, args):
        """ Override the default unknown command behavior
        """
        full_cmd = cmd + ' ' + args.split(' ')[0] if args else None
        if full_cmd:
            part1 = 'Command "%s" / "%s" not found.' % (cmd, full_cmd)
        else:
            part1 = 'Command "%s" not found.' % cmd
        ununderscore_keys = [m.replace('_', ' ') for m in self.commands.keys()]
        matches = difflib.get_close_matches(cmd, ununderscore_keys)
        if full_cmd:
            matches.extend(difflib.get_close_matches(full_cmd, ununderscore_keys))
        matches = set(matches)
        if matches:
            return (part1 + '\n\nDid you mean "' + self.bot_config.BOT_PREFIX +
                    ('" or "' + self.bot_config.BOT_PREFIX).join(matches) + '" ?')
        else:
            return part1

    def inject_commands_from(self, instance_to_inject):
        classname = instance_to_inject.__class__.__name__
        for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(value, '_err_command', False):
                commands = self.re_commands if getattr(value, '_err_re_command') else self.commands
                name = getattr(value, '_err_command_name')

                if name in commands:
                    f = commands[name]
                    new_name = (classname + '-' + name).lower()
                    self.warn_admins('%s.%s clashes with %s.%s so it has been renamed %s' % (
                        classname, name, type(f.__self__).__name__, f.__name__, new_name))
                    name = new_name
                commands[name] = value

                if getattr(value, '_err_re_command'):
                    log.debug('Adding regex command : %s -> %s' % (name, value.__name__))
                    self.re_commands = commands
                else:
                    log.debug('Adding command : %s -> %s' % (name, value.__name__))
                    self.commands = commands

    def remove_commands_from(self, instance_to_inject):
        for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(value, '_err_command', False):
                name = getattr(value, '_err_command_name')
                if getattr(value, '_err_re_command') and name in self.re_commands:
                    del (self.re_commands[name])
                elif not getattr(value, '_err_re_command') and name in self.commands:
                    del (self.commands[name])

    def warn_admins(self, warning):
        for admin in self.bot_config.BOT_ADMINS:
            self.send(admin, warning)

    def top_of_help_message(self):
        """Returns a string that forms the top of the help message

        Override this method in derived class if you
        want to add additional help text at the
        beginning of the help message.
        """
        return ""

    def bottom_of_help_message(self):
        """Returns a string that forms the bottom of the help message

        Override this method in derived class if you
        want to add additional help text at the end
        of the help message.
        """
        return ""

    @botcmd
    def help(self, mess, args):
        """   Returns a help string listing available options.

        Automatically assigned to the "help" command."""
        if not args:
            if self.__doc__:
                description = self.__doc__.strip()
            else:
                description = 'Available commands:'

            usage = '\n'.join(sorted([
                self.bot_config.BOT_PREFIX + '%s: %s' % (name, (command.__doc__ or
                                                         '(undocumented)').strip().split('\n', 1)[0])
                for (name, command) in self.commands.items()
                if name != 'help' and not command._err_command_hidden
            ]))
            usage = '\n\n' + '\n\n'.join(filter(None, [usage, self.MSG_HELP_TAIL]))
        else:
            description = ''
            if args in self.commands:
                usage = (self.commands[args].__doc__ or
                         'undocumented').strip()
            else:
                usage = self.MSG_HELP_UNDEFINED_COMMAND

        top = self.top_of_help_message()
        bottom = self.bottom_of_help_message()
        return ''.join(filter(None, [top, description, usage, bottom]))

    def send(self, user, text, in_reply_to=None, message_type='chat', groupchat_nick_reply=False):
        """Sends a simple message to the specified user."""

        nick_reply = self.bot_config.GROUPCHAT_NICK_PREFIXED

        if (message_type == 'groupchat' and in_reply_to and nick_reply and groupchat_nick_reply):
            reply_text = self.groupchat_reply_format().format(in_reply_to.nick, text)
        else:
            reply_text = text

        mess = self.build_message(reply_text)
        if hasattr(user, 'stripped'):
            mess.to = user.stripped
        else:
            mess.to = user

        if in_reply_to:
            mess.type = in_reply_to.type
            mess.frm = in_reply_to.to.stripped
        else:
            mess.type = message_type
            mess.frm = self.jid

        self.send_message(mess)

    # ##### HERE ARE THE SPECIFICS TO IMPLEMENT PER BACKEND

    def groupchat_reply_format(self):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def build_message(self, text):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def serve_forever(self):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def connect(self):
        """Connects the bot to server or returns current connection
        """
        raise NotImplementedError("It should be implemented specifically for your backend")

    def join_room(self, room, username=None, password=None):
        """
        Join a room (MUC).

        :param room:
            The JID/identifier of the room to join.
        :param username:
            An optional username to use.
        :param password:
            An optional password to use (for password-protected rooms).

        .. deprecated:: 2.2.0
            Use the methods on :class:`MUCRoom` instead.
        """
        warnings.warn(
            "Using join_room is deprecated, use query_room and the join "
            "method on the resulting response instead.",
            DeprecationWarning
        )
        self.query_room(room).join(username=username, password=password)

    def query_room(self, room):
        """
        Query a room for information.

        :param room:
            The JID/identifier of the room to query for.
        :returns:
            An instance of :class:`~MUCRoom`.
        """
        raise NotImplementedError("It should be implemented specifically for your backend")

    def shutdown(self):
        pass

    def connect_callback(self):
        pass

    def disconnect_callback(self):
        pass

    @property
    def mode(self):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def rooms(self):
        """
        Return a list of rooms the bot is currently in.

        :returns:
            A list of :class:`~errbot.backends.base.MUCRoom` instances.
        """
        raise NotImplementedError("It should be implemented specifically for your backend")
Example #9
0
File: base.py Project: kunaldeo/err
class Backend(object):
    # Implements the basic Bot logic (logic independent from the backend) and leave to you to implement the missing parts

    cmd_history = defaultdict(lambda: deque(maxlen=10)) #this will be a per user history

    MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
                         'An unexpected error occurred.'
    MESSAGE_SIZE_LIMIT = MESSAGE_SIZE_LIMIT
    MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
                          'Type "' + BOT_PREFIX + 'help" for available commands.'
    MSG_HELP_TAIL = 'Type help <command name> to get more info '\
                    'about that specific command.'
    MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'


    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot

        if BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = BOT_ALT_PREFIXES


    def send_message(self, mess):
        """Send a message"""
        self.connect().send_message(mess)

    def send_simple_reply(self, mess, text, private=False):
        """Send a simple response to a message"""
        self.send_message(self.build_reply(mess, text, private))

    def build_reply(self, mess, text=None, private=False):
        """Build a message for responding to another message.
        Message is NOT sent"""
        response = self.build_message(text)
        if private:
            response.setTo(get_jid_from_message(mess))
            response.setType('chat')
            response.setFrom(self.jid)
        else:
            response.setTo(mess.getFrom().getStripped())
            response.setType(mess.getType())
            response.setFrom(self.jid)
        return response

    def callback_message(self, conn, mess):
        """
        Needs to return False if we want to stop further treatment
        """
        # Prepare to handle either private chats or group chats
        type = mess.getType()
        jid = mess.getFrom()
        text = mess.getBody()
        username = get_sender_username(mess)
        user_cmd_history = self.cmd_history[username];

        if mess.isDelayed():
            logging.debug("Message from history, ignore it")
            return False

        if type not in ("groupchat", "chat"):
            logging.debug("unhandled message type %s" % mess)
            return False

        logging.debug("*** jid = %s" % jid)
        logging.debug("*** username = %s" % username)
        logging.debug("*** type = %s" % type)
        logging.debug("*** text = %s" % text)

        # If a message format is not supported (eg. encrypted),
        # txt will be None
        if not text:
            return False

        surpress_cmd_not_found = False

        tomatch = text.lower() if BOT_ALT_PREFIX_CASEINSENSITIVE else text
        if len(BOT_ALT_PREFIXES) > 0 and tomatch.startswith(self.bot_alt_prefixes):
            # Yay! We were called by one of our alternate prefixes. Now we just have to find out
            # which one... (And find the longest matching, in case you have 'err' and 'errbot' and
            # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as
            # part of the called command in that case)
            longest = 0
            for prefix in self.bot_alt_prefixes:
                l = len(prefix)
                if tomatch.startswith(prefix) and l > longest:
                    longest = l
            text = text[longest:]

            # Now also remove the separator from the text
            for sep in BOT_ALT_PREFIX_SEPARATORS:
                # While unlikely, one may have separators consisting of
                # more than one character
                l = len(sep)
                if text[:l] == sep:
                    text = text[l:]
        elif type == "chat" and BOT_PREFIX_OPTIONAL_ON_CHAT:
            logging.debug("Assuming '%s' to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True" % text)
            # In order to keep noise down we surpress messages about the command
            # not being found, because it's possible a plugin will trigger on what
            # was said with trigger_message.
            surpress_cmd_not_found = True
        elif not text.startswith(BOT_PREFIX):
            return True
        if text.startswith(BOT_PREFIX):
            text = text[len(BOT_PREFIX):]

        text_split = text.strip().split(' ')
        cmd = None
        command = None
        args = ''
        if len(text_split) > 1:
            command = (text_split[0] + '_' + text_split[1]).lower()
            if command in self.commands:
                cmd = command
                args = ' '.join(text_split[2:])

        if not cmd:
            command = text_split[0].lower()
            args = ' '.join(text_split[1:])
            if command in self.commands:
                cmd = command
                if len(text_split) > 1:
                    args = ' '.join(text_split[1:])

        if command == BOT_PREFIX:  # we did "!!" so recall the last command
            if len(user_cmd_history):
                cmd, args = user_cmd_history[-1]
            else:
                return False  # no command in history
        elif command.isdigit():  # we did "!#" so we recall the specified command
            index = int(command)
            if len(user_cmd_history) >= index:
                cmd, args = user_cmd_history[-index]
            else:
                return False  # no command in history

        if (cmd, args) in user_cmd_history:
            user_cmd_history.remove((cmd, args))  # we readd it below

        if cmd:
            logging.info("received command = %s matching [%s] with parameters [%s]" % (command, cmd, args))

            access, accessError = self.checkCommandAccess(mess, cmd)
            if not access:
                if not HIDE_RESTRICTED_ACCESS:
                    self.send_simple_reply(mess, accessError)
                return False

            f = self.commands[cmd]

            if f._err_command_admin_only and BOT_ASYNC:
                    self.thread_pool.wait()  # If it is an admin command, wait that the queue is completely depleted so we don't have strange concurrency issues on load/unload/updates etc ...

            if f._err_command_historize:
                user_cmd_history.append((cmd, args))  # add it to the history only if it is authorized to be so

            # Don't check for None here as None can be a valid argument to split.
            # '' was chosen as default argument because this isn't a valid argument to split()
            if f._err_command_split_args_with != '':
                args = args.split(f._err_command_split_args_with)
            if BOT_ASYNC:
                wr = WorkRequest(self._execute_and_send,
                                 [], {'cmd': cmd, 'args': args, 'mess': mess, 'jid': jid,
                                      'template_name': f._err_command_template})
                self.thread_pool.putRequest(wr)
                if f._err_command_admin_only:
                    self.thread_pool.wait()  # Again wait for the completion before accepting a new command that could generate weird concurrency issues
            else:
                self._execute_and_send(cmd=cmd, args=args, mess=mess, jid=jid,
                                       template_name=f._err_command_template)

        else:
            logging.debug("Command not found")
            if surpress_cmd_not_found:
                logging.debug("Surpressing command not found feedback")
            else:
                reply = self.unknown_command(mess, command, args)
                if reply is None:
                    reply = self.MSG_UNKNOWN_COMMAND % {'command': command}
                if reply:
                    self.send_simple_reply(mess, reply)

        return True

    def _execute_and_send(self, cmd, args, mess, jid, template_name=None):
        """Execute a bot command and send output back to the caller

        cmd: The command that was given to the bot (after being expanded)
        args: Arguments given along with cmd
        mess: The message object
        jid: The jid of the person executing the command
        template_name: The name of the template which should be used to render
            html-im output, if any

        """

        def process_reply(reply):
            # integrated templating
            if template_name:
                reply = tenv().get_template(template_name + '.html').render(**reply)

            # Reply should be all text at this point (See https://github.com/gbin/err/issues/96)
            return str(reply)

        def send_reply(reply):
            for part in split_string_after(reply, self.MESSAGE_SIZE_LIMIT):
                self.send_simple_reply(mess, part, cmd in DIVERT_TO_PRIVATE)

        try:
            if inspect.isgeneratorfunction(self.commands[cmd]):
                replies = self.commands[cmd](mess, args)
                for reply in replies:
                    if reply: send_reply(process_reply(reply))
            else:
                reply = self.commands[cmd](mess, args)
                if reply: send_reply(process_reply(reply))
        except Exception as e:
            tb = traceback.format_exc()
            logging.exception('An error happened while processing '
                              'a message ("%s") from %s: %s"' %
                              (mess.getBody(), jid, tb))
            send_reply(self.MSG_ERROR_OCCURRED + ':\n %s' % e)

    def checkCommandAccess(self, mess, cmd):        
        usr = str(get_jid_from_message(mess))
        typ = mess.getType()

        if cmd not in ACCESS_CONTROLS:
            ACCESS_CONTROLS[cmd] = ACCESS_CONTROLS_DEFAULT

        if 'allowusers' in ACCESS_CONTROLS[cmd] and usr not in ACCESS_CONTROLS[cmd]['allowusers']:
            return False, "You're not allowed to access this command from this user"
        if 'denyusers' in ACCESS_CONTROLS[cmd] and usr in ACCESS_CONTROLS[cmd]['denyusers']:
            return False, "You're not allowed to access this command from this user"
        if typ == 'groupchat':
            stripped = mess.getFrom().getStripped()
            if 'allowmuc' in ACCESS_CONTROLS[cmd] and ACCESS_CONTROLS[cmd]['allowmuc'] is False:
                return False, "You're not allowed to access this command from a chatroom"
            if 'allowrooms' in ACCESS_CONTROLS[cmd] and stripped not in ACCESS_CONTROLS[cmd]['allowrooms']:
                return False, "You're not allowed to access this command from this room"
            if 'denyrooms' in ACCESS_CONTROLS[cmd] and stripped in ACCESS_CONTROLS[cmd]['denyrooms']:
                return False, "You're not allowed to access this command from this room"
        else:
            if 'allowprivate' in ACCESS_CONTROLS[cmd] and ACCESS_CONTROLS[cmd]['allowprivate'] is False:
                return False, "You're not allowed to access this command via private message to me"

        f = self.commands[cmd]

        if f._err_command_admin_only:
            if typ == 'groupchat':
                return False, 'You cannot administer the bot from a chatroom, message the bot directly'
            if usr not in BOT_ADMINS:
                return False, 'You cannot administer the bot from this user %s.' % usr
        
        return True, ""

    def unknown_command(self, mess, cmd, args):
        """ Override the default unknown command behavior
        """
        full_cmd = cmd + ' ' + args.split(' ')[0] if args else None
        if full_cmd:
            part1 = 'Command "%s" / "%s" not found.' % (cmd, full_cmd)
        else:
            part1 = 'Command "%s" not found.' % cmd
        ununderscore_keys = [m.replace('_', ' ') for m in self.commands.keys()]
        matches = difflib.get_close_matches(cmd, ununderscore_keys)
        if full_cmd:
            matches.extend(difflib.get_close_matches(full_cmd, ununderscore_keys))
        matches = set(matches)
        if matches:
            return part1 + '\n\nDid you mean "' + BOT_PREFIX + ('" or "' + BOT_PREFIX).join(matches) + '" ?'
        else:
            return part1

    def inject_commands_from(self, instance_to_inject):
        classname = instance_to_inject.__class__.__name__
        for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(value, '_err_command', False):
                name = getattr(value, '_err_command_name')

                if name in self.commands:
                    f = self.commands[name]
                    new_name = (classname + '-' + name).lower()
                    self.warn_admins('%s.%s clashes with %s.%s so it has been renamed %s' % (classname, name, type(f.__self__).__name__, f.__name__, new_name ))
                    name = new_name
                logging.debug('Adding command : %s -> %s' % (name, value.__name__))
                self.commands[name] = value

    def remove_commands_from(self, instance_to_inject):
        for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(value, '_err_command', False):
                name = getattr(value, '_err_command_name')
                if name in self.commands:  # this could happen in premature shutdown
                    del (self.commands[name])

    def warn_admins(self, warning):
        for admin in BOT_ADMINS:
            self.send(admin, warning)

    def top_of_help_message(self):
        """Returns a string that forms the top of the help message

        Override this method in derived class if you
        want to add additional help text at the
        beginning of the help message.
        """
        return ""

    def bottom_of_help_message(self):
        """Returns a string that forms the bottom of the help message

        Override this method in derived class if you
        want to add additional help text at the end
        of the help message.
        """
        return ""

    @botcmd
    def help(self, mess, args):
        """   Returns a help string listing available options.

        Automatically assigned to the "help" command."""
        if not args:
            if self.__doc__:
                description = self.__doc__.strip()
            else:
                description = 'Available commands:'

            usage = '\n'.join(sorted([
            BOT_PREFIX + '%s: %s' % (name, (command.__doc__ or
                                            '(undocumented)').strip().split('\n', 1)[0])
            for (name, command) in self.commands.iteritems()\
            if name != 'help'\
            and not command._err_command_hidden
            ]))
            usage = '\n\n' + '\n\n'.join(filter(None, [usage, self.MSG_HELP_TAIL]))
        else:
            description = ''
            if args in self.commands:
                usage = (self.commands[args].__doc__ or
                         'undocumented').strip()
            else:
                usage = self.MSG_HELP_UNDEFINED_COMMAND

        top = self.top_of_help_message()
        bottom = self.bottom_of_help_message()
        return ''.join(filter(None, [top, description, usage, bottom]))

    def send(self, user, text, in_reply_to=None, message_type='chat'):
        """Sends a simple message to the specified user."""
        mess = self.build_message(text)
        if isinstance(user, str):
            mess.setTo(user)
        else:
            mess.setTo(user.getStripped())

        if in_reply_to:
            mess.setType(in_reply_to.getType())
            mess.setFrom(in_reply_to.getTo().getStripped())
        else:
            mess.setType(message_type)
            mess.setFrom(self.jid)

        self.send_message(mess)


    ###### HERE ARE THE SPECIFICS TO IMPLEMENT PER BACKEND

    def build_message(self, text):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def serve_forever(self):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def connect(self):
        """Connects the bot to server or returns current connection
        """
        raise NotImplementedError("It should be implemented specifically for your backend")

    def join_room(self, room, username=None, password=None):
        raise NotImplementedError("It should be implemented specifically for your backend")

    def shutdown(self):
        pass

    def connect_callback(self):
        pass

    def disconnect_callback(self):
        pass

    @property
    def mode(self):
        raise NotImplementedError("It should be implemented specifically for your backend")
Example #10
0
class Backend(object):
    # Implements the basic Bot logic (logic independent from the backend) and leave to you to implement the missing parts

    cmd_history = deque(maxlen=10)
    MSG_ERROR_OCCURRED = 'Sorry for your inconvenience. '\
                         'An unexpected error occurred.'
    MESSAGE_SIZE_LIMIT = 10000 # the default one from hipchat
    MESSAGE_SIZE_ERROR_MESSAGE = '|<- SNIP ! Message too long.'
    MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '\
                          'Type "' + BOT_PREFIX + 'help" for available commands.'
    MSG_HELP_TAIL = 'Type help <command name> to get more info '\
                    'about that specific command.'
    MSG_HELP_UNDEFINED_COMMAND = 'That command is not defined.'


    def __init__(self, *args, **kwargs):
        """ Those arguments will be directly those put in BOT_IDENTITY
        """
        if BOT_ASYNC:
            self.thread_pool = ThreadPool(3)
            logging.debug('created the thread pool' + str(self.thread_pool))
        self.commands = {}  # the dynamically populated list of commands available on the bot

        if BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = BOT_ALT_PREFIXES


    def send_message(self, mess):
        """Send a message"""
        self.connect().send_message(mess)

    def send_simple_reply(self, mess, text, private=False):
        """Send a simple response to a message"""
        self.send_message(self.build_reply(mess, text, private))

    def build_reply(self, mess, text=None, private=False):
        """Build a message for responding to another message.
        Message is NOT sent"""
        response = self.build_message(text)
        if private:
            # Use get_jid_from_message here instead of mess.getFrom because
            # getFrom will return the groupchat id instead of user's jid when
            # sent from a chatroom
            response.setTo(get_jid_from_message(mess))
            response.setType('chat')
            response.setFrom(self.jid)
        else:
            response.setTo(mess.getFrom().getStripped())
            response.setType(mess.getType())
            response.setFrom(self.jid)
        return response

    def callback_message(self, conn, mess):
        """
        Needs to return False if we want to stop further treatment
        """
        # Prepare to handle either private chats or group chats
        type = mess.getType()
        jid = mess.getFrom()
        props = mess.getProperties()
        text = mess.getBody()
        username = get_sender_username(mess)

        if type not in ("groupchat", "chat"):
            logging.debug("unhandled message type %s" % mess)
            return False

        logging.debug("*** props = %s" % props)
        logging.debug("*** jid = %s" % jid)
        logging.debug("*** username = %s" % username)
        logging.debug("*** type = %s" % type)
        logging.debug("*** text = %s" % text)

        # If a message format is not supported (eg. encrypted),
        # txt will be None
        if not text: return False

        surpress_cmd_not_found = False

        tomatch = text.lower() if BOT_ALT_PREFIX_CASEINSENSITIVE else text
        if len(BOT_ALT_PREFIXES) > 0 and tomatch.startswith(self.bot_alt_prefixes):
            # Yay! We were called by one of our alternate prefixes. Now we just have to find out
            # which one... (And find the longest matching, in case you have 'err' and 'errbot' and
            # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as
            # part of the called command in that case)
            longest = 0
            for prefix in self.bot_alt_prefixes:
                l = len(prefix)
                if tomatch.startswith(prefix) and l > longest:
                    longest = l
            text = text[longest:]

            # Now also remove the separator from the text
            for sep in BOT_ALT_PREFIX_SEPARATORS:
                # While unlikely, one may have separators consisting of
                # more than one character
                l = len(sep)
                if text[:l] == sep:
                    text = text[l:]
        elif type == "chat" and BOT_PREFIX_OPTIONAL_ON_CHAT:
            logging.debug("Assuming '%s' to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True" % text)
            # In order to keep noise down we surpress messages about the command
            # not being found, because it's possible a plugin will trigger on what
            # was said with trigger_message.
            surpress_cmd_not_found = True
        elif not text.startswith(BOT_PREFIX):
            return True
        else:
            text = text[len(BOT_PREFIX):]

        text_split = text.strip().split(' ')
        cmd = None
        command = None
        args = ''
        if len(text_split) > 1:
            command = (text_split[0] + '_' + text_split[1]).lower()
            if command in self.commands:
                cmd = command
                args = ' '.join(text_split[2:])

        if not cmd:
            command = text_split[0].lower()
            args = ' '.join(text_split[1:])
            if command in self.commands:
                cmd = command
                if len(text_split) > 1:
                    args = ' '.join(text_split[1:])

        if command == BOT_PREFIX:  # we did "!!" so recall the last command
            if len(self.cmd_history):
                cmd, args = self.cmd_history[-1]
            else:
                return False  # no command in history
        elif command.isdigit():  # we did "!#" so we recall the specified command
            index = int(command)
            if len(self.cmd_history) >= index:
                cmd, args = self.cmd_history[-index]
            else:
                return False  # no command in history

        if (cmd, args) in self.cmd_history:
            self.cmd_history.remove((cmd, args))  # we readd it below

        logging.info("received command = %s matching [%s] with parameters [%s]" % (command, cmd, args))

        if cmd:
            def execute_and_send(template_name):
                try:
                    reply = self.commands[cmd](mess, args)

                    # integrated templating
                    if template_name:
                        reply = tenv().get_template(template_name + '.html').render(**reply)

                    # Reply should be all text at this point (See https://github.com/gbin/err/issues/96)
                    reply = unicode(reply)
                except Exception, e:
                    logging.exception(u'An error happened while processing '
                                      u'a message ("%s") from %s: %s"' %
                                      (text, jid, traceback.format_exc(e)))
                    reply = self.MSG_ERROR_OCCURRED + ':\n %s' % e
                if reply:
                    if len(reply) > self.MESSAGE_SIZE_LIMIT:
                        reply = reply[:self.MESSAGE_SIZE_LIMIT - len(self.MESSAGE_SIZE_ERROR_MESSAGE)] + self.MESSAGE_SIZE_ERROR_MESSAGE
                    self.send_simple_reply(mess, reply, cmd in DIVERT_TO_PRIVATE)

            usr = str(get_jid_from_message(mess))
            typ = mess.getType()
            if cmd not in ACCESS_CONTROLS:
                ACCESS_CONTROLS[cmd] = ACCESS_CONTROLS_DEFAULT

            if 'allowusers' in ACCESS_CONTROLS[cmd] and usr not in ACCESS_CONTROLS[cmd]['allowusers']:
                self.send_simple_reply(mess, "You're not allowed to access this command from this user")
                return False
            if 'denyusers' in ACCESS_CONTROLS[cmd] and usr in ACCESS_CONTROLS[cmd]['denyusers']:
                self.send_simple_reply(mess, "You're not allowed to access this command from this user")
                return False
            if typ == 'groupchat':
                stripped = mess.getFrom().getStripped()
                if 'allowmuc' in ACCESS_CONTROLS[cmd] and ACCESS_CONTROLS[cmd]['allowmuc'] is False:
                    self.send_simple_reply(mess, "You're not allowed to access this command from a chatroom")
                    return False
                if 'allowrooms' in ACCESS_CONTROLS[cmd] and stripped not in ACCESS_CONTROLS[cmd]['allowrooms']:
                    self.send_simple_reply(mess, "You're not allowed to access this command from this room")
                    return False
                if 'denyrooms' in ACCESS_CONTROLS[cmd] and stripped in ACCESS_CONTROLS[cmd]['denyrooms']:
                    self.send_simple_reply(mess, "You're not allowed to access this command from this room")
                    return False
            else:
                if 'allowprivate' in ACCESS_CONTROLS[cmd] and ACCESS_CONTROLS[cmd]['allowprivate'] is False:
                    self.send_simple_reply(mess, "You're not allowed to access this command via private message to me")
                    return False

            f = self.commands[cmd]

            if f._err_command_admin_only:
                if typ == 'groupchat':
                    self.send_simple_reply(mess, 'You cannot administer the bot from a chatroom, message the bot directly')
                    return False
                if usr not in BOT_ADMINS:
                    self.send_simple_reply(mess, 'You cannot administer the bot from this user %s.' % usr)
                    return False
                if BOT_ASYNC:
                    self.thread_pool.wait() # If it is an admin command, wait that the queue is completely depleted so we don't have strange concurrency issues on load/unload/updates etc ...

            if f._err_command_historize:
                self.cmd_history.append((cmd, args)) # add it to the history only if it is authorized to be so

            # Don't check for None here as None can be a valid argument to split.
            # '' was chosen as default argument because this isn't a valid argument to split()
            if f._err_command_split_args_with != '':
                args = args.split(f._err_command_split_args_with)
            if BOT_ASYNC:
                wr = WorkRequest(execute_and_send, [f._err_command_template]) #execute_and_send(f._err_command_template)
                self.thread_pool.putRequest(wr)
                if f._err_command_admin_only:
                    self.thread_pool.wait() # Again wait for the completion before accepting a new command that could generate weird concurrency issues
            else:
                execute_and_send(f._err_command_template)

        else: