Exemple #1
0
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()
Exemple #2
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)
Exemple #3
0
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
Exemple #4
0
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()
Exemple #5
0
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
Exemple #6
0
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)