π€ Creating a Matrix Message Relay Bot with Deno and WASM
In this post, we see how you can build a Matrix message relay bot API with Deno. Matrix is a secure messaging protocol, which Element and other message apps use. Element has similar features to apps like Telegram. It offers some enhancements over Telegram, such as end-to-end encrypted group chat within rooms. Another difference is that you can use Element to message peers who use other Matrix messaging apps. This is analogous to you being able to use your favourite email app to email contacts who prefer another app.
Although messaging bots today have some quite sophisticated applications, we will keep things simple. Our bot will relay messages via a Deno API to an existing Matrix Element chat room. As an example, you might use this API to let you know each time someone sends a contact form message on one of the sites you are managing. Using the Element smartphone app, you or your team can get the alert even if away from the desk.
What are we Building?
We will create a Deno API to listen for new messages. The messages (sent from our contact form server code, for example) will arrive at the API as REST requests. Under the hood, to interface with the Matrix API, we will use the Rust Matrix SDK. WASM will provide the link between the Rust Matrix code and the Deno serverless app. If that sounds interesting to you, then letβs get going!
βοΈ Creating a Deno Server App
We will start by writing the HTTP server code for the Deno app. The server will listen for POST
HTTP requests on the /matrix-message
route. To get going, create a new directory for the project and in there add a main.ts
file with this content:
import "$std/dotenv/load.ts";
import { serve } from "$std/http/server.ts";
import { Temporal } from "js-temporal/polyfill/?dts";
const port = 8080;
function logRequest(request: Request, response: Response) {
const { method, url } = request;
const { hostname, pathname } = new URL(url);
const dateTime = Temporal.Now.plainDateTimeISO();
const dateTimeString = `${dateTime.toPlainDate().toString()} ${
dateTime.toLocaleString("en-GB", { timeStyle: "short" })
}`;
console.log(
`[${dateTimeString}] ${method} ${hostname}${pathname} ${response.status} ${response.statusText}`,
);
}
const handler = async (request: Request): Promise<Response> => {
const { method, url } = request;
const { pathname } = new URL(url);
if (pathname === "/matrix-message" && method === "POST") {
const body = await request.text();
try {
// const messageSent: boolean = TODO: Call WASM function to send the message here
if (!messageSent) {
const response = new Response("Bad request", { status: 400 });
logRequest(request, response);
return response;
}
} catch (error) {
console.error(`Error sending message: ${error}`);
return new Response("Server error", { status: 500 });
}
const response = new Response(null, { status: 204 });
logRequest(request, response);
return response;
}
const response = new Response("Bad request", { status: 400 });
logRequest(request, response);
return response;
};
await serve(handler, { port });
Deno has an HTTP server included in its standard library. We import it in line 2
and start the server running in the last line of the code (line 50
). You see, that serve
call takes a handler
function as an argument. We define handler
from line 19
down. Deno uses standard JavaScript APIs, so the handler
function probably does not contain anything too surprising to you. That will especially be the case if you have already read through the getting started with Deno Fresh posts which runs through Deno APIs.
We have a logRequest
utility function defined in the code above. It uses the Temporal API to add a timestamp to the message it creates. We add a deno.json
file next, with an import map for the Temporal module and the others we used above.
π οΈ Deno Config: deno.json
Create a deno.json
file in the project root directory and add the following content:
{
"tasks": {
"start": "deno run -A --watch=utils/,main.ts main.ts",
"test": "deno test --allow-env --allow-read --allow-net --allow-run --allow-write dev.ts",
"wasmbuild": "deno run -A https://deno.land/x/wasmbuild@0.10.4/main.ts"
},
"imports": {
"@/": "./",
"$std/": "https://deno.land/std@0.182.0/",
"js-temporal/": "https://cdn.skypack.dev/@js-temporal/"
}
}
This includes paths for imports as well as the tasks
we will run to build WASM and start up the app. The tasks
here provide a similar function to the scripts
you would find in a package.json
file.
Setting up the App to run WASM
We can use the wasmbuild
task straight away to set up our project for Deno WASM. Run this command in the Terminal:
deno task wasmbuild new
If this is your first time running WASM in Deno take a look at the Deno Fresh WASM post where we walk through a few details. The last command should have created an rs_lib
directory in your project for our Rust code. We will update the Cargo.toml
file, in there, next.
π¦ Rusting Up
Disclaimer time! Iβm still learning Rust. I tested the code, and it works, though you might have a better way of doing things here. Keen to hear about improvements. You can drop a comment below or reach out on Twitter or via other channels.
Cargo.toml
Update the content in rs_lib/Cargo.toml
:
[package]
name = "deno-matrix-element-bot"
version = "0.0.1"
edition = "2021"
authors = ["Rodney Johnson <ask@rodneylab.com>"]
license = "BSD-3-Clause"
repository = "https://github.com/rodneylab/deno/tree/main/demos/deno-fresh-rss-feed/deno-matrix-element-bot"
description = "Matrix Element bot WASM Rust code"
[lib]
crate_type = ["cdylib"]
[profile.release]
codegen-units = 1
incremental = true
lto = true
opt-level = "z"
[dependencies]
matrix-sdk = { version = "0.6", default-features = false, features = ["e2e-encryption", "js", "native-tls"] }
wasm-bindgen = "=0.2.84"
wasm-bindgen-futures = "0.4.34"
lib.rs
Next, update the content in rs_lib/src/lib.rs
:
mod matrix_client;
use matrix_client::MatrixClient;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
// Use `js_namespace` here to bind `console.log(..)` instead of just
// `log(..)`
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[allow(unused_macros)]
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
#[wasm_bindgen]
pub async fn matrix_message(
element_room_id: &str,
body: &str,
element_username: &str,
element_password: &str,
) -> bool {
let matrix_client =
MatrixClient::new(element_username, element_password, element_room_id, None);
matrix_client.send_message(body).await;
true
}
You will create the matrix_client
module referenced here next.
matrix_client.rs
Finally, create rs_lib/src/matrix_client.rs
with the following content:
use matrix_sdk::{
config::SyncSettings,
room::Room,
ruma::{events::room::message::RoomMessageEventContent, RoomId},
Client,
};
pub struct MatrixClient<'a> {
homeserver_url: &'a str,
username: &'a str,
password: &'a str,
room_id: &'a str,
}
impl <'a> MatrixClient<'a> {
pub fn new(
username: &'a str,
password: &'a str,
room_id: &'a str,
homeserver_url: Option<&'a str>,
) -> MatrixClient<'a> {
let actual_homeserver_url: &str = match homeserver_url {
Some(value) => value,
None => "https://matrix-client.matrix.org",
};
MatrixClient {
homeserver_url: actual_homeserver_url,
username,
password,
room_id,
}
}
pub async fn send_message(&self, message: &str) -> bool {
let client_builder = Client::builder().homeserver_url(self.homeserver_url);
let client = client_builder.build().await.unwrap();
if client
.login_username(self.username, self.password)
.initial_device_display_name("deno-matrix-element-bot app")
.send()
.await
.is_err()
{
return false;
}
if client.sync_once(SyncSettings::default()).await.is_err() {
return false;
}
let content = RoomMessageEventContent::text_plain(message);
let owned_room_id = match RoomId::parse(self.room_id) {
Ok(value) => value,
Err(_) => return false,
};
let room = client.get_room(&owned_room_id).unwrap();
if let Room::Joined(room) = room {
room.send(content, None).await.is_ok()
} else {
false
}
}
}
Rust Summary
In Cargo.toml
, we add the matrix-sdk
crate as well as wasm-bindgen-futures
. You need that last one because we will call an async Rust function from the WASM in our Deno app. The function, we call from the WASM is matrix_message
, defined in lib.rs
. It just sets up a Matrix client instance for us, then uses the instance to send the message. The parameters we give in our Deno code will feed through.
Finally, turning to matrix_client.rs
. The 'a
βs sprinkled throughout the file are Rust lifetime annotations. I added them here because when we create a new instance of MatrixClient
we will not reserve new memory for username
, password
and other parameters then copy them across. Instead, we βborrowβ them. Effectively, we just store their existing memory address and read their value directly when we need to.
The lifetime annotations help make our code memory safe, avoiding us accidentally trying to read the values after that memory has been freed, for example. For more explanation on the lifetime annotations, see the Rust Book.
𧱠Building WASM
To build the WASM module, just run the command:
deno task wasmbuild
This will create all the WASM code we need to contact the Matrix room from our Deno app. You can find the module in the lib
folder of your project.
Updating Deno App with WASM
Letβs add the missing logic (for sending messages) to the main server file main.ts
now:
import "$std/dotenv/load.ts";
import { serve } from "$std/http/server.ts";
import {
instantiate,
matrix_message as matrixMessage,
} from "@/lib/deno_matrix_element_bot.generated.js";
import { Temporal } from "js-temporal/polyfill/?dts";
const ELEMENT_ROOM_ID = Deno.env.get("ELEMENT_ROOM_ID");
const ELEMENT_BOT_USERNAME = Deno.env.get("ELEMENT_BOT_USERNAME");
const ELEMENT_BOT_PASSWORD = Deno.env.get("ELEMENT_BOT_PASSWORD");
const port = 8080;
function logRequest(request: Request, response: Response) {
const { method, url } = request;
const { hostname, pathname } = new URL(url);
const dateTime = Temporal.Now.plainDateTimeISO();
const dateTimeString = `${dateTime.toPlainDate().toString()} ${
dateTime.toLocaleString("en-GB", { timeStyle: "short" })
}`;
console.log(
`[${dateTimeString}] ${method} ${hostname}${pathname} ${response.status} ${response.statusText}`,
);
}
const handler = async (request: Request): Promise<Response> => {
if (typeof ELEMENT_ROOM_ID === "undefined") {
throw new Error("env `ELEMENT_ROOM_ID` must be set");
}
if (typeof ELEMENT_BOT_USERNAME === "undefined") {
throw new Error("env `ELEMENT_BOT_USERNAME` must be set");
}
if (typeof ELEMENT_BOT_PASSWORD === "undefined") {
throw new Error("env `ELEMENT_BOT_PASSWORD` must be set");
}
const { method, url } = request;
const { pathname } = new URL(url);
if (pathname === "/matrix-message" && method === "POST") {
const body = await request.text();
await instantiate();
// remember to authenticate request before sending a message in a real-world app
try {
const messageSent = await matrixMessage(
ELEMENT_ROOM_ID,
body,
ELEMENT_BOT_USERNAME,
ELEMENT_BOT_PASSWORD,
);
if (!messageSent) {
const response = new Response("Bad request", { status: 400 });
logRequest(request, response);
return response;
}
} catch (error) {
console.error(`Error sending message: ${error}`);
return new Response("Server error", { status: 500 });
}
const response = new Response(null, { status: 204 });
logRequest(request, response);
return response;
}
const response = new Response("Bad request", { status: 400 });
logRequest(request, response);
return response;
};
await serve(handler, { port });
The WASM module import statement is in lines 3
-6
. Notice, we have an instantiate
function (generated for us) as well as the matrix_message
function we wrote in Rust. Before we can call matrix_message
we need to make a one-off call to this instantiate
function. That initializes the WASM module.
The only thing we are missing now is the environment variables. We can add those next, then run a test.
π€ Environment Variables
Create a .env
file in the project root directory. Remember to add it to your .gitignore
file, so your secrets will not get committed to your remote repo.
ELEMENT_ROOM_ID="!abcdefghijklmonpqr:matrix.org"
ELEMENT_BOT_USERNAME="your-bot-username"
ELEMENT_BOT_PASSWORD='YOUR_BOT_ACCOUNT_PASSWORD'
To get the ELEMENT_ROOM_ID
in Element Matrix desktop:
- Select the room the bot will post to.
- Click the info button in the top right corner.
- Select Room settings then Advanced.
- Use the Internal room ID displayed here.
You can create a new account for your bot at https://app.element.io/#/welcome
. Use your bot account username and password in your .env
file.
π― Matrix Message Relay Bot: Testing
To spin up your app, run this command from the Terminal:
deno task start
The server should now be listening on http://localhost:8080
. We will use curl from another Terminal tab to test it with this command (sending a POST
request with plaintext message in the request body):
curl -H "Content-Type: text/plain" --data "Good morning" \\
http://localhost:8080/matrix-message
Check back in the Deno app Terminal tab. You should see a log message showing a 204
response code.
Also, if you open up Element Matrix, you should see the message:
ππ½ Matrix Message Relay Bot: Wrapping Up
We had an introduction to how you can create a Matrix message relay bot. In particular, you saw:
- how to create an HTTP server in Deno
- how to use the Matrix SDK to send messages into a Matrix chat room
- an example of how you can generate WASM code from Rust with Deno tooling
The complete code for this project is in the Rodney Lab GitHub repo. I do hope the post has either helped you with an existing project or provided some inspiration for a new project. You might want to explore the Matrix Rust SDK further, considering extensions like:
- listening for messages in the Element room and using AI to respond
- moderating messages received on the REST API (filtering out inappropriate content, for example) before relaying them
Be sure to add authentication before pushing the code to your live server.
Get in touch if you have some tips on how I can improve my Rust. Also, let me know if you have suggestions for other content.
ππ½ Matrix Message Relay Bot: Feedback
Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also, if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, then please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter, @rodney@toot.community on Mastodon and also the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Astro as well as Deno. Also, subscribe to the newsletter to keep up-to-date with our latest projects.