Mattermost Apps: All the Moving Parts

In the first part of this series, we outlined the basic steps you need to take in order to begin setting up a developer environment, installing your first app, and making use of the first commands. 

In this installment, we’ll aim to answer the most common questions about what was installed, how it works, and how the various pieces interact with each other. Additionally, this post will lay out all the different components that are involved in the overall lifecycle of a Mattermost app. 

The Mattermost Dev Environment

The primary artefact is a functional Mattermost instance. This is one of the different items that is spun up as a Docker container as part of the app development process. 

This Mattermost instance is a pre-compiled Docker image, which relieves a developer of any complexity that might arise from setting up a sample environment. This particular image is set up such that it consumes fewer resources when compared to a full-blown installation. Obviously, this particular method of installation is not recommended for anything but app development and basic testing uses.

Functionally, it is on par with any other Mattermost instance. It combines both a Mattermost webapp and the server components. Additionally, it comes installed with a few components. 

The Manifest File

A manifest file is required for the installation of the app inside a Mattermost instance. It is a declarative artefact that provides all the details required for the installation process to kick off on the Mattermost instance. 

The full details of the manifest file — including fields, formatting, etc. — are available in the documentation page for the app integrations. 

You need to make sure that the manifest file is formatted as a valid JSON file. To give you a better idea of what to expect, let’s take a look at an example and learn the basic fields contained within the manifest:

{
    	"app_id": "first-app",
    	"display_name": "Test App",
    	"app_type": "http",
    	"root_url": "http://localhost:9090",
    	"requested_permissions": [
   	  "act_as_bot"
     ],
    	"requested_locations": [
   	  "/channel_header",
   	  "/command"
     ]
}

In the example manifest file illustrated above, the following fields are present:

ParameterExplanation
app_idA unique (alphanumeric) identifier for the app
display_nameA string that will be used as the display name of the app
app_typeIdentifies the type of hosting used by the app. Supports one of two types http or aws_lambda
root_urlThe URL of the web service backend that the app will connect with
requested_permissionsThe access levels and permissions that an app can request for. Some examples include acting as a bot or an admin, and requesting OAuth or webhooks
requested_locationsSpecifies which parts of the interface the app can attach itself to. Examples include channel header, slash command, post menu, etc.

The /apps install Command

Now, let’s take a quick look at the installation command, which will make the app available on a Mattermost instance.

The /install command ensures that the appropriate permissions are granted and the right calls are made to the Mattermost server. The corresponding AWS Lambda function is called (if using the lambda argument).

Here is the command which is specified in the first part of this tutorial.

/apps install http http://node_app:4000/manifest.json

The app is installed using a slash command. The /apps command is built in and allows you to manage the full lifecycle of the app.

The install command fetches the manifest file from the URL parameter provided and registers the app. Based on the metadata in the manifest file, permissions are requested and locations are created. Tokens and app secrets are exchanged as needed.

The http parameter indicates that the app will make use of HTTP methods to connect and transfer payloads. Finally, the URL is the endpoint that will supply the manifest file needed for the installation. 

Backing Apps

Irrespective of the type of installation (i.e., http or aws_lambda), there will be web services backing the app installed on Mattermost. This is where all the desired functionality will be sandboxed. Interacting with the app will trigger remote calls, either using HTTP or Lambda functions. 

Building on the first part of this tutorial, let’s take a look at a single example app written in a couple of different languages. This trivial example will display a message using a Bot account when invoked using the button on the channel header or the slash command. 

A JavaScript Example Application

The first function that is required is one that returns a manifest file. The app is installed based on the metadata that is available in this file. In this example, a JSON is constructed inline and returned. Alternatively, this can be parameterized to return an actual JSON file, too. When an http connection hits the route named manifest.json, the required parameters are exchanged:

app.get('/manifest.json', (req, res) => {
    res.json({
        app_id: 'node-example',
        display_name: 'I'm an App!',
        homepage_url: 'https://github.com/mattermost/mattermost-plugin-apps',
        app_type: 'http',
        icon: 'icon.png',
        root_url: 'http://localhost:8080',
        requested_permissions: [
            'act_as_bot',
        ],
        requested_locations: [
            '/channel_header',
            '/command',
        ],
    });
});

The next call is made to the bindings route. This is an internal call made during the installation process. The bindings route creates a path for communication between the Mattermost UI and the application that backs the integration. This is also a one-time call, which makes a modification to the Mattermost instance. After this call, the instance is configured with the information it needs about which calls to make when a user interacts with the app.

Within the binding route, a certain number of locations can be configured. Locations denote specific parts of the Mattermost UI. The three types of locations available are: (i) Channel Header (ii) Command and (iii) Post Menu.

app.post('/bindings', (req, res) => {
	res.json({
    	type: 'ok',
    	data: [
        	{
            	location: '/channel_header',
            	bindings: [
                	{
                	    	location: 'send-button',
                	    	icon: 'icon.png',
                	    	label: 'send hello message',
                	    	submit: {
                	        	path: '/send',
                	    	},
                	},
            	],
        	},
        	{
            	location: '/command',
            	bindings: [
                	{
                    		icon: 'icon.png',
                    		label: 'node-example',
                    		description: 'Example app written with Node.js',
                    		hint: '[send]',
                    		bindings: [
                        	{
                           	 	location: 'send',
                           	 	label: 'send',
                           	 	{
                             			title: "I'm a form!",
                                		icon: 'icon.png',
                                		fields: [
                                    		{
                                        		type: 'text',
                                        		name: 'message',
                                        		label: 'message',
                                        		position: 1,
                                    		},
                                		],
                                		submit: {
                                    			path: '/send/submit',
                                		},
                            		},
                        	},
                    		],
                	},
            		],
        	},
    	],
	});
});

The actual functions that are called when a user interacts with the Mattermost UI elements of apps are the next parts that need to be included in the applications that back up the integrations. These functions handle webhooks and any events that are thrown during interactions. The first function we will examine is the one that handles the business logic:

app.post('/send/submit', async (req, res) => {
    const call = req.body;

    const botClient = new Client4();
    botClient.setUrl(call.context.mattermost_site_url);
    botClient.setToken(call.context.bot_access_token);

    const formValues = call.values;

    let message = 'Hello, world!';
    const submittedMessage = formValues.message;
    if (submittedMessage) {
        message += ' ...and ' + submittedMessage + '!';
    }

    const users = [
        call.context.bot_user_id,
        call.context.acting_user.id,
    ];

    let channel;
    try {
        channel = await botClient.createDirectChannel(users);
    } catch (e) {
        res.json({
            type: 'error',
            error: 'Failed to create/fetch DM channel: ' + e.message,
        });
        return;
    }

    const post = {
        channel_id: channel.id,
        message,
    };

    try {
        await botClient.createPost(post)
    } catch (e) {
        res.json({
            type: 'error',
            error: 'Failed to create post in DM channel: ' + e.message,
        });
        return;
    }

    const callResponse = {
        type: 'ok',
        markdown: 'Created a post in your DM channel.',
    };

    res.json(callResponse);
});

This particular function does the following:

  1. It creates an object from the request parameter.
  2. Next, it creates a string to be printed on the UI by concatenating a preconfigured string variable with a message passed by the user.
  3. After that, it prepares an array of users, attaches their access tokens, and configures the POST call to be made to the Mattermost server, passes the channels, and then makes the POST call. 
  4. Finally, the response object is populated and returned to the calling function up the call stack. 

Next, let’s create the function that gets invoked every time a user interacts with the app from the UI. This particular example function is called from the channel header:

app.post('/send', (req, res) => {
    res.json({
        type: 'form',
        form: {
            title: 'Hello, world!',
            icon: 'icon.png',
            fields: [
                {
                    type: 'text',
                    name: 'message',
                    label: 'message',
                },
            ],
            submit: {
                path: '/send/submit',
            },
        },
    });
});

This function, when invoked, ultimately calls the send function which handles the business logic. Before it does that, it creates a modal displayed on the UI (if invoked via the channel header). Compare this to when the user interacts with a slash command, which simply gets called and passes on the call to the /send/submit function:

The final function we will illustrate in this example is the static function. In the event that an application or the integration needs to serve up some static assets, it’ll be done using a separate function. The call can be made from the bindings when the app is being installed for the first time. This function will simply serve the static assets on demand:

app.use('/static', express.static('./static'));

A Golang Example 

Unlike the JavaScript example above, the Golang implementation follows a slightly different pattern. It uses a single main program from which all the respective routes are called. The routes and their purposes remain the same. However, a JSON is returned in lieu of request and response objects.

Let’s first examine the main.go file to understand this method in detail.

package main

import (
	_ "embed"
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/mattermost/mattermost-plugin-apps/apps"
	"github.com/mattermost/mattermost-plugin-apps/apps/appsclient"
)

//go:embed icon.png
var iconData []byte

//go:embed manifest.json
var manifestData []byte

//go:embed bindings.json
var bindingsData []byte

//go:embed send_form.json
var formData []byte

const (
	host = "localhost"
	port = 8080
)

func main() {
	// Serve its own manifest as HTTP for convenience in dev. mode.
	http.HandleFunc("/manifest.json", writeJSON(manifestData))

	// Returns the Channel Header and Command bindings for the app.
	http.HandleFunc("/bindings", writeJSON(bindingsData))

	// The form for sending a Hello message.
	http.HandleFunc("/send/form", writeJSON(formData))

	// The main handler for sending a Hello message.
	http.HandleFunc("/send/submit", send)

	// Forces the send form to be displayed as a modal.
	http.HandleFunc("/send-modal/submit", writeJSON(formData))

	// Serves the icon for the app.
	http.HandleFunc("/static/icon.png", writeData("image/png", iconData))

	addr := fmt.Sprintf("%v:%v", host, port)
	fmt.Printf(`hello-world app listening at http://%s`, addr)
	http.ListenAndServe(addr, nil)
}

