Matrix Message Relay Bot: API with Deno & Rust
Tags: deno, rust
Published: 2023-04-11 07:44:34

---
title: "Matrix Message Relay Bot: API with Deno & Rust"
published: "true"
description: "Matrix Message Relay Bot 📢 how you can create a serverless API to relay messages to your Matrix chat room using Deno and 🦀 Rust WASM."
tags: "deno, rust"
canonical_url: "https://rodneylab.com/matrix-message-relay-bot/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/akxs0kdy5es8b7oop2iv.png"
---
## 🤖 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&rsquo;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:

```typescript
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 <a href="https://rodneylab.com/getting-started-deno-fresh/">read through the getting started with Deno Fresh posts</a> 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:

```json
{
  "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:

```shell
deno task wasmbuild new
```

If this is your first time <a href="https://rodneylab.com/deno-fresh-wasm/">running WASM in Deno take a look at the Deno Fresh WASM post</a> 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&rsquo;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`:

```rust
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:

```rust
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`&rsquo;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 &ldquo;borrow&rdquo; 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 <a href="https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html">explanation on the lifetime annotations, see the Rust Book</a>.

## 🧱 Building WASM

To build the WASM module, just run the command:

```shell
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&rsquo;s add the missing logic (for sending messages) to the main server file `main.ts` now:

```typescript
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.

```plaintext
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:
1. Select the room the bot will post to.
2. Click the info button in the top right corner.
3. Select **Room settings** then **Advanced**.
4. 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:

```shell
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):

```shell
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.

![Matrix Message Relay Bot: screen capture shows the Terminal with a Deno server running at http://localhost:8080.  Log shows the date and time a POST request was received.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i9mg3g3ql3atnjj792hc.png)

Also, if you open up Element Matrix, you should see the message:

![Matrix Message Relay Bot: screen capture shows the message window in the Matrix Element app with a recent message reading 'Good morning'](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/abq13x60bdrewtduvvuq.png)

## 🙌🏽 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 <a href="https://github.com/rodneylab/deno/tree/main/demos/deno-matrix-element-bot">complete code for this project is in the Rodney Lab GitHub repo</a>.  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 <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

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 <a aria-label="at ask Rodney" href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on Twitter, <a href="https://toot.community/@rodney">@rodney@toot.community</a> on Mastodon and also the <a aria-label="Join the Rodney Lab Element Matrix room" href="https://matrix.to/#/%23rodney:matrix.org">#rodney</a> Element Matrix room. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact/">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on Astro" href="https://rodneylab.com/tags/astro/">Astro</a> as well as <a aria-label="See posts on deno" href="https://rodneylab.com/tags/deno/">Deno</a>. Also, <a aria-label="Subscribe to the Rodney Lab newsletter" href="https://newsletter.rodneylab.com/issue/latest-issue">subscribe to the newsletter to keep up-to-date</a> with our latest projects.