Matrix Message Relay Bot: API with Deno & Rust

Rodney Lab - Apr 11 '23 - - Dev Community

πŸ€– 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 });
Enter fullscreen mode Exit fullscreen mode

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/"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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:

deno task start
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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'

πŸ™ŒπŸ½ 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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .