dalz's blog

aboutgeminiitalianoRSS feed icon

Write a simple Matrix bot in Scheme (or any other language) - Part 2
2021-04-04

After getting to know Matrix’s API in Part 1, we can finally start working on the bot itself!

It may be helpful to keep Guile’s manual and guile-json’s README open, as I’ll just provide a bird’s eye view over the code.

Getting to Scheme

First things first, let’s get some imports and defines out of the way:

(use-modules (rnrs bytevectors) ; utf8->string
             (srfi srfi-11)     ; let-values
             (srfi srfi-43)     ; vector-for-each
             (web client)
             (web response)
             (json))

(define base "https://matrix.alsd.eu:8448/_matrix/client/r0")
(define bot-user "@testbot:alsd.eu")
(define bot-pass "<PASSWORD>")

(define auth-header) ; we'll set! this later
(define tx-id 0)
(define next-batch #f)

Nothing new here, just replace the values as needed.

As you have seen we’ll be handling nested JSON objects (converted to alists by guile-json), so you may find this macro useful in dealing with them:

(define-syntax assoc-ref*
  (syntax-rules ()
    ((_ alist key key* ...)
     (assoc-ref* (assoc-ref alist key) key* ...))
    ((_ alist) alist)))

;; example:
;; (assoc-ref*
;;   '((a . ((b . 42)
;;           (c . "foo"))
;;     (d . "bar")))
;;   'a 'c)
;;
;; => "foo"

Logging in

As before, logging in is a matter of POSTing the bot’s credentials to $base/login. We then decode the response to a scheme representation of the JSON object, extract the token and set auth-header accordingly (Guile expects headers of the form (Name . "content")).

