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
.
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
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
.
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
×tamp=1442409742.000006
&user_id=U3355223E
&user_name=alice
&text=hello+word
We'll be interested in the user_name
, text
and channel_name
.
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.
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!
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!
// 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();
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;
...
- The code to process the Slack POST request does not include any limits on the upload size.