def run(self) -> None: """Start integration with server. Based on the settings done in initialization, this will... - start workers - load plugin modules - add scheduled jobs and start scheduler - connect to server - stop workers and scheduler when connection is gone """ # Setup required workers self.worker = ThreadPoolExecutor(max_workers=self.max_workers) \ if self.max_workers else None self.message_worker = ThreadExecutor() # Load plugins self.load_plugins() # Set scheduled job self.add_schedule_jobs(self.schedules) self.scheduler.start() try: self.connect() except Exception as e: logging.error("Error occurred while bot interaction", e) finally: self.stop()
def test_valid(self): base_impl = create_concrete_class()(None, max_workers=3) base_impl.message_worker = ThreadExecutor() with patch.object(base_impl.message_worker, 'submit', return_value=Future()): base_impl.enqueue_sending_message(lambda _: "dummy") assert_that(base_impl.message_worker.submit.call_count) \ .is_equal_to(1)
def run(self) -> None: # Setup required workers self.worker = ThreadPoolExecutor(max_workers=self.max_workers) \ if self.max_workers else None self.message_worker = ThreadExecutor() # Load plugins self.load_plugins() # Set scheduled job self.add_schedule_jobs(self.schedules) self.scheduler.start() self.connect()
class Base(object, metaclass=abc.ABCMeta): """Base class of all bot implementation.""" __commands = {} # type: Dict[str, List[Command]] __schedules = {} # type: Dict[str, List[ScheduledCommand]] __instances = {} # type: Dict[str, Base] # Should be its subclass def __init__(self, plugins: Iterable[PluginConfig] = None, max_workers: Optional[int] = None) -> None: """Initializer. This may be extended by each bot implementation to do some extra setup, but should not be overridden. For the term "extend" and "override," refer to pep257. :param plugins: List of plugin modules to import :param max_workers: Optional number of worker threads. Methods with @concurrent decorator will be submitted to this thread pool. """ if not plugins: plugins = () # {module_name: config, ...} # Some simple plugins can be used without configuration, so second # element may be omitted on assignment. # In that case, just give empty dictionary as configuration value. self.plugin_config = OrderedDict([(p[0], p[1] if len(p) > 1 else {}) for p in plugins]) self.max_workers = max_workers self.scheduler = background.BackgroundScheduler() self.user_context_map = {} # type: Dict[str, UserContext] # To be set on run() self.worker = None # type: ThreadPoolExecutor self.message_worker = None # type: ThreadExecutor cls = self.__class__ cls_name = cls.__name__ # Reset to ease tests in one file cls.__commands[cls_name] = [] cls.__schedules[cls_name] = [] # To refer to this instance from class method decorator cls.__instances[cls_name] = self @abc.abstractmethod def generate_schedule_job(self, command: ScheduledCommand) \ -> Optional[Callable[..., None]]: """Generate callback function to be registered to scheduler. Since the job handling behaviour varies depending on each bot implementation, it is each concrete classes' responsibility to generate scheduled job. Returned function will be registered to scheduler and will be executed. :param command: ScheduledCommand object that holds job information :return: Optional callable object to be scheduled """ pass @abc.abstractmethod def connect(self) -> None: """Connect to server. Concrete class must override this to establish bot-to-server connection. This is called at the end of run() after all setup is done. """ pass def run(self) -> None: """Start integration with server. Based on the settings done in initialization, this will... - start workers - load plugin modules - add scheduled jobs and start scheduler - connect to server - stop workers and scheduler when connection is gone """ # Setup required workers self.worker = ThreadPoolExecutor(max_workers=self.max_workers) \ if self.max_workers else None self.message_worker = ThreadExecutor() # Load plugins self.load_plugins() # Set scheduled job self.add_schedule_jobs(self.schedules) self.scheduler.start() try: self.connect() except Exception as e: logging.error("Error occurred while bot interaction", e) finally: self.stop() def stop(self) -> None: """Stop. Cleanup scheduler and workers. Consider this as finalizer.""" logging.info('STOP SCHEDULER') if self.scheduler.running: try: self.scheduler.shutdown() logging.info('CANCELLED SCHEDULED WORK') except Exception as e: logging.error(e) logging.info('STOP CONCURRENT WORKER') if self.worker: self.worker.shutdown(wait=False) logging.info('STOP MESSAGE WORKER') self.message_worker.shutdown(wait=False) @classmethod def concurrent(cls, callback_function): """A decorator to provide concurrent job mechanism. A function wrapped by this decorator will be fed to worker thread pool and waits for execution. If max_workers setting is None on initialization, thread pool is not created so the given function will run immediately. Since bot, in nature, requires a lot of I/O bound tasks such as retrieving data from 3rd party web API so it is suitable to feed those tasks to thread pool. For CPU bound concurrent tasks, consider employing multi-process approach. :param callback_function: Function to be fed to worker thread pool. """ @wraps(callback_function) def wrapper(self, *args, **kwargs): if self.worker: return self.worker.submit(callback_function, self, *args, **kwargs) else: return callback_function(self, *args, **kwargs) return wrapper def enqueue_sending_message(self, function, *args, **kwargs) -> Future: """Submit given callback function to message worker. The message_worker is a single-threaded executor, so it is safe to say only one message sending task run at a time. :param function: Callable to be executed in worker thread. :param args: Arguments to be fed to function. :param kwargs: Keyword arguments to be fed to function. :return: Future object that represent the result of given job. """ return self.message_worker.submit(function, *args, **kwargs) def load_plugins(self) -> None: """Load given plugin modules.""" for module_name in self.plugin_config.keys(): self.load_plugin(module_name) @staticmethod def load_plugin(module_name: str) -> None: """Load given plugin module. If the module is already loaded, it reloads to reflect any change. """ try: if module_name in sys.modules.keys(): imp.reload(sys.modules[module_name]) else: importlib.import_module(module_name) except Exception as e: logging.warning('Failed to load %s. %s. Skipping.' % (module_name, e)) else: logging.info('Loaded plugin. %s' % module_name) def respond(self, user_key: str, user_input: str) -> Optional[Union[RichMessage, str]]: """Receive user input and respond to it. It checks if any UserContext is stored with the given user_key. If found, consider this user is in a middle of "conversation" with a plugin module. Then the user input is passed to the next_step of that UserContext and proceed. If there is no UserContext stored, see if any registered command is applicable. If found, pass the user input to given plugin module command and receive response. Command response can be one of UserContext, RichMessage, or string. When UserContext is returned, register this context with the user key so the next input from the same user can proceed to next step. :param user_key: Stringified unique user key. Format varies depending on each bot implementation. :param user_input: User input text. :return: One of RichMessage, string, or None. """ user_context = self.user_context_map.get(user_key, None) if user_input == '.help': return self.help() ret = None # type: Optional[Union[RichMessage, UserContext, str]] error = [] # type: List[Tuple[str, str]] if user_context: # User is in the middle of conversation if user_input == '.abort': # If user wishes, abort the current conversation, and remove # context data. self.user_context_map.pop(user_key) return 'Abort current conversation' # Check if we can proceed conversation. If user input is irrelevant # return help message. next_step = user_context.find_next_step(user_input) if next_step is None: return user_context.help_message try: config = self.plugin_config.get(next_step.__module__, {}) ret = next_step( CommandMessage(original_text=user_input, text=user_input, sender=user_key), config) # Only when command is successfully executed, remove current # context. To forcefully abort the conversation, use ".abort" # command self.user_context_map.pop(user_key) except Exception as e: error.append((next_step.__name__, str(e))) else: # If user is not in the middle of conversation, see if the input # text contains command. command = self.find_command(user_input) if command is None: # If it doesn't match any command, leave it. return None try: text = re.sub(r'{0}\s+'.format(command.name), '', user_input) ret = command( CommandMessage(original_text=user_input, text=text, sender=user_key)) except Exception as e: error.append((command.name, str(e))) if error: logging.error('Error occurred. ' 'command: %s. input: %s. error: %s.' % (error[0][0], user_input, error[0][1])) return 'Something went wrong with "%s"' % user_input elif not ret: logging.error('command should return UserContext or text' 'to let user know the result or next move') return 'Something went wrong with "%s"' % user_input elif isinstance(ret, UserContext): self.user_context_map[user_key] = ret return ret.message else: # String or RichMessage return ret def find_command(self, text: str) -> Optional[Command]: """Receive user input text and return applicable command if any. :param text: User input. """ return next((c for c in self.commands if text.startswith(c.name)), None) def help(self) -> Union[str, RichMessage]: """Return stringified help message. Override this method to provide more detailed or rich help message. :return: String or RichMessage that contains help message """ return "\n".join(c.help for c in self.commands) @property def schedules(self) -> List[ScheduledCommand]: """Return registered schedules. :return: List of ScheduledCommand instances. """ cls = self.__class__ return cls.__schedules.get(cls.__name__, []) @classmethod def schedule(cls, name: str) \ -> Callable[[ScheduledFunction], ScheduledFunction]: """A decorator to provide scheduled function. When function with this decorator is found on module loading, this searches for schedule configuration in the plugin configuration and appends the combination of function and the configuration to schedules list. :param name: Name of the scheduled job. :return: Callable that contains registering function. This is to ease unit test for plugin modules. """ def wrapper(func: ScheduledFunction) -> ScheduledFunction: @wraps(func) def wrapped_function(given_config: Dict[str, Any]) \ -> Union[str, RichMessage]: return func(given_config) module = inspect.getmodule(func) self = cls.__instances.get(cls.__name__, None) # Register only if bot is instantiated. if self and module: module_name = module.__name__ config = self.plugin_config.get(module_name, {}) schedule_config = config.get('schedule', {}) if schedule_config: # If command name duplicates, update with the later one. # The order stays. command = ScheduledCommand(name, wrapped_function, module_name, config, schedule_config) try: # If command is already registered, updated it. idx = [c.name for c in cls.__schedules[cls.__name__]] \ .index(command.name) cls.__schedules[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__schedules[cls.__name__].append(command) else: logging.warning( 'Missing configuration for schedule job. %s. ' 'Skipping.' % module_name) # To ease plugin's unit test return wrapped_function return wrapper def add_schedule_jobs(self, commands: Iterable[ScheduledCommand]) -> None: """Add given function to scheduler. :param commands: List of ScheduledCommand instances. :return: None """ for command in commands: # self.add_schedule_job(command) job_function = self.generate_schedule_job(command) if not job_function: continue logging.info("Add schedule %s" % command.job_id) self.scheduler.add_job(job_function, id=command.job_id, **command.schedule_config.pop( 'scheduler_args', { 'trigger': "interval", 'minutes': 5 })) @property def commands(self) -> List[Command]: """Return registered commands. :return: List of Command instances. """ cls = self.__class__ return cls.__commands.get(cls.__name__, []) @classmethod def command(cls, name: str, examples: Iterable[str] = None) \ -> Callable[[CommandFunction], CommandFunction]: """A decorator to provide command function. When function with this decorator is found on module loading, this searches for configuration in the plugin configuration and appends the combination of function and the configuration to commands list. :param name: Name of the command. :param examples: Optional list of string to be displayed as input example. :return: Callable that contains registering function. This is to ease unit test for plugin modules. """ def wrapper(func: CommandFunction) -> CommandFunction: @wraps(func) def wrapped_function(command_message: CommandMessage, given_config: Dict[str, Any]) \ -> Union[str, UserContext, RichMessage]: return func(command_message, given_config) module = inspect.getmodule(func) self = cls.__instances.get(cls.__name__, None) # Register only if bot is instantiated. if self and module: module_name = module.__name__ config = self.plugin_config.get(module_name, {}) # If command name duplicates, update with the later one. # The order stays. command = Command(name, func, module_name, config, examples) try: # If command is already registered, updated it. idx = [c.name for c in cls.__commands[cls.__name__]] \ .index(command.name) cls.__commands[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__commands[cls.__name__].append(command) # To ease plugin's unit test return wrapped_function return wrapper
class Base(object, metaclass=abc.ABCMeta): """Base class of all bot implementation.""" __commands = {} # type: Dict[str, List[Command]] __schedules = {} # type: Dict[str, List[ScheduledCommand]] __instances = {} # type: Dict[str, Base] # Should be its subclass def __init__(self, plugins: Iterable[PluginConfig] = None, max_workers: Optional[int] = None) -> None: """Initializer. This may be extended by each bot implementation to do some extra setup, but should not be overridden. For the term "extend" and "override," refer to pep257. :param plugins: List of plugin modules to import :param max_workers: Optional number of worker threads. Methods with @concurrent decorator will be submitted to this thread pool. """ if not plugins: plugins = () # {module_name: config, ...} # Some simple plugins can be used without configuration, so second # element may be omitted on assignment. # In that case, just give empty dictionary as configuration value. self.plugin_config = OrderedDict( [(p[0], p[1] if len(p) > 1 else {}) for p in plugins]) self.max_workers = max_workers self.scheduler = background.BackgroundScheduler() self.user_context_map = {} # type: Dict[str, UserContext] # To be set on run() self.worker = None # type: ThreadPoolExecutor self.message_worker = None # type: ThreadExecutor cls = self.__class__ cls_name = cls.__name__ # Reset to ease tests in one file cls.__commands[cls_name] = [] cls.__schedules[cls_name] = [] # To refer to this instance from class method decorator cls.__instances[cls_name] = self @abc.abstractmethod def generate_schedule_job(self, command: ScheduledCommand) \ -> Optional[Callable[..., None]]: """Generate callback function to be registered to scheduler. Since the job handling behaviour varies depending on each bot implementation, it is each concrete classes' responsibility to generate scheduled job. Returned function will be registered to scheduler and will be executed. :param command: ScheduledCommand object that holds job information :return: Optional callable object to be scheduled """ pass @abc.abstractmethod def connect(self) -> None: """Connect to server. Concrete class must override this to establish bot-to-server connection. This is called at the end of run() after all setup is done. """ pass def run(self) -> None: """Start integration with server. Based on the settings done in initialization, this will... - start workers - load plugin modules - add scheduled jobs and start scheduler - connect to server - stop workers and scheduler when connection is gone """ # Setup required workers self.worker = ThreadPoolExecutor(max_workers=self.max_workers) \ if self.max_workers else None self.message_worker = ThreadExecutor() # Load plugins self.load_plugins() # Set scheduled job self.add_schedule_jobs(self.schedules) self.scheduler.start() try: self.connect() except Exception as e: logging.error("Error occurred while bot interaction", e) finally: self.stop() def stop(self) -> None: """Stop. Cleanup scheduler and workers. Consider this as finalizer.""" logging.info('STOP SCHEDULER') if self.scheduler.running: try: self.scheduler.shutdown() logging.info('CANCELLED SCHEDULED WORK') except Exception as e: logging.error(e) logging.info('STOP CONCURRENT WORKER') if self.worker: self.worker.shutdown(wait=False) logging.info('STOP MESSAGE WORKER') self.message_worker.shutdown(wait=False) @classmethod def concurrent(cls, callback_function): """A decorator to provide concurrent job mechanism. A function wrapped by this decorator will be fed to worker thread pool and waits for execution. If max_workers setting is None on initialization, thread pool is not created so the given function will run immediately. Since bot, in nature, requires a lot of I/O bound tasks such as retrieving data from 3rd party web API so it is suitable to feed those tasks to thread pool. For CPU bound concurrent tasks, consider employing multi-process approach. :param callback_function: Function to be fed to worker thread pool. """ @wraps(callback_function) def wrapper(self, *args, **kwargs): if self.worker: return self.worker.submit(callback_function, self, *args, **kwargs) else: return callback_function(self, *args, **kwargs) return wrapper def enqueue_sending_message(self, function, *args, **kwargs) -> Future: """Submit given callback function to message worker. The message_worker is a single-threaded executor, so it is safe to say only one message sending task run at a time. :param function: Callable to be executed in worker thread. :param args: Arguments to be fed to function. :param kwargs: Keyword arguments to be fed to function. :return: Future object that represent the result of given job. """ return self.message_worker.submit(function, *args, **kwargs) def load_plugins(self) -> None: """Load given plugin modules.""" for module_name in self.plugin_config.keys(): self.load_plugin(module_name) @staticmethod def load_plugin(module_name: str) -> None: """Load given plugin module. If the module is already loaded, it reloads to reflect any change. """ try: if module_name in sys.modules.keys(): imp.reload(sys.modules[module_name]) else: importlib.import_module(module_name) except Exception as e: logging.warning('Failed to load %s. %s. Skipping.' % (module_name, e)) else: logging.info('Loaded plugin. %s' % module_name) def respond(self, user_key: str, user_input: str) -> Optional[Union[RichMessage, str]]: """Receive user input and respond to it. It checks if any UserContext is stored with the given user_key. If found, consider this user is in a middle of "conversation" with a plugin module. Then the user input is passed to the next_step of that UserContext and proceed. If there is no UserContext stored, see if any registered command is applicable. If found, pass the user input to given plugin module command and receive response. Command response can be one of UserContext, RichMessage, or string. When UserContext is returned, register this context with the user key so the next input from the same user can proceed to next step. :param user_key: Stringified unique user key. Format varies depending on each bot implementation. :param user_input: User input text. :return: One of RichMessage, string, or None. """ user_context = self.user_context_map.get(user_key, None) if user_input == '.help': return self.help() ret = None # type: Optional[Union[RichMessage, UserContext, str]] error = [] # type: List[Tuple[str, str]] if user_context: # User is in the middle of conversation if user_input == '.abort': # If user wishes, abort the current conversation, and remove # context data. self.user_context_map.pop(user_key) return 'Abort current conversation' # Check if we can proceed conversation. If user input is irrelevant # return help message. next_step = user_context.find_next_step(user_input) if next_step is None: return user_context.help_message try: config = self.plugin_config.get(next_step.__module__, {}) ret = next_step(CommandMessage(original_text=user_input, text=user_input, sender=user_key), config) # Only when command is successfully executed, remove current # context. To forcefully abort the conversation, use ".abort" # command self.user_context_map.pop(user_key) except Exception as e: error.append((next_step.__name__, str(e))) else: # If user is not in the middle of conversation, see if the input # text contains command. command = self.find_command(user_input) if command is None: # If it doesn't match any command, leave it. return None try: text = re.sub(r'{0}\s+'.format(command.name), '', user_input) ret = command(CommandMessage(original_text=user_input, text=text, sender=user_key)) except Exception as e: error.append((command.name, str(e))) if error: logging.error('Error occurred. ' 'command: %s. input: %s. error: %s.' % ( error[0][0], user_input, error[0][1] )) return 'Something went wrong with "%s"' % user_input elif not ret: logging.error('command should return UserContext or text' 'to let user know the result or next move') return 'Something went wrong with "%s"' % user_input elif isinstance(ret, UserContext): self.user_context_map[user_key] = ret return ret.message else: # String or RichMessage return ret def find_command(self, text: str) -> Optional[Command]: """Receive user input text and return applicable command if any. :param text: User input. """ return next((c for c in self.commands if text.startswith(c.name)), None) def help(self) -> Union[str, RichMessage]: """Return stringified help message. Override this method to provide more detailed or rich help message. :return: String or RichMessage that contains help message """ return "\n".join(c.help for c in self.commands) @property def schedules(self) -> List[ScheduledCommand]: """Return registered schedules. :return: List of ScheduledCommand instances. """ cls = self.__class__ return cls.__schedules.get(cls.__name__, []) @classmethod def schedule(cls, name: str) \ -> Callable[[ScheduledFunction], ScheduledFunction]: """A decorator to provide scheduled function. When function with this decorator is found on module loading, this searches for schedule configuration in the plugin configuration and appends the combination of function and the configuration to schedules list. :param name: Name of the scheduled job. :return: Callable that contains registering function. This is to ease unit test for plugin modules. """ def wrapper(func: ScheduledFunction) -> ScheduledFunction: @wraps(func) def wrapped_function(given_config: Dict[str, Any]) \ -> Union[str, RichMessage]: return func(given_config) module = inspect.getmodule(func) self = cls.__instances.get(cls.__name__, None) # Register only if bot is instantiated. if self and module: module_name = module.__name__ config = self.plugin_config.get(module_name, {}) schedule_config = config.get('schedule', {}) if schedule_config: # If command name duplicates, update with the later one. # The order stays. command = ScheduledCommand(name, wrapped_function, module_name, config, schedule_config) try: # If command is already registered, updated it. idx = [c.name for c in cls.__schedules[cls.__name__]] \ .index(command.name) cls.__schedules[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__schedules[cls.__name__].append(command) else: logging.warning( 'Missing configuration for schedule job. %s. ' 'Skipping.' % module_name) # To ease plugin's unit test return wrapped_function return wrapper def add_schedule_jobs(self, commands: Iterable[ScheduledCommand]) -> None: """Add given function to scheduler. :param commands: List of ScheduledCommand instances. :return: None """ for command in commands: # self.add_schedule_job(command) job_function = self.generate_schedule_job(command) if not job_function: continue logging.info("Add schedule %s" % command.job_id) self.scheduler.add_job( job_function, id=command.job_id, **command.schedule_config.pop( 'scheduler_args', {'trigger': "interval", 'minutes': 5})) @property def commands(self) -> List[Command]: """Return registered commands. :return: List of Command instances. """ cls = self.__class__ return cls.__commands.get(cls.__name__, []) @classmethod def command(cls, name: str, examples: Iterable[str] = None) \ -> Callable[[CommandFunction], CommandFunction]: """A decorator to provide command function. When function with this decorator is found on module loading, this searches for configuration in the plugin configuration and appends the combination of function and the configuration to commands list. :param name: Name of the command. :param examples: Optional list of string to be displayed as input example. :return: Callable that contains registering function. This is to ease unit test for plugin modules. """ def wrapper(func: CommandFunction) -> CommandFunction: @wraps(func) def wrapped_function(command_message: CommandMessage, given_config: Dict[str, Any]) \ -> Union[str, UserContext, RichMessage]: return func(command_message, given_config) module = inspect.getmodule(func) self = cls.__instances.get(cls.__name__, None) # Register only if bot is instantiated. if self and module: module_name = module.__name__ config = self.plugin_config.get(module_name, {}) # If command name duplicates, update with the later one. # The order stays. command = Command(name, func, module_name, config, examples) try: # If command is already registered, updated it. idx = [c.name for c in cls.__commands[cls.__name__]] \ .index(command.name) cls.__commands[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__commands[cls.__name__].append(command) # To ease plugin's unit test return wrapped_function return wrapper
class Base(object, metaclass=abc.ABCMeta): __commands = {} __schedules = {} __instances = {} def __init__(self, plugins: Sequence[PluginConfig]=None, max_workers: Optional[int]=None) -> None: if not plugins: plugins = () # {module_name: config, ...} # Some simple plugins can be used without configuration, so second # element may be omitted on assignment. # In that case, just give empty dictionary as configuration value. self.plugin_config = OrderedDict( [(p[0], p[1] if len(p) > 1 else {}) for p in plugins]) self.max_workers = max_workers self.scheduler = BackgroundScheduler() self.user_context_map = {} # To be set on run() self.worker = None self.message_worker = None # Reset to ease tests in one file self.__commands[self.__class__.__name__] = [] self.__schedules[self.__class__.__name__] = [] # To refer to this instance from class method decorator self.__instances[self.__class__.__name__] = self @abc.abstractmethod def add_schedule_job(self, command: Command) -> None: pass @abc.abstractmethod def connect(self) -> None: pass def run(self) -> None: # Setup required workers self.worker = ThreadPoolExecutor(max_workers=self.max_workers) \ if self.max_workers else None self.message_worker = ThreadExecutor() # Load plugins self.load_plugins() # Set scheduled job self.add_schedule_jobs(self.schedules) self.scheduler.start() self.connect() def stop(self) -> None: logging.info('STOP MESSAGE WORKER') self.message_worker.shutdown(wait=False) logging.info('STOP CONCURRENT WORKER') if self.worker: self.worker.shutdown(wait=False) logging.info('STOP SCHEDULER') if self.scheduler.running: try: self.scheduler.shutdown() logging.info('CANCELLED SCHEDULED WORK') except Exception as e: logging.error(e) @classmethod def concurrent(cls, callback_function: AnyFunction): @wraps(callback_function) def wrapper(self, *args, **kwargs): if self.worker: return self.worker.submit(callback_function, self, *args, **kwargs) else: return callback_function(self, *args, **kwargs) return wrapper def enqueue_sending_message(self, function, *args, **kwargs) -> Future: return self.message_worker.submit(function, *args, **kwargs) def load_plugins(self) -> None: for module_name in self.plugin_config.keys(): self.load_plugin(module_name) @staticmethod def load_plugin(module_name: str) -> None: try: if module_name in sys.modules.keys(): imp.reload(sys.modules[module_name]) else: importlib.import_module(module_name) except Exception as e: logging.warning('Failed to load %s. %s. Skipping.' % (module_name, e)) else: logging.info('Loaded plugin. %s' % module_name) def respond(self, user_key, user_input) -> Union[RichMessage, str]: user_context = self.user_context_map.get(user_key, None) ret = None error = [] if user_context: # User is in the middle of conversation if user_input == '.abort': # If user wishes, abort the current conversation, and remove # context data. self.user_context_map.pop(user_key) return 'Abort current conversation' # Check if we can proceed conversation. If user input is irrelevant # return help message. option = next( (o for o in user_context.input_options if o.match(user_input)), None) if option is None: return user_context.help_message try: config = self.plugin_config.get(option.next_step.__module__, {}) ret = option.next_step(CommandMessage(original_text=user_input, text=user_input, sender=user_key), config) # Only when command is successfully executed, remove current # context. To forcefully abort the conversation, use ".abort" # command self.user_context_map.pop(user_key) except Exception as e: error.append((option.next_step.__name__, str(e))) else: # If user is not in the middle of conversation, see if the input # text contains command. command = self.find_command(user_input) if command is None: # If it doesn't match any command, leave it. return try: text = re.sub(r'{0}\s+'.format(command.name), '', user_input) ret = command.execute(CommandMessage(original_text=user_input, text=text, sender=user_key)) except Exception as e: error.append((command.name, str(e))) if error: logging.error('Error occurred. ' 'command: %s. input: %s. error: %s.' % ( error[0][0], user_input, error[0][1] )) return 'Something went wrong with "%s"' % user_input elif not ret: logging.error('command should return UserContext or text' 'to let user know the result or next move') return 'Something went wrong with "%s"' % user_input elif isinstance(ret, UserContext): self.user_context_map[user_key] = ret return ret.message else: # String or RichMessage return ret def find_command(self, text: str) -> Optional[Command]: return next((c for c in self.commands if text.startswith(c.name)), None) @property def schedules(self) -> OrderedDict: return self.__schedules.get(self.__class__.__name__, OrderedDict()) @classmethod def schedule(cls, name: str) -> Callable[[CommandFunction], None]: def wrapper(func: CommandFunction) -> None: @wraps(func) def wrapped_function(*args, **kwargs) -> str: return func(*args, **kwargs) # Register only if bot is instantiated. self = cls.__instances.get(cls.__name__, None) if self: config = self.plugin_config.get(func.__module__, {}) if config: # If command name duplicates, update with the later one. # The order stays. command = Command(name, wrapped_function, func.__module__, config) try: # If command is already registered, updated it. idx = [c.name for c in cls.__schedules[cls.__name__]] \ .index(command) cls.__schedules[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__schedules[cls.__name__].append(command) else: logging.warning( 'Missing configuration for schedule job. %s. ' 'Skipping.' % func.__module__) # To ease plugin's unit test return wrapped_function return wrapper def add_schedule_jobs(self, commands: Sequence[Command]) -> None: for command in commands: self.add_schedule_job(command) @property def commands(self) -> OrderedDict: return self.__commands.get(self.__class__.__name__, []) @classmethod def command(cls, name: str) -> Callable[[CommandFunction], CommandFunction]: def wrapper(func: CommandFunction) -> CommandFunction: @wraps(func) def wrapped_function(*args, **kwargs) -> Union[str, UserContext]: return func(*args, **kwargs) # Register only if bot is instantiated. self = cls.__instances.get(cls.__name__, None) if self: config = self.plugin_config.get(func.__module__, {}) # If command name duplicates, update with the later one. # The order stays. command = Command(name, func, func.__module__, config) try: # If command is already registered, updated it. idx = [c.name for c in cls.__commands[cls.__name__]] \ .index(command) cls.__commands[cls.__name__][idx] = command except ValueError: # Not registered, just append it. cls.__commands[cls.__name__].append(command) # To ease plugin's unit test return wrapped_function return wrapper