Skip to content

Latest commit

 

History

History
331 lines (291 loc) · 12.5 KB

HOWTO.md

File metadata and controls

331 lines (291 loc) · 12.5 KB

This How-To will explain how to write a basic Matrix <--> Slack bridge in under 100 lines of code. You should be comfortable with:

  • REST/JSON APIs
  • Webhooks
  • Basic Node.js/JS tasks

You need to have:

  • A working homeserver install
  • Node.js (with NPM)

Note, this how-to refers to the binary of Node.js as node - on some Linux distros this may be called nodejs.

Set up a new project

Create a new directory and run npm init to generate a package.json file after answering some questions. Install bridge library matrix-appservice-bridge and request to make sending HTTP requests easier. Create a file index.js which we'll use to write the logic for the bridge.

$ npm init
$ npm install matrix-appservice-bridge request
$ touch index.js

Slack-to-Matrix

First, we need to create an Outgoing WebHook in Slack (via the Integrations section). This will send HTTP requests to us whenever a Slack user sends something in a Slack channel. We'll monitor the channel #matrix when sending outgoing webhooks rather than trigger words. Set the URL to a publically accessible endpoint for your machine, or use something like ngrok if you're developing. We'll use ngrok, and forward port $PORT.

Variables to remember:

  • Your monitored channel $SLACK_CHAN.

Printing out outbound Slack requests

Open up index.js and write the following:

const http = require("node:http");
const qs = require("node:querystring"); // we will use this later
const requestLib = require("request"); // we will use this later
let bridge; // we will use this later

http.createServer(function(request, response) {
    console.log(request.method + " " + request.url);

    let body = "";
    request.on("data", function(chunk) {
        body += chunk;
    });

    request.on("end", function() {
        console.log(body);
        response.writeHead(200, {"Content-Type": "application/json"});
        response.write("{}");
        response.end();
    });
}).listen($PORT);  // replace me with your actual port number!

Send "hello world" in $SLACK_CHAN and it will print out something like this (pretty-printed):

POST /
token=53cr4t
&team_id=ABC123
&team_domain=yourteamname
&service_id=1234567890
&channel_id=AAABBCC
&channel_name=$SLACK_CHAN
&timestamp=1442409742.000006
&user_id=U3355223E
&user_name=alice
&text=hello+word

We'll be interested in the user_name, text and channel_name.

Registering as an application service

We now want to do a lot more than just print out a POST request. We need to be able to register as an application service, listen and handle incoming Matrix requests and expose a nice CLI to use. Open up index.js and add this at the bottom of the file:

const Cli = require("matrix-appservice-bridge").Cli;
const Bridge = require("matrix-appservice-bridge").Bridge; // we will use this later
const AppServiceRegistration = require("matrix-appservice-bridge").AppServiceRegistration;

new Cli({
    registrationPath: "slack-registration.yaml",
    generateRegistration: function(reg, callback) {
        reg.setId(AppServiceRegistration.generateToken());
        reg.setHomeserverToken(AppServiceRegistration.generateToken());
        reg.setAppServiceToken(AppServiceRegistration.generateToken());
        reg.setSenderLocalpart("slackbot");
        reg.addRegexPattern("users", "@slack_.*", true);
        callback(reg);
    },
    run: function(port, config) {
        // we will do this later
    }
}).run();

This will setup a CLI via the Cli class, which will dump the registration file to slack-registration.yaml. It will register the user ID @slackbot:domain and ask for exclusive rights (so no one else can create them) to the namespace of users with the prefix @slack_. It also generates two tokens which will be used for authentication.

Now type node index.js -r -u "http://localhost:9000" (the URL is the URL that the homeserver will try to use to communicate with the application service) and a file slack-registration.yaml will be produced. In your Synapse install, edit homeserver.yaml to include this file:

app_service_config_files: ["/path/to/slack/bridge/slack-registration.yaml"]

Then restart your homeserver. Your application service is now registered.

Sending messages to Matrix

We need to have a bridge to send messages from, so in the run: function(port, config) method, type the following:

run: function(port) {
    bridge = new Bridge({
        homeserverUrl: "http://localhost:8008",
        domain: "localhost",
        registration: "slack-registration.yaml",
        controller: {
            onUserQuery: function(queriedUser) {
                return {}; // auto-provision users with no additonal data
            },
    
            onEvent: function(request, context) {
                return; // we will handle incoming matrix requests later
            }
        }
    });
    console.log("Matrix-side listening on port %s", port);
    bridge.run(port);
})

This configures the bridge to try to communicate with the homeserver at http://localhost:8008 using the information from the registration file slack-registration.yaml. We now need to use the bridge to send the message we were printing out from Slack earlier. Just like how the Slack room is hard-coded to $SLACK_CHAN, we'll hard-code the room ID to send to. Create a new public room on Matrix, which has the room ID $ROOM_ID.

NB: You can do this as an invite-only room on Matrix, but you MUST invite the Slack AS bridge user (@slackbot:domain) to the room so it can invite virtual Slack users.

