A Look at Igor's Internals

By (7 minutes read)

Over the past couple of weeks I’ve written several articles about the things that support Igor, from deployments to installation, but I haven’t written much about how it actually works yet. This article aims to rectify that.

Basic structure

The idea behind Igor is already explained so I won’t go into that again. Similarly, in this presentation I explain some of how it works, but it’s good to have a written version of this as well so let me do that first.

Igor itself is designed to be as lightweight as possible, and let all the real work be done by its plugins. That means that the basic structure of the application itself is quite simple and straightforward. The plugins are all built-in (although I’m exploring ways to allow for separate plugins), but treated as their own entity. I’m showing some code snippets below, but feel free to check out the complete (and current) source in GitHub.

Because Lambda doesn’t support Go natively (yet), Igor uses a NodeJS wrapper to receive the data. This wrapper simply passes the contents it receives along as an argument to a call to the application binary. My knowledge of NodeJS and how to deal with child processes in there is very limited, so it’s very possible this can be improved.

var child_process = require('child_process');

exports.handler = function(event, context) {
  var proc = child_process.spawn('./main', [JSON.stringify(event)], { stdio: [process.stdin, 'pipe', 'pipe'] });

  proc.stdout.on('data', function(line){
    var msg = JSON.parse(line);
    context.succeed(msg);
  })

  proc.stderr.on('data', function(line){
    var msg = new Error(line)
    context.fail(msg);
  })

  proc.on('exit', function(code){
    console.error('exit: %s', code)
    context.fail("No results")
  })
}

Inside the binary, this is then naturally caught by the main() function. Because it’s good practice, and as I’m hopeful that at some point Lambda will support Go, this is kept simple and will only translate what it receives into a struct1. That way when Lambda has native support I will only need to make the changes in there to ensure it can run. The only other things that main does is pass this struct to the handle() function and print the output of that call.

func main() {
	buf := new(bytes.Buffer)
	args := os.Args
	event := []byte(args[1])

	body := body{}
	json.Unmarshal(event, &body)

	response := handle(body)
	responseString, _ := json.Marshal(response)
	fmt.Fprintf(buf, "%s", responseString)
	buf.WriteTo(os.Stdout)
}

Handle first loads the general configuration, does all the validation work (currently limited to checking if the right Slack key was provided), and then tries to determine the response. Slack expects that certain characters are escaped, so this is handled as the very last step before the response is returned.

func handle(body body) slack.Response {
	request := slack.LoadRequestFromQuery(body.Body)
	config, err := config.GeneralConfig()
	if err != nil {
		response := slack.SomethingWrongResponse(request)
		response.Escape()
		return response
	}
	response := slack.Response{}
	if !request.Validate(config) {
		response = slack.ValidationErrorResponse()
	} else {
		response = determineResponse(request, config)
	}
	response.Escape()
	return response
}

The determineResponse() function loops over the enabled plugins, and tries to find a match for the command that it received. If it doesn’t find a match, it will then return a NothingFoundResponse. This is just a standard response with a message that will be shown in Slack to indicate there was no match.

Additionally, it will return an error message if something actually went wrong with one of the plugins. However, because it might not always be relevant, it will only return that error if there was no match. This is to ensure that when a plugin that isn’t called fails for some reason, it won’t interfere with the actual call.

func determineResponse(request slack.Request, config config.Config) slack.Response {
	pluginlist := plugins.GetPlugins(config)
	hasError := false
	for _, value := range pluginlist {
		response, err := value.Work(request)
		if err == nil {
			return response
		}
		switch err.(type) {
		case *plugins.NoMatchError:
		default:
			// Something actually went wrong with one of the plugins,
			// return that something went wrong if nothing matches
			// Don't send the actual message though
			hasError = true
		}
	}
	if hasError {
		return slack.SomethingWrongResponse(request)
	}

	return slack.NothingFoundResponse(request)
}

Looking at the above code, you may notice that it doesn’t specifically check if a plugin can handle a command. Instead it tells the plugin to do the work, and return the result if it’s capable of handling it. I went for this method at the start because it’s faster, but it’s possible that this will change in the future as an extra check might have some other benefits.

