The simplest Matrix 'chatbot' base in C#

I'd wanted to create an experimental chatbot in Matrix that would interact with the the Obskurnee book club app. There are a couple listed for C# in the official list, but they didn't work for me. One worked when I tried the examples, but not for my use-case (seemed like they maybe don't support public rooms). With the other one, I haven't even managed to get the example running.

Rather than spending a ton of time figuring out what the issues were with those libraries (and then not using most of their features), I'd decided to write a simple class of my own. It only does one thing, though; if you came here for a fully-featured SDK, you won't find it. I'm just sharing this in case you're googling for something similar.

Overview

So, what can it do?

  • Open a connection to Matrix.
  • Listen for incoming messages for a specific room. Checks for new ones every second. When a message is received, calls the handler event.
  • When sending messages, you can either use plaintext or Markdown.

Though it would be trivial to extend it for multiple rooms in the FetchMessages method.

That's about it.

Dependencies

There are two dependencies:

  • Flurl, which I like to use for web requests
  • Markdig, an excellent library for Markdown rendering

I'm referencing those in most projects anyway; they would be easy to remove/replace if you don't want them.

Usage

  • Initialize the class with all the info it needs - the URL of the homeserver its user is on, the user's name and auth token, and the internal ID of the room to watch. (No other auth type is supported.)
  • Register a listener for the NewMessageReceived event - this will be called for every incoming message separately.
  • Await the Start() method.

Older messages that were sent before Start() was called are discarded. For every new message that arrives in that room after that point, NewMessageReceived is called.

Here is a sample that responds with a (Markdown-formatted) "Hello, World!" to every message in the room:

using (var matrix = new MatrixChatroomWatcher(
            homeserverUrl: "https://matrix.zble.sk",
            roomId: "!yourRoomIdentifier:zble.sk",
            username: "@myUser:zble.sk",
            token: "Pregenerated Security Token"))
{
    matrix.NewMessageReceived += async (msg) =>
    {
        // No real need to await it here, but you can if you want to
        matrix.SendMessage("_Hello, **World!**_");
    };
    await matrix.Start();
    
    // your code here
}

That's it.

The Code

The code is also in this gist.

using Flurl;
using Flurl.Http;
using Markdig;

namespace zblesk
{
    public class MatrixChatroomWatcher : IDisposable
    {
        private readonly string _homeserverUrl;
        private readonly string _username;
        private readonly string _roomId;
        private readonly string _authToken;

        private string resumeToken = "";
        private bool started = false;
        private Timer? timer;

        public delegate void MessageCallback(dynamic message);
        public event MessageCallback? NewMessageReceived;

        public MatrixChatroomWatcher(string homeserverUrl, string roomId, string username, string token)
        {
            if (string.IsNullOrEmpty(homeserverUrl))
            {
                throw new ArgumentException($"'{nameof(homeserverUrl)}' cannot be null or empty.", nameof(homeserverUrl));
            }

            _homeserverUrl = homeserverUrl.TrimEnd('/');
            _username = username ?? throw new ArgumentException($"'{nameof(username)}' cannot be null or empty.", nameof(username));
            _roomId = roomId ?? throw new ArgumentException($"'{nameof(roomid)}' cannot be null or empty.", nameof(roomid));
            _authToken = token ?? throw new ArgumentException($"'{nameof(token)}' cannot be null or empty.", nameof(token));
        }

        public async Task Start()
        {
            if (started)
                return;
            await InitializeSync();
            started = true;
            timer = new Timer(FetchMessages, null, 100, 1000);
        }

        public void Stop()
        {
            if (!started)
                return;
            timer?.Change(Timeout.Infinite, 0);
            started = false;
        }

        public async Task SendMessage(string message, bool renderMarkdown = true)
        {
            object body = new
            {
                msgtype = "m.text",
                body = message,
            };
            if (renderMarkdown)
                body = new
                {
                    msgtype = "m.text",
                    body = message,
                    format = "org.matrix.custom.html",
                    formatted_body = Markdown.ToHtml(message),
                };
            await $"{_homeserverUrl}/_matrix/client/r0/rooms/{_roomId}/send/m.room.message?access_token={_authToken}"
                                .PostJsonAsync(body);
        }

        public void Dispose()
        {
            timer?.Dispose();
        }

        private async Task InitializeSync()
        {
            var res = await $"{_homeserverUrl}/_matrix/client/v3/sync?access_token={_authToken}"
                                .GetJsonAsync();
            resumeToken = res.next_batch;
        }

        private void FetchMessages(object? state)
        {
            var request = $"{_homeserverUrl}/_matrix/client/v3/sync?access_token={_authToken}"
                            .SetQueryParam("timeout", 10)
                            .SetQueryParam("since", resumeToken)
                            .GetJsonAsync();
            request.Wait();
            var response = request.Result;
            resumeToken = response.next_batch;
            if (((IDictionary<string, dynamic>)response).ContainsKey("rooms"))
            {
                var r = (IDictionary<string, dynamic>)response.rooms.join;
                if (r.ContainsKey(_roomId))
                {
                    var tt = r[_roomId]?.timeline?.events;
                    foreach (var q in tt)
                    {
                        if (q.sender != _username)
                            NewMessageReceived?.Invoke(q);
                    }
                }
            }
        }
    }
}