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 - `npm` and `nodejs` NB: This how-to refers to the binary `node` - this may be `nodejs` depending on your distro. # Setup a new project Create a new directory and run `npm init` to generate a `package.json` file after answering some questions. Run `npm install matrix-appservice-bridge` to install the bridge library, `request` to make sending HTTP requests easier and `matrix-appservice` to install the AS library. Create a file `index.js` which we'll use to write logic for the bridge. ``` $ npm init $ npm install matrix-appservice-bridge $ npm install 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](https://ngrok.com/) 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: ```javascript const http = require("http"); const qs = require("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(JSON.stringify({})); 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 ×tamp=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: ```javascript 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: ```yaml 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: ```javascript 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: ```javascript 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: ```javascript 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 room. That's it! # Full source ```javascript // Usage: // node index.js -r -u "http://localhost:9000" # remember to add the registration! // node index.js -p 9000 const http = require("http"); const qs = require('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`: ```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. ```javascript 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.