(let*-values (((resp raw-body)
               (http-post
                 (string-append base "/login")
                 #:body (scm->json-string
                          `((type . "m.login.password")
                            (identifier .
                                        ((type . "m.id.user")
                                         (user . ,bot-user)))
                            (password . ,bot-pass)
                            (device_id . "bot")))))
              ((body) (json-string->scm (utf8->string raw-body))))

  (when (not (= 200 (response-code resp)))
    (error "Login error:"
           (assoc-ref body "errcode")
           (assoc-ref body "error")))

  (set! auth-header
    (cons 'Authorization (string-append "Bearer "
                                        (assoc-ref body "access_token")))))

If something goes wrong the body will contain (hopefully) useful information on the issue as an object that looks like this:

{
  "errcode": "<error code>",
  "error": "<error message>"
}

Synchronizing state

Now let’s define a sync procedure that GETs the appropriate endpoint with a timeout (defaulting to 30 seconds) and, if next-batch isn’t #f, since parameter. On success, update next-batch and return the response (converted to an alist). Remember to provide the Authentication header this time!

(define* (sync #:optional (timeout 30000))
  (let*-values (((resp raw-body)
                 (http-get
                   (string-append
                     base "/sync?"
                     (if next-batch "since=" "")
                     (or next-batch "")
                     "&timeout=" (number->string timeout))
                   #:headers (list auth-header)))
                ((body) (json-string->scm (utf8->string raw-body))))

    (when (not (= 200 (response-code resp)))
      (error "Sync error:"
             (assoc-ref body "errcode")
             (assoc-ref body "error")))

    (set! next-batch (assoc-ref body "next_batch"))
    body))

Feel free to evaluate (sync) in a REPL and see how the output looks.

With the help of this function we can define a sync loop: our program will keep requesting updates and act on the response; if nothing is happening the server will just keep the bot waiting (up to timeout ms, of course).

;; drop old messages
(sync 0)

;; sync loop
(let loop ((state (sync)))
  (for-each
    (lambda (room)
      (vector-for-each
        (lambda (_ ev) (handle-event ev (car room)))
        (assoc-ref* (cdr room) "timeline" "events")))
    (assoc-ref* state "rooms" "join"))

  (loop (sync)))

After receiving the response (bound to state) we iterate over the Joined Rooms object (the one that maps room ids to a list of events) and for each event of each room we call handle-event, passing the event and the id of the event’s room to it.

For now you can define handle-event as follows:

(define (handle-event event room)
  (let ((type (assoc-ref event "type"))
        (content (assoc-ref event "content"))
        (sender (assoc-ref event "sender")))

    (display "room: ")    (display room)    (newline)
    (display "type: ")    (display type)    (newline)
    (display "content: ") (display content) (newline)
    (display "sender: ")  (display sender)  (newline)
    (newline)))

Run the program, type something into the bot’s room, and take a look at the output:

room: !PXeSeufpLzIQnfleAn:alsd.eu
type: m.room.message
content: ((body . hello there) (msgtype . m.text))
sender: @dalz:alsd.eu

Sending messages

As before, defining a procedure to send messages is just a matter of translating the example with curl into scheme:

(define (send-message room text) ; `text` is either a string or a pair of strings
  (http-put (format #f "~a/rooms/~a/send/m.room.message/~a" base room tx-id)
            #:headers (list auth-header)
            #:body (scm->json-string
                     `((msgtype . "m.text")
                       ,@(if (pair? text)
                           `((body . ,(car text))
                             (format . "org.matrix.custom.html")
                             (formatted_body . ,(cdr text)))
                           `((body . ,text))))))

  (set! tx-id (1+ tx-id)))

There’s something new in the JSON object we’re sending: if the text argument is a pair like ("plain message text" . "<i>formatted</i> message text"), it looks like:

{
    "body": "plain message text",
    "format": "org.matrix.custom.html",
    "formatted_body": "<i>formatted</i> message text"
}

The plain text content is always required for clients not capable of showing formatted messages. Passing a simple string to send-message leads to in that case format and formatted_body not being inserted, in case we don’t need special formatting.

Let’s modify handle-event so that the bot replies to our salutations:

(define (handle-event event room)
  (let ((type (assoc-ref event "type"))
        (content (assoc-ref event "content"))
        (sender (assoc-ref event "sender")))
    (cond
      ((string=? sender bot-user))

      ((and (string=? type "m.room.message")
            (string=? (assoc-ref content "body") "hello there"))
         (send-message room "hi!"))))

We want the bot to ignore its own events, so I added an empty cond clause to check for that.

The test room feels much less lonely now that my “hello there”s don’t go unanswered :)

Formatted messages

A good use for formatted messages is to send clickable room and user tags. How about we greet by name users who join the room?

User membership events are documented here, what we need is to check that:

(define (welcome-message user)
  (cons
    (string-append "welcome " user "! have you seen #matrix:matrix.org?")
    (string-append
      "welcome <a href=\"https://matrix.to/#/" user "\">" user "</a>, have you seen "
      "<a href=\"https://matrix.to/#/#matrix:matrix.org\">#matrix:matrix.org</a>?")))

(define (handle-event event room)
  (let ((type (assoc-ref event "type"))
        (content (assoc-ref event "content"))
        (sender (assoc-ref event "sender")))
    (cond
      ((string=? sender bot-user))

      ((and (string=? type "m.room.member")
            (string=? (assoc-ref content "membership") "join")
            (not (equal? (assoc-ref* event "unsigned" "prev_content" "membership") "join")))
       (send-message room (welcome-message sender)))

      ((and (string=? type "m.room.message")
            (string=? (assoc-ref content "body") "hello there"))
         (send-message room "hi!")))))

I find it convenient to define function like welcome-message to create formatted messages with the structure expected by our send-message. This helps to keep handle-event clean from that not terribly good looking HTML. Since there’s no matrix: URI scheme yet, user and room links use https://matrix.to (docs).

This is how the final result looks:

Testing the bot

Conclusions

That’s all you need to know to implement IRC-style bots! There’s more you can do of course, like using reactions and redactions to issue and reply to commands without cluttering the chat, but I didn’t have much use for anything more complex than what I’ve shown here.

I have uploaded the code of the bot this post is based on here.



Comments? Send them to blog@alsd.eu!
Back to the article list.