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 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.
Comments? Send them to
blog@alsd.eu!
Back to the article list.