def __init__(self): self.__validate() self.logger = Logger(self.__class__.__name__).get() self.cache = None if not self.is_destructive() and self.use_cache(): ttl = self.cache_ttl() max_items = self.max_items_in_cache() self.logger.debug("Created {} command cache (maxitems={}, ttl={})" .format(self.name(), max_items, ttl)) self.cache = TTLCache(max_items, ttl)
def __init__(self, token=None, command=None, requires_token=False, is_destructive=True): config = Config.get() self.__logger = Logger(__name__).get() if token: self.__token = token else: self.__token = config.active_workspace_token() self.__requires_token = requires_token if not command else command.requires_token() self.__is_destructive = is_destructive if not command else command.is_destructive()
class Registrar: """Registrar keeps commands that have been registered with it.""" def __init__(self): self.__commands = {} # command name -> command instance self.logger = Logger(self.__class__.__name__).get() def register(self, command): """Registers a command by name.""" if command.name() in self.__commands: raise ValueError("Command already registered: {}".format(command.name())) self.__commands[command.name()] = command def find(self, name): """Lookup command by name.""" if name in self.__commands: return self.__commands[name] return None def action(self, name, args=None): """Convenience method to action command if name is found.""" cmd = self.find(name) if cmd: cmd.action(args) else: self.logger.debug("Could not find and action command: {}".format(name)) def count(self): """Returns the amount of commands registered.""" return len(self.__commands) def commands(self): """Returns all registered command instances.""" return self.__commands.values() def names(self): """Returns all registered command names.""" return self.__commands.keys() def get_completer(self, command): command = command.strip().lower() instance = self.find(command) if not instance: return None return instance.make_completer()
def verify_token(token): try: return SlackAPI(token=token, requires_token=True, is_destructive=False).post("auth.test") except Exception as ex: Logger(__name__).get().warning(ex) return None
def make_parser(self): parser = ArgumentParser(prog=self.name(), description=self.description()) parser.add_argument("-l", "--level", choices=Logger.level_names(), help="Set another log level.") return parser
def workspace_token_prompt(msg="Input workspace token: "): logger = Logger(__name__).get() config = Config.get() workspace = "" token = "" while True: token = prompt(msg, is_password=True).strip() if len(token) == 0: continue data = verify_token(token) if not data: continue workspace = data["team"] if workspace in config.workspaces(): logger.warning("Workspace of token already exists: {}".format(workspace)) continue return (workspace, token)
def __init__(self): """Get instance of Config via Config.get().""" if not Config.__instance: self.__logger = Logger(self.__class__.__name__).get() # Set default values. self.reset() # Load from file if it exists. try: self.load() except Exception as ex: self.__logger.debug("Config does not exist: {}\n{}".format( self.file_path(), ex)) # Assign singleton instance. Config.__instance = self
def action(self, args=None): if args.level: level = Logger.level_from_name(args.level) self.logger.debug("Set log level to: {}".format(args.level)) config = Config.get() config.set_log_level(level) config.save() else: level = logging.getLevelName(self.logger.getEffectiveLevel()) self.logger.info("Log level: {}".format(level))
class SlackAPI: """Encapsulates sending requests to the Slack API and getting back JSON responses.""" def __init__(self, token=None, command=None, requires_token=False, is_destructive=True): config = Config.get() self.__logger = Logger(__name__).get() if token: self.__token = token else: self.__token = config.active_workspace_token() self.__requires_token = requires_token if not command else command.requires_token() self.__is_destructive = is_destructive if not command else command.is_destructive() def __check_read_only_abort(self, method): if self.__is_destructive and Config.get().read_only(): raise SlackAPIException("Not executing '{}' due to read-only mode!".format(method)) def post(self, method, args={}): """Send HTTP POST using method, as the part after https://slack.com/api/, and arguments as a dictionary of arguments.""" self.__check_read_only_abort(method) url = "https://slack.com/api/{}".format(method) if self.__requires_token: args["token"] = self.__token response = requests.post(url, data=args) if response.status_code != 200: raise SlackAPIException("Unsuccessful API request: {} (code {})\nReason: {}\nResponse: {}" .format(response.url, response.status_code, response.reason, response.text)) data = response.json() if "ok" not in data: raise SlackAPIException("Unsuccessful API request: {}\nInvalid response: {}" .format(response.url, data)) if not data["ok"]: error = "" if "error" in data: error = data["error"] raise SlackAPIException("Unsuccessful API request: {}\nError: {}".format(response.url, error), error) return data def download_file(self, file_id, folder): """Download file via ID to a folder. File IDs can be retrieved using the `files.list' command. Private files use Bearer authorization via the token.""" if not os.path.exists(folder): os.makedirs(folder, exist_ok=True) headers = {} file_info = self.post("files.info", {"file": file_id})["file"] if file_info["is_public"]: url = file_info["url_download"] else: url = file_info["url_private_download"] headers["Authorization"] = "Bearer {}".format(self.__token) self.__logger.debug("Downloading {} to {}".format(url, folder)) res = requests.get(url, stream=True, headers=headers) if res.status_code != 200: raise SlackAPIException("Unsuccessful API request: {} (code {})\nReason: {}" .format(res.url, res.status_code, res.reason)) file_name = os.path.join(folder, file_info["name"]) self.__logger.debug("Writing to disk {} -> {}".format(url, file_name)) with open(file_name, "wb") as f: for chunk in res.iter_content(1024): f.write(chunk) return file_name
class SlackAPI: """Encapsulates sending requests to the Slack API and getting back JSON responses.""" def __init__(self, token=None, command=None, requires_token=False, is_destructive=True): config = Config.get() self.__logger = Logger(__name__).get() self.__url = "https://slack.com/api/{}" self.__cache = None if not command else command.cache self.__requires_token = requires_token if not command else command.requires_token( ) self.__is_destructive = is_destructive if not command else command.is_destructive( ) if token: self.__token = token else: self.__token = config.active_workspace_token() def __check_read_only_abort(self, method): """Returns exception to avoid sending requests to Slack API. If the command is marked as destructive or the REPL is in read-only mode requests to Slack API will not be sent. Arguments: method {str} -- Slack API method name Raises: SlackAPIException """ if self.__is_destructive and Config.get().read_only(): raise SlackAPIException( "Not executing '{}' due to read-only mode!".format(method)) def post(self, method, args={}): """Send HTTP POST request to Slack API. Arguments: method {str} -- Slack API method name Keyword Arguments: args {dict} -- Arguments required by Slack API method (default: {{}}) Returns: dict -- Slack API response """ self.__check_read_only_abort(method) url = self.__url.format(method) if self.__requires_token: args["token"] = self.__token # Check for cached request cache_key = self.__generate_cache_key(url, args) cache_value = self.__get_cached_value(cache_key) if cache_value is not None: return cache_value response = requests.post(url, data=args) self.__validate_response(response) json_response = response.json() if cache_key is not None: self.__update_cache(cache_key, json_response) return json_response def get(self, method, args={}): """Send HTTP GET request to Slack API. Arguments: method {string} -- Slack API method name Keyword Arguments: args {dict} -- Arguments required by Slack API method (default: {{}}) Returns: dict -- Slack API response """ self.__check_read_only_abort(method) url = self.__url.format(method) if self.__requires_token: args["token"] = self.__token response = requests.get(url, params=args) self.__validate_response(response) return response.json() def download_file(self, file_id, folder): """Download file via ID to a folder. File IDs can be retrieved using the `files.list' command. Private files use Bearer authorization via the token.""" if not os.path.exists(folder): os.makedirs(folder, exist_ok=True) headers = {} file_info = self.post("files.info", {"file": file_id})["file"] if file_info["is_public"]: url = file_info["url_download"] else: url = file_info["url_private_download"] headers["Authorization"] = "Bearer {}".format(self.__token) self.__logger.debug("Downloading {} to {}".format(url, folder)) res = requests.get(url, stream=True, headers=headers) if res.status_code != 200: raise SlackAPIException( "Unsuccessful API request: {} (code {})\nReason: {}".format( res.url, res.status_code, res.reason)) file_name = os.path.join(folder, file_info["name"]) self.__logger.debug("Writing to disk {} -> {}".format(url, file_name)) with open(file_name, "wb") as f: for chunk in res.iter_content(1024): f.write(chunk) return file_name def __validate_response(self, response): """Check Slack API response for errors Arguments: response {requests.Response} -- [description] Raises: SlackAPIException """ if response.status_code != 200: raise SlackAPIException( "Unsuccessful API request: {} (code {})\nReason: {}\nResponse: {}" .format(response.url, response.status_code, response.reason, response.text)) data = response.json() if "ok" not in data: raise SlackAPIException( "Unsuccessful API request: {}\nInvalid response: {}".format( response.url, data)) if not data["ok"]: error = "" if "error" in data: error = data["error"] raise SlackAPIException( "Unsuccessful API request: {}\nError: {}".format( response.url, error), error) def __get_cached_value(self, key): """Get value from cache given the hash key""" if self.__cache is None or key is None: return None val = self.__cache.get(key) if val is None: self.__logger.debug("Cache miss {}".format(key)) else: self.__logger.debug("Cache hit {}".format(key)) return val def __update_cache(self, key, resp): """Update cache value given a key""" self.__logger.debug("Updating cache: {}".format(key)) self.__cache[key] = resp def __generate_cache_key(self, url, params): """Returns hash of the url and params""" if self.__cache is None: return None m = hashlib.sha256() m.update(url.encode("utf-8")) m.update(json.dumps(params).encode("utf-8")) return m.hexdigest()
def __init__(self): self.__commands = {} # command name -> command instance self.logger = Logger(self.__class__.__name__).get()
class Command(ABC): """Command encapsulates a Slacker command with a name, description, help text, and the action to perform. """ def __init__(self): self.__validate() self.logger = Logger(self.__class__.__name__).get() self.cache = None if not self.is_destructive() and self.use_cache(): ttl = self.cache_ttl() max_items = self.max_items_in_cache() self.logger.debug("Created {} command cache (maxitems={}, ttl={})" .format(self.name(), max_items, ttl)) self.cache = TTLCache(max_items, ttl) @abstractmethod def name(self): """Returns the name of the command. This is the actual command, like 'download'.""" pass @abstractmethod def description(self): """Returns the description of the command.""" pass def requires_token(self): """Whether or not the method requires the active workspace's token to function. It defaults to False to not send the token if not needed.""" return False def is_destructive(self): """Whether or not the method is destructive, modifies state, or sends messages. It defaults to True to require attention if the command is viable in read-only mode.""" return True def use_cache(self): """Whether the command API call should be cached and looked up later instead of sending and HTTP request to the Slack API. The cache should only be used on non destructive commands""" return False def cache_ttl(self): """Controls the TTL on cache keys for the command""" return 60 def max_items_in_cache(self): """ In addition to TTL the cache implements LRU (least recently used). This controls the number of items allowed in the cache""" return 5 def __validate(self): """Validates that the command is valid and conforming with the requirements.""" derive_cls = type(self).__mro__[0] if not COMMAND_NAME_REGEX.fullmatch(self.name()): raise ValueError("Command name is invalid '{}' in {}".format(self.name(), derive_cls)) def make_parser(self): """Override to define slacker.commands.argument_parser.ArgumentParser.""" return None def make_completer(self): """Creates a word completer from arguments parser if defined.""" parser = self.make_parser() if not parser: return None return WordCompleter(parser.words(), meta_dict=parser.meta(), ignore_case=True) def parse_args(self, args): """Parse arguments from a list of strings. The output can be given to action().""" parser = self.make_parser() if not parser: return None return parser.parse_args(args) @abstractmethod def action(self, args=None): """Executes the action of the command with optional arguments parsed via parse_args().""" pass @staticmethod def find_all(): """Finds all command classes deriving from Command.""" cmds = set() for child in Command.__subclasses__(): if child not in cmds: cmds.add(child) return cmds def slack_api_post(self, method, args={}): return SlackAPI(command=self).post(method, args) def slack_api_download_file(self, file_id, folder): return SlackAPI(command=self).download_file(file_id, folder)
def ask_abort(msg="Do you want to abort?", abort_on_yes=True): if confirm(msg.strip() + " ") == abort_on_yes: Logger(__name__).get().info("Aborting!") raise AbortConfirmation()
def set_log_level(self, level): if level not in Logger.levels(): raise ValueError("Invalid log level: {}".format(level)) self.__log_level = level Session.get().set_log_level(level) Logger.set_level(level)
class Config: __instance = None def __init__(self): """Get instance of Config via Config.get().""" if not Config.__instance: self.__logger = Logger(self.__class__.__name__).get() # Set default values. self.reset() # Load from file if it exists. try: self.load() except Exception as ex: self.__logger.debug("Config does not exist: {}\n{}".format( self.file_path(), ex)) # Assign singleton instance. Config.__instance = self @staticmethod def get(): if not Config.__instance: Config() return Config.__instance def repl_prefix(self): return self.__repl_prefix def set_repl_prefix(self, repl_prefix): self.__repl_prefix = repl_prefix def active_workspace(self): """Active workspace name, if defined.""" return self.__active_workspace def set_active_workspace(self, name): if name not in self.__workspaces: raise ValueError( "Cannot set unknown workspace active: '{}'".format(name)) self.__active_workspace = name def add_workspace(self, name, token): if name in self.__workspaces: raise ValueError( "Cannot add workspace '{}' because it already exists!".format( name)) self.__workspaces[name] = token def remove_workspace(self, name): if name not in self.__workspaces: raise ValueError( "Cannot remove unknown workspace: '{}'".format(name)) if self.active_workspace() == name: raise ValueError( "Cannot remove active workspace: '{}'".format(name)) del (self.__workspaces[name]) def workspaces(self): return list(self.__workspaces.keys()) def workspace_token(self, name): if name not in self.__workspaces: raise ValueError( "Cannot get token for unknown workspace: '{}'".format(name)) return self.__workspaces[name] def active_workspace_token(self): if not self.active_workspace(): raise ValueError("No workspace is active!") return self.workspace_token(self.active_workspace()) def set_log_level(self, level): if level not in Logger.levels(): raise ValueError("Invalid log level: {}".format(level)) self.__log_level = level Session.get().set_log_level(level) Logger.set_level(level) def log_level(self): return self.__log_level def read_only(self): return self.__read_only def set_read_only(self, enable): self.__read_only = enable def file_path(self): return os.path.expanduser("~/.slacker") def safe_dict(self): """Returns a safe dictionary of current values excluding any tokens.""" return { "repl_prefix": self.repl_prefix(), "workspaces": self.workspaces(), "active_workspace": self.active_workspace(), "log_level": self.log_level(), "read_only": self.read_only() } def save(self): data = { "repl_prefix": self.repl_prefix(), "workspaces": self.__workspaces, "active_workspace": self.active_workspace(), "log_level": self.log_level(), "read_only": self.read_only() } with open(self.file_path(), "w") as fp: json.dump(data, fp, indent=2) self.__logger.debug("Saved config to: {}".format(self.file_path())) def load(self): with open(self.file_path(), "r") as fp: data = json.load(fp) if "repl_prefix" in data: self.set_repl_prefix(data["repl_prefix"]) if "workspaces" in data: self.__workspaces = data["workspaces"] if "active_workspace" in data: self.__active_workspace = data["active_workspace"] if "log_level" in data: self.set_log_level(data["log_level"]) if "read_only" in data: self.set_read_only(data["read_only"]) self.__logger.debug("Loaded config from: {}".format( self.file_path())) def reset(self): """Resets all values to default.""" self.set_repl_prefix("> ") self.__workspaces = {} # Workspace name -> API token self.__active_workspace = None self.__log_level = logging.INFO self.__read_only = False
def signal_handler(signal, _callback, *args): """Handle signal interrupts.""" Logger(__name__).get().info("Caught sig.. {}".format(signal)) sys.exit(0)
def start_slacker(): signal.signal(signal.SIGINT, signal_handler) (args, cmd_args) = parse_args() session = Session.get() session.set_quiet_mode(args.quiet) global config config = Config.get() session.set_log_level(config.log_level()) global slacker_logger slacker_logger = Logger(__name__).get() slacker_logger.debug("Starting Slacker...") if args.verbose: session.set_log_level(logging.DEBUG) Logger.set_level(logging.DEBUG) slacker_logger.debug("Verbose mode setting debug session log level.") if args.check: check() return if args.init: init() if not config.active_workspace(): slacker_logger.error("No workspace active!") slacker_logger.error( "Run slacker with --init to interactively create a workspace and config " "file.") return reg = Registrar() for cmd in Command.find_all(): reg.register(cmd()) if reg.count() == 0: slacker_logger.error("No commands found!") sys.exit(-1) try: if not args.no_tests: reg.action("api.test") reg.action("auth.test") except Exception as ex: slacker_logger.error(str(ex)) slacker_logger.warning( "Make sure you have internet access and verify your tokens!") sys.exit(-1) if cmd_args: # The arguments are already parsed into a list so pass it on! process(cmd_args, reg) return completer = build_prompt_completer(reg) in_memory_history = InMemoryHistory() while True: line = readline(completer, in_memory_history) if line is None: break elif not line: continue process(line, reg)
def __init__(self): self.__validate() self.logger = Logger(self.__class__.__name__).get()