Replace the function request.on("end", function(), with the following:

request.on("end", function() {
    const params = qs.parse(body);
    if (params.user_id !== "USLACKBOT") {
        const intent = bridge.getIntent("@slack_" + params.user_name + ":localhost");
        intent.sendText(ROOM_ID, params.text);
    }
    response.writeHead(200, {"Content-Type": "application/json"});
    response.write(JSON.stringify({}));
    response.end();
});

We filter out USLACKBOT to avoid showing duplicate messages when we do the reverse (sending to slack from an inbound webhook). qs.parse is used to convert the POST string into a JSON object. The Intent object obtained from the bridge is scoped to a Slack user ID specified in getIntent. This means that sendText will be sent as the @slack_<user_name>:localhost entity.

Note that if your server_name is not localhost you must change the server part of the user ID in the bridge.getIntent() call.

Then run the application service with node index.js -p 9000 and send a message from Slack. It should then be passed through to the specified matrix room!

Matrix-to-Slack

First, you need to create an Incoming WebHook under the Integrations section. You'll need to remember your allocated webhook url: $WEBHOOK_URL.

Replace the onEvent: function(request, context) function created earlier with:

onEvent: function(request, context) {
    const event = request.getData();
    // replace with your room ID
    if (event.type !== "m.room.message" || !event.content || event.room_id !== $ROOM_ID) {
        return;
    }
    requestLib({
        method: "POST",
        json: true,
        uri: $WEBHOOK_URL, // replace with your url!
        body: {
            username: event.sender,
            text: event.content.body
        }
    }, function(err, res) {
        if (err) {
            console.log("HTTP Error: %s", err);
        }
        else {
            console.log("HTTP %s", res.statusCode);
        }
    });
}

Run the app service with node index.js -p 9000 and send a message to the Matrix room and that message will be relayed to the specified Slack channel. That's it!

Full source

// Usage:
// node index.js -r -u "http://localhost:9000" # remember to add the registration!
// node index.js -p 9000
const http = require("node:http");
const qs = require("node:querystring");
const requestLib = require("request");
let bridge;
const PORT = 9898; // Slack needs to hit this port e.g. use "ngrok 9898"
const ROOM_ID = "!YiuxjYhPLIZGVVkFjT:localhost"; // this room must have join_rules: public
const SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/AAAA/BBBBB/CCCCC";

http.createServer(function(request, response) {
    console.log(request.method + " " + request.url);

    let body = "";
    request.on("data", function(chunk) {
        body += chunk;
    });

    request.on("end", function() {
        const params = qs.parse(body);
        if (params.user_id !== "USLACKBOT") {
            const intent = bridge.getIntent("@slack_" + params.user_name + ":localhost");
            intent.sendText(ROOM_ID, params.text);
        }
        response.writeHead(200, {"Content-Type": "application/json"});
        response.write(JSON.stringify({}));
        response.end();
    });
}).listen(PORT);

const Cli = require("matrix-appservice-bridge").Cli;
const Bridge = require("matrix-appservice-bridge").Bridge;
const AppServiceRegistration = require("matrix-appservice-bridge").AppServiceRegistration;

new Cli({
    registrationPath: "slack-registration.yaml",
    generateRegistration: function(reg, callback) {
        reg.setId(AppServiceRegistration.generateToken());
        reg.setHomeserverToken(AppServiceRegistration.generateToken());
        reg.setAppServiceToken(AppServiceRegistration.generateToken());
        reg.setSenderLocalpart("slackbot");
        reg.addRegexPattern("users", "@slack_.*", true);
        callback(reg);
    },
    run: function(port) {
        bridge = new Bridge({
            homeserverUrl: "http://localhost:8008",
            domain: "localhost",
            registration: "slack-registration.yaml",

            controller: {
                onUserQuery: function(queriedUser) {
                    return {}; // auto-provision users with no additonal data
                },

                onEvent: function(request, context) {
                    const event = request.getData();
                    if (event.type !== "m.room.message" || !event.content || event.room_id !== ROOM_ID) {
                        return;
                    }
                    requestLib({
                        method: "POST",
                        json: true,
                        uri: SLACK_WEBHOOK_URL,
                        body: {
                            username: event.sender,
                            text: event.content.body
                        }
                    }, function(err, res) {
                        if (err) {
                            console.log("HTTP Error: %s", err);
                        }
                        else {
                            console.log("HTTP %s", res.statusCode);
                        }
                    });
                }
            }
        });
        console.log("Matrix-side listening on port %s", port);
        bridge.run(port);
    }
}).run();

Configuration

So far in this example we have hard-coded various items of information that would be considered "configuration"; namely the Slack outbound webhook token and the list of room mappings to bridge. We can use the ConfigValidator to help parse a configuration file at startup time to obtain this information from instead.

Start by defining a schema file that describes what the YAML config file can contain. This is also a YAML file in the JSON Schema format. Store this in a file called slack-config-schema.yaml:

type: object
requires: ["slack_webhook_url"]
properties:
    slack_webhook_url:
        type: string

If we supply the name of this schema file to the constructor of the main Cli object then it will use this to validate a config file that the user passes on the command line. The markup that this config file provides will be parsed and presented as the config parameter to the main run function.

new Cli({
    registrationPath: "slack-registration.yaml",
    generateRegistration: function(reg, callback) {
        ...
    },
    bridgeConfig: {
        schema: "slack-config-schema.yaml"
    },
    run: function(port, config) {
        const slack_webhook_url = config.slack_webhook_url;
        ...

Extensions

  • The code to process the Slack POST request does not include any limits on the upload size.