func send(w http.ResponseWriter, req *http.Request) {
	c := apps.CallRequest{}
	json.NewDecoder(req.Body).Decode(&c)

	message := "Hello, world!"
	v, ok := c.Values["message"]
	if ok && v != nil {
		message += fmt.Sprintf(" ...and %s!", v)
	}
	appsclient.AsBot(c.Context).DM(c.Context.ActingUserID, message)

    json.NewEncoder(w).Encode(apps.CallResponse{
		Type:     apps.CallResponseTypeOK,
		Markdown: "Created a post in your DM channel.",
	})
}

func writeData(ct string, data []byte) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, req *http.Request) {
		w.Header().Set("Content-Type", ct)
		w.Write(data)
	}
}

func writeJSON(data []byte) func(w http.ResponseWriter, r *http.Request) {
	return writeData("application/json", data)
}

As you can see from the file above, after instantiating a few constants for handling data, there are six method calls that follow the pattern of returning a JSON object based on the call that is made. It handles the different events of:

  • Returning a manifest
  • Creating bindings to the UI
  • Creating a modal when the function is invoked from the channel header
  • Responding to an invocation from the slash command
  • Serving static assets

This approach requires three distinct JSON files to be made available in the execution path, adjacent to the main.go program.

First, the manifest.json file. As illustrated in the section above, this file is required during the installation phase of the app. The metadata available here will be used to create the app and install it to the Mattermost instance. The following are the contents of the manifest.json file:

{
	"app_id": "hello-world",
	"display_name": "Hello, world!",
	"app_type": "http",
        "icon": "icon.png",
	"root_url": "http://localhost:8080",
	"requested_permissions": [
		"act_as_bot"
	],
	"requested_locations": [
		"/channel_header",
		"/command"
	]
}

Next is the bindings.json file. It will contain all the details of the locations in the UI where the visual elements of the app need to be placed and the right function calls to bind them to. The use of bindings happens just after an app is installed. After the first call, the instance is configured with the information it needs about the calls to make when a user interacts with the app.

Within the binding.json, a certain number of locations can be configured. Locations denote specific parts of the Mattermost UI. The three types of locations available are: (i) Channel Header (ii) Command, and (iii) Post Menu.   

In this example, you can see that the JSON parameters for the channel header invocation differ from that of the slash command invocation. This is because they provide different ways of interacting with the user. The slash command directly invokes the function that handles the business logic behind the interaction, whereas the invocation from the channel header first displays a modal form. When this is filled and submitted, only then does it invoke (the same) backing function containing the business logic.

{
	"type": "ok",
	"data": [
		{
			"location": "/channel_header",
			"bindings": [
				{
					"location": "send-button",
					"icon": "icon.png",
					"label":"send hello message",
					"call": {
						"path": "/send-modal"
					}
				}
			]
		},
		{
			"location": "/command",
			"bindings": [
				{
					"icon": "icon.png",
					"label": "helloworld",
					"description": "Hello World app",
					"hint": "[send]",
					"bindings": [
						{
							"location": "send",
							"label": "send",
							"call": {
								"path": "/send"
							}
						}
					]
				}
			]
		}
	]
}

The third file required is the send_form.json file. It basically helps configure a UI modal when invoked via the channel header. Apart from displaying a UI element, this file also provides the call to the send function, which will handle the downstream functions to complete an interaction with the form.

{
	"type": "form",
	"form": {
		"title": "Hello, world!",
		"icon": "icon.png",
		"fields": [
			{
				"type": "text",
				"name": "message",
				"label": "message"
			}
		],
		"call": {
			"path": "/send"
		}
	}
}

Using either example application will get the same result in terms of having an application that is backing the app from the Mattermost installation. 

Customizing the User Experience with the Mattermost Apps Frameworks

This post is meant to illustrate the working principles behind setting up and customizing applications to work with apps and integrations installed on a Mattermost instance. The examples provided are meant to act as a starting point to help you understand how to customize a Mattermost instance further. You can connect to any of your applications that support your business logic to enable ChatOps for your teams when using Mattermost.

Additionally, you can browse a gallery of all the integrations in the Marketplace to check out the various ways in which the community is helping extend the core of Mattermost to satisfy different needs. If you’d like help or support, please make use of the Mattermost Community server — specifically the channel for Apps. Once there, you can hop in on the conversation about building, testing, and using the apps you’re building. We hope to see you there!

mm

Ram Iyengar is an engineer by practice and an educator at heart. He enjoys helping engineering teams around the world discover new and creative ways to work.