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 __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 __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 __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
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")
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:
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")
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")
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: