def __init__(self, chat, tasker, auth_builder, reporting_channel, config_path): # type: (Chat, Tasker, Callable[[str], Auth], str, str) -> None ''' Args: chat (Chat): The chat object to use for messaging. tasker (Tasker): The Tasker object to get tasks from auth_builder (Auth): The constructor to build Auth objects from. It should take in only a username as a parameter. reporting_channel (str): Channel ID to report alerts in need of verification to. config_path (str): Path to configuration file ''' logging.info('Creating securitybot.') # username of the test user - doesn't notify this user and sets associated task as done self.test_username = os.getenv('TEST_USERNAME', None) self.tasker = tasker self.auth_builder = auth_builder self.reporting_channel = reporting_channel self._last_task_poll = datetime.min.replace(tzinfo=pytz.utc) self._last_report = datetime.min.replace(tzinfo=pytz.utc) self._load_config(config_path) self.chat = chat chat.connect() # Load blacklist from SQL self.blacklist = SQLBlacklist() # A dictionary to be populated with all members of the team self.users = {} # type: Dict[str, User] self.users_by_name = {} # type: Dict[str, User] self._populate_users() # Dictionary of users who have outstanding tasks self.active_users = {} # type: Dict[str, User] # Dictionary of active tasks to have only one instance of every active task self.active_tasks = {} # Recover tasks self.recover_in_progress_tasks() logging.info('Test user is "{}", who will not be notified.'.format( self.test_username)) logging.info('Done!')
class SecurityBot(object): ''' It's always dangerous naming classes the same name as the project... ''' def __init__(self, chat, tasker, auth_builder, reporting_channel, config_path): # type: (Chat, Tasker, Callable[[str], Auth], str, str) -> None ''' Args: chat (Chat): The chat object to use for messaging. tasker (Tasker): The Tasker object to get tasks from auth_builder (Auth): The constructor to build Auth objects from. It should take in only a username as a parameter. reporting_channel (str): Channel ID to report alerts in need of verification to. config_path (str): Path to configuration file ''' logging.info('Creating securitybot.') self.tasker = tasker self.auth_builder = auth_builder self.reporting_channel = reporting_channel self._last_task_poll = datetime.min.replace(tzinfo=pytz.utc) self._last_report = datetime.min.replace(tzinfo=pytz.utc) self._load_config(config_path) self.chat = chat chat.connect() # Load blacklist from SQL self.blacklist = SQLBlacklist() # A dictionary to be populated with all members of the team self.users = {} # type: Dict[str, User] self.users_by_name = {} # type: Dict[str, User] self._populate_users() # Dictionary of users who have outstanding tasks self.active_users = {} # type: Dict[str, User] # Recover tasks self.recover_in_progress_tasks() logging.info('Done!') # Initialization functions def _load_config(self, config_path): # type: (str) -> None ''' Loads a configuration file for the bot. ''' logging.info('Loading configuration.') with open(config_path, 'r') as f: config = yaml.safe_load(f) # Required parameters try: self._load_messages(config['messages_path']) self._load_commands(config['commands_path']) except KeyError as e: logging.error('Missing parameter: {0}'.format(e)) raise SecurityBotException( 'Configuration file missing parameters.') # Optional parameters self.icon_url = config.get('icon_url', 'https://placehold.it/256x256') def _load_messages(self, messages_path): # type: (str) -> None ''' Loads messages from a YAML file. Args: messages_path (str): Path to messages file. ''' self.messages = yaml.safe_load(open(messages_path)) def _load_commands(self, commands_path): # type: (str) -> None ''' Loads commands from a configuration file. Args: commands_path (str): Path to commands file. ''' with open(commands_path, 'r') as f: commands = yaml.safe_load(f) self.commands = {} # type: Dict[str, Any] for name, cmd in commands.items(): new_cmd = DEFAULT_COMMAND.copy() new_cmd.update(cmd) try: new_cmd['fn'] = getattr(bot_commands, format(cmd['fn'])) except AttributeError as e: raise SecurityBotException( 'Invalid function: {0}'.format(e)) self.commands[name] = new_cmd logging.info('Loaded commands: {0}'.format(self.commands.keys())) # Bot functions def run(self): # type: () -> None ''' Main loop for the bot. ''' while True: now = datetime.now(tz=pytz.utc) if now - self._last_task_poll > TASK_POLL_TIME: self._last_task_poll = now self.handle_new_tasks() self.handle_in_progress_tasks() self.handle_verifying_tasks() self.handle_messages() self.handle_users() time.sleep(.1) def handle_messages(self): # type: () -> None ''' Handles all messages sent to securitybot. Currently only active users are considered, i.e. we don't care if a user sends us a message but we haven't sent them anything. ''' messages = self.chat.get_messages() for message in messages: user_id = message['user'] text = message['text'] user = self.user_lookup(user_id) # Parse each received line as a command, otherwise send an error message if self.is_command(text): self.handle_command(user, text) else: self.chat.message_user(user, self.messages['bad_command']) def handle_command(self, user, command): # type: (User, str) -> None ''' Handles a given command from a user. ''' key, args = self.parse_command(command) logging.info('Handling command {0} for {1}'.format(key, user['name'])) cmd = self.commands[key] if cmd['fn'](self, user, args): if cmd['success_msg']: self.chat.message_user(user, cmd['success_msg']) else: if cmd['failure_msg']: self.chat.message_user(user, cmd['failure_msg']) def valid_user(self, username): # type: (str) -> bool ''' Validates a username to be valid. ''' if len(username.split()) != 1: return False try: self.user_lookup_by_name(username) return True except SecurityBotException as e: logging.warn('{}'.format(e)) return False def _add_task(self, task): # type: (Task) -> None ''' Adds a new task to the user specified by that task. Args: task (Task): the task to add. ''' username = task.username if self.valid_user(username): # Ignore blacklisted users if self.blacklist.is_present(username): logging.info( 'Ignoring task for blacklisted {0}'.format(username)) task.comment = 'blacklisted' task.set_verifying() else: user = self.user_lookup_by_name(username) user_id = user['id'] if user_id not in self.active_users: logging.debug('Adding {} to active users'.format(username)) self.active_users[user_id] = user self.greet_user(user) user.add_task(task) task.set_in_progress() else: # Escalate if no valid user is found logging.warn('Invalid user: {0}'.format(username)) task.comment = 'invalid user' task.set_verifying() def handle_new_tasks(self): # type: () -> None ''' Handles all new tasks. ''' for task in self.tasker.get_new_tasks(): # Log new task logging.info('Handling new task for {0}'.format(task.username)) self._add_task(task) def handle_in_progress_tasks(self): # type: () -> None ''' Handles all in progress tasks. ''' pass def recover_in_progress_tasks(self): # type: () -> None ''' Recovers in progress tasks from a previous run. ''' for task in self.tasker.get_active_tasks(): # Log new task logging.info('Recovering task for {0}'.format(task.username)) self._add_task(task) def handle_verifying_tasks(self): # type: () -> None ''' Handles all tasks which are currently waiting for verification. ''' pass def handle_users(self): # type: () -> None ''' Handles all users. ''' for user_id in self.active_users.keys(): user = self.active_users[user_id] user.step() def cleanup_user(self, user): # type: (User) -> None ''' Cleanup a user from the active users list once they have no remaining tasks. ''' logging.debug('Removing {} from active users'.format(user['name'])) self.active_users.pop(user['id'], None) def alert_user(self, user, task): # type: (User, Task) -> None ''' Alerts a user about an alert that was trigged and associated with their name. Args: user (User): The user associated with the task. task (Task): A task to alert on. ''' # Format the reason to be indented reason = '\n'.join(['>' + s for s in task.reason.split('\n')]) message = self.messages['alert'].format(task.description, reason) message += '\n' message += self.messages['action_prompt'] self.chat.message_user(user, message) # User creation and lookup methods def _populate_users(self): # type: () -> None ''' Populates the members dictionary mapping user IDs to username, avatar, etc. ''' logging.info('Gathering information about all team members...') members = self.chat.get_users() for member in members: user = User(member, self.auth_builder(member['name']), self) self.users[member['id']] = user self.users_by_name[member['name']] = user logging.info('Gathered info on {} users.'.format(len(self.users))) def user_lookup(self, id): # type: (str) -> User ''' Looks up a user by their ID. Args: id (str): The ID of a user to look up, formatted like U12345678. Returns: (dict): All known information about that user. ''' if id not in self.users: raise SecurityBotException('User {} not found'.format(id)) return self.users[id] def user_lookup_by_name(self, username): # type: (str) -> User ''' Looks up a user by their username. Args: username (str): The username of the user to look up. Resturns: (dict): All known information about that user. ''' if username not in self.users_by_name: raise SecurityBotException('User {} not found'.format(username)) return self.users_by_name[username] # Chat methods def greet_user(self, user): # type: (User) -> None ''' Sends a greeting message to a user. Args: user (User): The user to greet. ''' self.chat.message_user( user, self.messages['greeting'].format(user.get_name())) # Command functions def is_command(self, command): # type: (str) -> bool '''Checks if a raw command is a command.''' return clean_command(command.split()[0]) in self.commands def parse_command(self, command): # type: (str) -> Tuple[str, List[str]] ''' Parses a given command. Args: command (str): The raw command to parse. Returns: (str, List[str]): A tuple of the command followed by arguments. ''' # First try shlex command = clean_input(command) try: split = shlex.split(command) except ValueError: # ignore shlex exception # Fall back to naive method split = command.split() return (clean_command(split[0]), split[1:])