Write a simple Matrix bot in Scheme (or any other language) - Part 2
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 define
s out of the way:
; utf8->string
(use-modules (rnrs bytevectors) ; let-values
(srfi srfi-11) ; vector-for-each
(srfi srfi-43)
(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-poststring-append base "/login")
(
#:body (scm->json-string. "m.login.password")
`((type .
(identifier . "m.id.user")
((type . ,bot-user)))
(user . ,bot-pass)
(password . "bot")))))
(device_id utf8->string raw-body))))
((body) (json-string->scm (
not (= 200 (response-code resp)))
(when (error "Login error:"
("errcode")
(assoc-ref body "error")))
(assoc-ref body
set! auth-header
(cons 'Authorization (string-append "Bearer "
("access_token"))))) (assoc-ref body
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-getstring-append
("/sync?"
base if next-batch "since=" "")
(or next-batch "")
("&timeout=" (number->string timeout))
list auth-header)))
#:headers (utf8->string raw-body))))
((body) (json-string->scm (
not (= 200 (response-code resp)))
(when (error "Sync error:"
("errcode")
(assoc-ref body "error")))
(assoc-ref body
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
0)
(sync
;; sync loop
let loop ((state (sync)))
(for-each
(lambda (room)
(
(vector-for-eachlambda (_ ev) (handle-event ev (car room)))
(cdr room) "timeline" "events")))
(assoc-ref* ("rooms" "join"))
(assoc-ref* state
(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"))
(content (assoc-ref event "sender")))
(sender (assoc-ref event
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
(#f "~a/rooms/~a/send/m.room.message/~a" base room tx-id)
(http-put (format list auth-header)
#:headers (
#:body (scm->json-string. "m.text")
`((msgtype if (pair? text)
,@(. ,(car text))
`((body . "org.matrix.custom.html")
(format . ,(cdr text)))
(formatted_body . ,text))))))
`((body
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"))
(content (assoc-ref event "sender")))
(sender (assoc-ref event cond
(string=? sender bot-user))
((
and (string=? type "m.room.message")
((string=? (assoc-ref content "body") "hello there"))
("hi!")))) (send-message room
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:
- the event type is
m.room.member
; - the
membership
field isjoin
; - the previous membership status wasn’t
join
(otherwise we’d be reacting to name and avatar changes too). We can get this information from.unsigned.prev_content.membership
.
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"))
(content (assoc-ref event "sender")))
(sender (assoc-ref event 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"))
("hi!"))))) (send-message room
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:

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.