The plugins

After all that, it’s pretty obvious that most of the really interesting work will be in the plugins. So, let’s follow the steps as they are taken in the code. That means we’ll first have a look at the GetPlugins() function.

GetPlugins is, once again, fairly simple in the way it works. We define a map containing the plugins. These plugins are already instantiated but, other than potentially reading some configuration, haven’t done anything yet. When the configuration is read it’s possible for the instantiated plugin to return an error. As I haven’t decided yet how to deal with these, they’re just ignored for now. Yes, I know that’s not a good thing to do.

In case you wonder about the provided, but unused, Config struct, that is because the below snippet doesn’t show the handling of whitelisting and blacklisting of plugin defined in the configuration.

func GetPlugins(config config.Config) map[string]IgorPlugin {
	plugins := make(map[string]IgorPlugin)
	plugins["help"] = Help()
	plugins["weather"], _ = Weather()
	plugins["tumblr"], _ = RandomTumblr()
	plugins["status"] = Status()

	return plugins
}

Each of these plugins have to implement the IgorPlugin interface. This is a fairly simple interface, with only 4 methods defined.

type IgorPlugin interface {
	Work(slack.Request) (slack.Response, error)
	Describe() map[string]string
	Name() string
	Description() string
}

The Work method is the main one, it handles all the tasks that the Plugin can handle and matches the provided keywords to it. Describe needs to give a map of functionality descriptions and keywords for information purposes (specifically, to be used by the help command).

The other two are more boilerplate. Name provides a short name for the plugin that config items can be matched against and Description is for a global description of the plugin.

Looking back at GetPlugins(), we can see that it calls functions to instantiate the plugins. The goal of these functions is to return an IgorPlugin implementing struct. This can be a very simple struct like for the HelpPlugin.

func Help() IgorPlugin {
	plugin := HelpPlugin{
		name:        "help",
		description: "I provide help with the following commands",
	}
	return plugin
}

Or like the WeatherPlugin that also needs to parse data from the configuration.

func Weather() (IgorPlugin, error) {
	pluginName := "weather"
	pluginConfig, err := parseWeatherConfig()
	if err != nil {
		return WeatherPlugin{}, err
	}
	description := fmt.Sprintf("Igor provides weather information for the city you specify. If no city is specified, the default city (%s) is used.", pluginConfig.DefaultCity)
	plugin := WeatherPlugin{
		name:        pluginName,
		Source:      "http://api.openweathermap.org/data/2.5/",
		description: description,
		Config:      pluginConfig,
	}
	return plugin, nil
}

Either way, the instantiation shouldn’t do anything that interacts with 3rd party services or tools. Doing so would slow down Igor, potentially without reason as that plugin might not be called.

Let’s have a look at the Work() method for one of these plugins. I’ll use the WeatherPlugin as an example, but they all work similarly.

func (plugin WeatherPlugin) Work(request slack.Request) (slack.Response, error) {
	response := slack.Response{}
	if len(request.Text) >= 7 && request.Text[:7] == "weather" {
		response, err := plugin.handleWeather(request)
		return response, err
	} else if len(request.Text) >= 8 && request.Text[:8] == "forecast" {
		response, err := plugin.handleForecast(request)
		return response, err
	}

	return response, CreateNoMatchError("Nothing found")
}

As defined in the IgorPlugin interface, Work() expects a slack.Request struct and returns both a slack.Response struct and error. Going through the code, you can see that it tries to find a match to the Text from the request. If it finds a match, great, it will pass it along to a separate function that will handle the rest. If it doesn’t find a match, it simply returns the empty response and a NoMatchError.

From this point, the results are simply returned along the chain and Slack will receive the result (or lack of results).

If you want to have a look at the rest of the code, feel free to do so on Igor’s GitHub page. Additionally, if you have any questions or ideas for improvements don’t hesitate to mention them either here or on GitHub. I’m always looking to improve Igor.


  1. In the case of server mode, required for running it in Docker, this is done by running a server that does the same thing. The code for this is also in the main() function, but not shown in the snippet. ↩︎

comments powered by Disqus