def __init__(self, token: str, config: dict = default_config, logger: Optional[logging.Logger] = None) -> None: self.slack_client = SlackClient(token) self.commands = {} # type: Dict[Pattern[str], Callable] self.command_names = {} # type: Dict[Pattern[str], str] self.middleware_functions = [] # type: List[Callable] self.config = dict(self.default_config) self.config.update(config) self.running = False self.scheduler = Scheduler() self.fallback_command_func = None # type: Optional[Callable] if logger is None: logger = logging.getLogger(__name__) if not logger.hasHandlers(): handler = logging.StreamHandler() formatter = logging.Formatter(fmt="%(asctime)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) logger.propagate = False logger.setLevel(logging.INFO) self.logger = logger _global_ctx_stack.push({}) self._register_standard_commands()
def test_adds_job_correctly(self): test_func = MagicMock() schedule = Schedule().every().day().at(12, 00) job = ScheduledJob(schedule, test_func) scheduler = Scheduler() scheduler.add_job(job) self.assertIn(job, scheduler.jobs)
def test_runs_jobs_correctly(self): test_func = MagicMock() schedule = Schedule().every().day().at(12, 00) job = ScheduledJob(schedule, test_func) job.should_run = MagicMock(return_value=True) scheduler = Scheduler() scheduler.add_job(job) scheduler.run_pending() test_func.assert_called_once()
class Phial(): ''' The Phial object acts as the central object. It is given a token and an optional config dictionary. Once created it holds the core components of the bot, including command functions, command patterns and more. ''' #: Default configuration default_config = { 'prefix': "!", 'registerHelpCommand': True, 'baseHelpText': "All available commands:", 'autoReconnect': True } def __init__(self, token: str, config: dict = default_config, logger: Optional[logging.Logger] = None) -> None: self.slack_client = SlackClient(token) self.commands = {} # type: Dict[Pattern[str], Callable] self.command_names = {} # type: Dict[Pattern[str], str] self.middleware_functions = [] # type: List[Callable] self.config = dict(self.default_config) self.config.update(config) self.running = False self.scheduler = Scheduler() self.fallback_command_func = None # type: Optional[Callable] if logger is None: logger = logging.getLogger(__name__) if not logger.hasHandlers(): handler = logging.StreamHandler() formatter = logging.Formatter(fmt="%(asctime)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) logger.propagate = False logger.setLevel(logging.INFO) self.logger = logger _global_ctx_stack.push({}) self._register_standard_commands() def _register_standard_commands(self) -> None: ''' Register any standard commands respecting the configuration provided ''' if self.config['registerHelpCommand']: # The command function has to be a lambda as we wish to delay # execution until all commands have been registered. self.add_command("help", lambda: help_command(self), help_text_override="List all available commmands") @staticmethod def _build_command_pattern(command: str, case_sensitive: bool) -> Pattern[str]: '''Creates the command pattern regexs''' command = re.sub(r'(<\w+>)', r'(\"?\1\"?)', command) command_regex = re.sub(r'(<\w+>)', r'(?P\1[^"]*)', command) return re.compile("^{}$".format(command_regex), 0 if case_sensitive else re.IGNORECASE) def add_command(self, command_pattern_template: str, command_func: Callable, case_sensitive: bool = False, help_text_override: Optional[str] = None) -> None: ''' Creates a command pattern and adds a command function to the bot. This is the same as :meth:`command`. :: @bot.command('hello') def hello(): return "world" Is the same as :: def hello(): return "world" bot.add_command('hello', world) Args: command_pattern_template(str): A string that will be used to create a command_pattern regex command_func(func): The function to be run when the command pattern is matched case_sensitive(bool, optional): Whether or not the command is case sensitive. Defaults to False help_text_override(str, optional): Text that should be used as a description of the command using the inbuilt !help function. If not overriden the command's docstring will be used as the help text. Defaults to None Raises: ValueError If command with the same name already registered ''' command_pattern = self._build_command_pattern(command_pattern_template, case_sensitive) # Have to ignore the type of the line below as mypy can not currently # deal with 'extending' functions to have extra attributes # GitHub Issue: https://github.com/python/mypy/issues/2087 command_func._help = ( help_text_override if # type: ignore help_text_override is not None else command_func.__doc__) if command_pattern not in self.commands: self.command_names[command_pattern] = command_pattern_template self.commands[command_pattern] = command_func self.logger.debug( "Command {0} added".format(command_pattern_template)) else: raise ValueError('Command {0} already exists'.format( command_pattern.split("<")[0])) def get_command_match(self, text: str) -> Optional[Tuple[Dict, Pattern[str]]]: ''' Returns a dictionary of args and the command pattern for the command pattern that is matched. Will returns None if no match Args: text(str): The string to be matched to a command Returns: A :obj:`dict` object with kwargs and the command pattern if a match is found otherwise :obj:`None` ''' prefix = cast(str, self.config['prefix']) if prefix: if not text.startswith(prefix): return None text = text[1:] for command_pattern in self.commands: m = command_pattern.match(text) if m: return m.groupdict(), command_pattern raise ValueError('Command "{}" has not been registered'.format(text)) def fallback_command(self) -> Callable: ''' A decorator that is used to register a command function for use when a user tries to execute a command that doesn't exist Example: :: @bot.fallback_command() def error_handler(attempted_command: Command): return "Oops that command doesn't seem to exist" ''' def decorator(f: Callable) -> Callable: self.add_fallback_command(f) return f return decorator def add_fallback_command(self, command_func: Callable) -> None: ''' Registers a fallback function to run when a user tries to execute a command that doesn't exist. This is the same as :meth:`fallback_command`. :: @bot.fallback_command() def error_handler(attempted_command: Command): return "Oops that command doesn't seem to exist" Is the same as :: def error_handler(attempted_command: Command): return "Oops that command doesn't seem to exist" bot.add_fallback_command(error_handler) Args: command_func(func): The function to be set as the fallback ''' self.fallback_command_func = command_func def command(self, command_pattern_template: str, case_sensitive: bool = False, help_text_override: Optional[str] = None) -> Callable: ''' A decorator that is used to register a command function for a given command. This does the same as :meth:`add_command` but is used as a decorator. Args: command_pattern_template(str): A string that will be used to create a command_pattern regex case_sensitive(bool, optional): Whether or not the command is case sensitive. Defaults to False help_text_override(str, optional): Text that should be used as a description of the command using the inbuilt !help function. If not overriden the command's docstring will be used as the help text. Defaults to None Example: :: @bot.command('hello') def hello(): return "world" @bot.command('caseSensitive', case_sensitive=True) def case_sensitive(): return "You typed caseSensitive" ''' def decorator(f: Callable) -> Callable: self.add_command(command_pattern_template, f, case_sensitive, help_text_override) return f return decorator def middleware(self) -> Callable: ''' A decorator that is used to register a middleware function. This does the same as :meth:`add_middleware` but is used as a decorator. Each middleware function will be passed a `Message` , and must return a `Message` for the next middleware function to be able to work. To stop processing of a message simply return `None`. Example: :: @bot.middleware() def intercept_message(message): # Process message from slack before passing it on phial's # default processing ... code here return message @bot.middleware() def intercept_and_halt_message(message): # Process message from slack without phial's default # processing ... code here return None ''' def decorator(f: Callable) -> Callable: self.add_middleware(f) return f return decorator def add_middleware(self, middleware_func: Callable) -> None: ''' Adds a middleware function to the bot. This is the same as :meth:`middleware`. :: @bot.middleware() def intercept(messaage): return message Is the same as :: def intercept(messaage): return message bot.add_middleware(intercept) Args: middleware_func(func): The function to be added to the middleware pipeline ''' self.logger.debug("Middleware {0} added".format( getattr(middleware_func, '__name__', repr(middleware_func)))) self.middleware_functions.append(middleware_func) def alias(self, command_pattern_template: str, case_sensitive: bool = False) -> Callable: ''' A decorator that is used to register an alias for a command. Internally this is the same as :meth:`command`. Args: command_pattern_template(str): A string that will be used to create a command_pattern regex case_sensitive(bool, optional): Whether or not the command is case sensitive. Defaults to False Example: :: @bot.command('hello') @bot.alias('goodbye') def hello(): return "world" Is the same as :: @bot.command('hello') @bot.command('goodbye') def hello(): return "world" ''' return self.command(command_pattern_template, case_sensitive) def scheduled(self, schedule: Schedule) -> Callable: ''' A decorator that is used to register a scheduled function. This does the same as :meth:`add_scheduled` but is used as a decorator. Args: schedule(Schedule): The schedule used to run the function Example: :: @bot.scheduled(Schedule().every().day()) def scheduled_beep(): bot.send_message(Response(text="Beep", channel="channel-id">)) ''' def decorator(f: Callable) -> Callable: self.add_scheduled(schedule, f) return f return decorator def add_scheduled(self, schedule: Schedule, scheduled_func: Callable) -> None: ''' Adds a scheduled function to the bot. This is the same as :meth:`scheduled`. :: @bot.scheduled(Schedule().every().day()) def scheduled_beep(): bot.send_message(Response(text="Beep", channel="channel-id">)) Is the same as :: def scheduled_beep(): bot.send_message(Response(text="Beep", channel="channel-id">)) bot.add_scheduled(Schedule().every().day(), scheduled_beep) Args: schedule(Schedule): The schedule used to run the function scheduled_func(func): The function to be run in accordance to the schedule ''' self.logger.debug("Schedule {0} added".format( getattr(scheduled_func, '__name__', repr(scheduled_func)))) job = ScheduledJob(schedule, scheduled_func) self.scheduler.add_job(job) def _create_command(self, command_message: Message) -> Optional[Command]: '''Creates an instance of a command''' try: command_match = self.get_command_match(command_message.text) except ValueError: # Command not registered. return Command(None, command_message.channel, None, command_message.user, command_message) if command_match: kwargs, command_pattern = command_match return Command(command_pattern, command_message.channel, kwargs, command_message.user, command_message) return None def _handle_command(self, command: Optional[Command]) -> Any: '''Executes a given command''' if command is None: return # Do nothing if no command if command.command_pattern is None: command_func = None else: command_func = self.commands.get(command.command_pattern, None) if command_func is None: # If no command found warn and then return early self.logger.warn("Command {0} not found".format( command.message.text)) if self.fallback_command_func is None: return _command_ctx_stack.push(command) return self.fallback_command_func(command) if command.args is None: self.logger.exception("Command has no args") raise ValueError("Command has no args") _command_ctx_stack.push(command) return command_func(**command.args) def _parse_slack_output(self, slack_rtm_output: List[Dict]) -> Optional[Message]: """ The Slack Real Time Messaging API is an events firehose. This function parses the JSON form Slack into phial Messages """ output_list = slack_rtm_output if output_list and len(output_list) > 0: for output in output_list: if (output and 'text' in output): self.logger.debug( "Message recieved from Slack: {0}".format(output)) bot_id = None if 'bot_id' in output: bot_id = output['bot_id'] return Message(output['text'], output['channel'], output['user'], output['ts'], bot_id) return None def send_message(self, message: Response) -> None: ''' Takes a response object and then sends the message to Slack Args: message(Response): message object to be sent to Slack ''' api_method = ('chat.postEphemeral' if message.ephemeral else 'chat.postMessage') if message.original_ts: self.slack_client.api_call(api_method, channel=message.channel, text=message.text, thread_ts=message.original_ts, attachments=json.dumps( message.attachments, default=lambda o: o.__dict__), user=message.user, as_user=True) else: self.slack_client.api_call(api_method, channel=message.channel, text=message.text, attachments=json.dumps( message.attachments, default=lambda o: o.__dict__), user=message.user, as_user=True) def send_reaction(self, response: Response) -> None: ''' Takes a `Response` object and then sends the reaction to Slack Args: response(Response): response object containing the reaction to be sent to Slack ''' self.slack_client.api_call("reactions.add", channel=response.channel, timestamp=response.original_ts, name=response.reaction, as_user=True) def upload_attachment(self, attachment: Attachment) -> None: ''' Takes an `Attachment` object and then uploads the contents to Slack Args: attachment(Attachment): attachment object containing the file to be uploaded to Slack ''' self.slack_client.api_call('files.upload', channels=attachment.channel, filename=attachment.filename, file=attachment.content) def _execute_response(self, response: Union[str, Response, Attachment]) -> None: '''Execute the response of a command function''' if response is None: return # Do nothing if command function returns nothing if isinstance(response, str): self.send_message(Response(text=response, channel=command.channel)) elif not isinstance(response, Response) and not isinstance( response, Attachment): raise ValueError('Only Response or Attachment objects can be ' + 'returned from command functions') if isinstance(response, Response): if response.original_ts and response.reaction and response.text: raise ValueError( 'Response objects with an original timestamp ' + 'can only have one of the attributes: ' + 'Reaction, Text') if response.original_ts and response.reaction: self.send_reaction(response) elif response.text or response.attachments: self.send_message(response) if isinstance(response, Attachment): if not response.content: raise ValueError('The content field of Attachment objects ' + 'must be set') self.upload_attachment(response) def _is_running(self) -> bool: return self.running def _handle_message(self, message: Message) -> None: ''' Takes a `Message` object and run the middleware on the message before attempting to create and executes a `Command` if the message has not been intercepted. ''' # Run middleware functions for func in self.middleware_functions: if message: message = func(message) # If message has been intercepted or is a bot message return early if not message or message.bot_id: return # If message has not been intercepted continue with standard message # handling try: command = self._create_command(message) response = self._handle_command(command) self._execute_response(response) except ValueError as err: self.logger.exception('ValueError: {}'.format(err)) finally: _command_ctx_stack.pop() def run(self) -> None: '''Connects to slack client and handles incoming messages''' self.running = True slack_client = self.slack_client auto_reconnect = self.config['autoReconnect'] if not slack_client.rtm_connect(auto_reconnect=auto_reconnect, with_team_state=False): raise ValueError("Connection failed. Invalid Token or bot ID") self.logger.info("Phial connected and running!") while self._is_running(): try: message = self._parse_slack_output(slack_client.rtm_read()) if message: self._handle_message(message) self.scheduler.run_pending() except Exception as e: self.logger.exception("Error: {0}".format(e))
def test_creates_correctly(self): scheduler = Scheduler() self.assertEqual(len(scheduler.jobs), 0)