hoowl

Physicist, Emacser, Digitales Spielkind

An extendable GNU Jami chat bot written in Elisp
Published on Apr 16, 2023.

Jami is a distributed and private messenger that is part of the GNU project and mainly developed by Savoir-faire Linux. Private in this context does not only refer to the fact that messages, calls and video chats are encrypted, but that you need to provide essentially no personal information to create a Jami account.

The technology behind Jami is quite interesting: since the recent introduction of the so-called Swarm conversations, it uses git behind the scenes to store, sync and merge conversations between peers. This means that you can have encrypted group chats without requiring any server to exchange them through – which even works between devices on the same local network while the internet is down.

Jami is easy to deploy on most OS and is available for mobile devices as well. As we have seen in the previous post where I explored Jami’s D-Bus interface, most of the applications functions can be easily accessed from other local applications. That makes Jami an interesting candidate for a chat bot written in Elisp: a function that subscribes to Jami’s messageReceived signal, parses the message and reacts to it.

While generating silly replies (Emacs’ doctor anyone?) would be fun for a while, I am envisioning more of a simple command parser that allows to access and/or record specific information through Emacs. For me, that would be taking quick notes on my mobile to record those fleeting thoughts and ideas while sitting on the bus on my way home. Or, to construct longer notes in several steps: like when I am on a tour of a lab or other place of interest and want to combine a series of pictures with short notes to keep track of things I want to remember. Of course I want to have those notes in Org mode at the end of the day, so why not send and capture them immediately?

But this is getting ahead of what this post is about: creating a bare-bones chat bot in Elisp that can be extended to perform above mentioned tasks. The extension to Org mode functionality is what the next post in this series is about. On that occasion, I will discuss the pro and contra of this approach in a little more detail. If you are not so much interested in the technical details of the implementation then feel free to skip ahead! If, on the other hand, you are curious how to make the chat bot follow your own commands, then just continue reading.

Note: the code discussed below is what I originally wrote – it probably has evolved since and the newest version will be hosted at this repository: https://gitlab.com/hperrey/jami-bot

Defining a message handler

The entry point to our chat bot will be a function that we register on the messageReceived signal. Each message by Jami for any account and any conversation will be passed on to this function as well as the account it was received via and the conversation that the message is part of.

To keep things modular, this function needs only to serve two purposes:

  1. Filter out any unwanted messaging activity. Most importantly, this includes messages that were sent by the chat bot account(s) to avoid ending in an infinite loop reacting to our own messages. But the user might want to limit the chat bot to certain local accounts.
  2. Distinguish the type of message (text, file transfer, internal) and pass it on to the appropriate function for further processing or ignore it.

Below is the definition for the function – we will walk through the various branches step-by-step.

 1: 
 2: (defun jami-bot--messageReceived-handler (account conversation msg)
 3:   "Handle messages from Jami's `messageReceived' D-Bus signal.
 4: 
 5:   ACCOUNT and CONVERSATION are the corresponding ids to which the
 6:   MSG belongs to. The latter contains additional fields such as
 7:   `author' and `body'. The field `type' is used to identify which
 8:   function to call for further processing."
 9:   ;; make sure we are not reacting to messages sent from our own local
10:   ;; account(s) or accounts we are not to monitor
11:   (unless jami-bot--jami-local-account-ids
12:     (jami-bot--refresh-accountid-list))
13:   (let ((author (cadr (assoc "author" msg)))
14:         (type (cadr (assoc "type" msg))))
15:     (when (or
16:            (and jami-bot-account-user-names
17:                  ;; account id should match a user name to be monitored
18:                  (member (car (rassoc account jami-bot--jami-local-account-ids))
19:                          jami-bot-account-user-names)
20:                  ;; .. but msg should not be authored by ourselves
21:                  (not (member author jami-bot-account-user-names)))
22:             ;; no account filter: check msg not from local account
23:            (and (not jami-bot-account-user-names)
24:                 (not (assoc author jami-bot--jami-local-account-ids))))
25:       (message "jami-bot received %s message from %s on account %s." type author account)
26:       (pcase type
27:         ("text/plain"
28:          (jami-bot--process-text-message
29:           account
30:           conversation
31:           msg))
32:         ("application/data-transfer+json"
33:          (jami-bot--process-data-transfer
34:           account
35:           conversation
36:           msg))
37:         ;; ignore merges of the conversation; usually transparent to the user
38:         ;; anyway
39:         ("merge" (ignore))
40:         ;; ignore new members joining
41:         ("member" (ignore))
42:         (_
43:          (jami-bot--process-unknown-type
44:           account
45:           conversation
46:           msg))))))
47: 
48: (defun jami-bot--process-unknown-type (account conversation msg)
49: "Handle messages of unknown type by sending an error message as reply.
50: 
51:   ACCOUNT and CONVERSATION are the corresponding ids to which the
52:   MSG belongs to."
53:   (let ((type (cadr (assoc "type" msg))))
54:     (message "Error: received message with unkonwn type: %s" type)
55:     (jami-bot-reply-to-message
56:      account
57:      conversation
58:      msg
59:      (format "Unknown message type: %s" type))))

The first part of jami-bot--messageReceived-handler handles the filtering. Any messages from local accounts are ignored to avoid feedback loops created when responding to our own replies. In case several local Jami accounts are present, one can limit jami-bot to only react to messages to specific accounts:

(defvar jami-bot-account-user-names nil
  "List of account user names that `jami-bot' handles messages for.

        If set to nil then `jami-bot' will react to any message
        send to a local account. The user name is also sometimes
        referred to as address in Jami and should be a 40
        character has such as
        \"badac18e13ec1a6e1266600e457859afebfb9c46\".")

This has to be the full user name (I think sometimes referred to as address in Jami), that is to say the full hash, for example:

(setq jami-bot-account-user-names '("badac18e13ec1a6e1266600e457859afebfb9c46"))

To make it easier to enter these, here is a little interactive helper function that allows to pick a user from the list of local accounts:

(defun jami-bot-select-and-insert-local-account ()
  "Prompt user for a local Jami user account and insert id at position."
  (interactive)
  (jami-bot--refresh-accountid-list)
  (insert (completing-read "Pick a account user name to insert: " jami-bot--jami-local-account-ids)))

From line 25 onward, the different message types are distinguished. While text and file transfer are handled in separate functions, the internal messages merge (when the conversation is synced after it diverged due to a loss of connectivity) and member (when someone is added to a conversation) are ignored. Anything else will be sent to jami-bot--process-unknown-type defined on line 47. In that case, a warning will be added to the conversation, mentioning the unknown type.

The actual registration of the handler on the bus for the messageReceived signal is done by a helper function:

 1: (defun jami-bot-register ()
 2:   "Ping the Jami daemon and register `jami-bot' handler for receiving messages."
 3:   (interactive)
 4:   (or (dbus-ping :session "cx.ring.Ring")
 5:       (error "Jami Daemon (jamid) not available through dbus. Please check Jami installation"))
 6:   (dbus-register-signal :session "cx.ring.Ring"
 7:                       "/cx/ring/Ring/ConfigurationManager"
 8:                       "cx.ring.Ring.ConfigurationManager"
 9:                       "messageReceived"
10:                       #'jami-bot--messageReceived-handler))

Pinging the Jami D-Bus service on line 4 verifies not only that Jami is installed correctly and registered on D-Bus but also has the side effect of starting the daemon if it is not already running. Therefore, this helper function is useful should you ever need to restart the Jami daemon: simply run killall jamid on the terminal and call M-x jami-bot-register in Emacs to start it up again.

Now that our chat bot can receive messages, let us start to react to them. We start with text messages.

A simple command parser

To make the conversations with the bot a little more interesting, I want to be able to issue simple commands via text messages. Any message starting with an exclamation mark and a single word will be interpreted as a command and forwarded to a specific function. An example would be the !help command which will respond with the list of available commands.

To make this easily extensible, I define an alist that maps commands with the function that processes them:

(defvar jami-bot-command-function-alist
  '(("!ping" . jami-bot--command-function-ping)
    ("!help" . jami-bot--command-function-help))
  "Alist mapping command strings in message body to functions to be executed.

Each command needs to start with an exclamation mark '!' and
consist of a single (lowercase) word. The corresponding function needs to accept
the account id, the conversation id and the message alist as
arguments and return a string (that is sent as reply to the original message).")

Of course, some actions should also be taken if there is no command given at all. For those cases, I define an abnormal hook that the user can set arbitrary functions to process. Abnormal means that the functions will be called with arguments, as they will need the account, conversation and actual message to process.

(defvar jami-bot-text-message-functions nil
  "A list of functions that will be called when processing a plain text message.

Functions must take the ACCOUNT and CONVERSATION ids as well as
the actual MSG as arguments. Their return value will be ignored.")

Every text message is forwarded from jami-bot--messageReceived-handler on line 27 processed by jami-bot--process-text-message below.

 1: (defun jami-bot--process-text-message (account conversation msg)
 2:   "Process plain text messages and parse the message body for commands.
 3: 
 4:   ACCOUNT and CONVERSATION are the corresponding ids to which the
 5:   message MSG belongs to. Messages containing commands must start
 6:   with an exclamation mark (\"!\") followed by the single-word
 7:   command. Each command is mapped to a function via
 8:   `jami-bot-command-function-alist' which will be executed when
 9:   the command is received.
10: 
11:   If the message does not start with an exclamation mark, the
12:   abnormal hook `jami-bot-text-message-functions' will be run for
13:   further processing."
14:   (let ((body (cadr (assoc-string "body" msg))))
15:     ;; check for criteria handling first line of body as command
16:     ;;  - string starts with '!' and is a single word
17:     (if (string-prefix-p "!" body)
18:         ;; command in msg body
19:         (let*
20:             ((cmd (downcase (substring body 0 (string-match-p "[^[:word:]!]" body))))
21:              (fcn (cdr (assoc-string cmd jami-bot-command-function-alist))))
22:           ;; remove the command from the message body
23:           (setcdr (assoc-string "body" msg)
24:                   (list (string-trim-left (string-remove-prefix cmd body))))
25:           (if fcn
26:             ;; call function and reply with return value
27:             (jami-bot-reply-to-message
28:              account
29:              conversation
30:              msg
31:              (funcall fcn account conversation msg))
32:             ;; no matching command defined:
33:             ;; report error as reply to msg
34:             (jami-bot-reply-to-message
35:              account
36:              conversation
37:              msg
38:              (format "Unknown command: %s" cmd))))
39:       ;; not a command in msg body: run hook instead
40:       (run-hook-with-args 'jami-bot-text-message-functions
41:                           account conversation msg))))

If a command prefix was found in the message and a matching handler defined, then the function will be called on line 25 and the functions return value be sent as a reply. Otherwise, a reply will be sent with “unknown command”. In case there was no command in the message, we might still want to do something with it and provide a hook on line 40.

Now we can define a couple of functions that react to commands.

The simplest command processor is the ping command that replies to a message with pong! (and any part of the original message after the command keyword):

(defun jami-bot--command-function-ping (_account _conversation msg)
  "Return the string 'pong!' followed by the message's body.

Example for a basic jami bot command handling function. Acts on MSG
received via _ACCOUNT in _CONVERSATION. The latter two are unused."
  (let ((body (cadr (assoc-string "body" msg))))
    (format "pong! %s" body)))

A little more useful is the help command that lists all available commands and their docstring:

(defun jami-bot--command-function-help (_account _conversation _msg)
  "Return a summary of available commands.

Acts on _MSG received via _ACCOUNT in _CONVERSATION, none of
which are used."
  (let (result)
    (dolist (cmd jami-bot-command-function-alist (string-join result "\n"))
      (push (concat "- " (car cmd) " :: "
                    (car (split-string (documentation (cdr cmd)) "\n"))) result))))

Handling data transfers (i.e. attached files)

If one sends a file via Jami, e.g. an image, the message contains the file name and a file id but no actual data. This has to be retrieved by downloading it before we can continue processing it. So for this simple handler, we define a variable path to store files in (jami-bot-download-path) and a function jami-bot--process-data-transfer to download the file and run a hook for additional handling.

(defvar jami-bot-download-path "~/jami/"
"Path in which to store files downloaded from conversations.

Will be created if not existing yet.")
 1: (defun jami-bot--process-data-transfer (account conversation msg)
 2:   "Process data transfer from received messages.
 3: 
 4:   Downloads files to the path given by `jami-bot-download-path'
 5:   and calls the abnormal hook `jami-bot-data-transfer-functions'
 6:   for further processing. ACCOUNT and CONVERSATION are the
 7:   corresponding ids to which the message MSG belongs to."
 8:   (let* ((id (cadr (assoc-string "id" msg)))
 9:          (fileid (cadr (assoc-string "fileId" msg)))
10:          (filename (cadr (assoc-string "displayName" msg)))
11:          (dlpath (file-name-as-directory
12:                   (expand-file-name jami-bot-download-path)))
13:          (dlname (concat dlpath (format-time-string "%Y%m%d-%H%M") "_" filename)))
14:     (unless (file-directory-p dlpath) (make-directory dlpath 't))
15:     (message "jami-bot: downloading file %s" dlname)
16:     (jami-bot--dbus-cfgmgr-call-method "downloadFile" account conversation id fileid dlname)
17:     (run-hook-with-args 'jami-bot-data-transfer-functions account conversation msg dlname)))

This so far takes care of transferring the file to local storage. If desired, the user can process the data further by adding a hook which is run on line 17:

(defvar jami-bot-data-transfer-functions nil
  "A list of functions that will be called when processing a data transfer message.

Functions must take the ACCOUNT and CONVERSATION ids as well as
the actual MSG and the local downloaded file name, DLNAME, as
arguments. Their return value will be ignored.")

Support functions for D-Bus and account bookkeeping

The final puzzle pieces still missing are the support functions that help with the D-Bus interaction and with keeping track of local Jami accounts.

As all D-Bus calls necessary for the chat bot are part of Jami’s ConfigurationManager interface, and mostly concerned with sending messages, we can factorize the constant bits of these calls into three helper functions:

 1: (defun jami-bot--dbus-cfgmgr-call-method (method &rest args)
 2:   "Call Jami ConfigurationManager dbus METHOD with arguments ARGS."
 3:   (apply #'dbus-call-method `(:session
 4:                     "cx.ring.Ring"
 5:                     "/cx/ring/Ring/ConfigurationManager"
 6:                     "cx.ring.Ring.ConfigurationManager"
 7:                     ,method ,@(when args args))))
 8: 
 9: (defun jami-bot-send-message (account conversation text &optional reply)
10:   "Add TEXT to CONVERSATION via ACCOUNT. REPLY specifies a message id."
11:   (jami-bot--dbus-cfgmgr-call-method "sendMessage"
12:                                      account
13:                                      conversation
14:                                      text
15:                                      `(,@(if reply reply ""))
16:                                      :int32 0))
17: 
18: (defun jami-bot-reply-to-message (account conversation msg text)
19:   "Add TEXT as a reply to MSG in CONVERSATION via ACCOUNT."
20:   (let ((id (cadr (assoc-string "id" msg))))
21:     (jami-bot-send-message account conversation text id)))

If you are not familiar with the special marker shown on line 7 then you can read about how to use the special marker to construct argument lists in a previous blog post.

Finally, we can cache local accounts in a variable and set up a function to update these:

(defvar jami-bot--jami-local-account-ids nil
  "List of `jami' local accounts user ids and name pairs.

Caches output of dbus-methods 'getAccountList' and
'getAccountDetails'. For internal use in `jami-bot'.")
(defun jami-bot--refresh-accountid-list ()
  "Update cached values of known local account ids.

The values are stored in `jami-bot--jami-local-account-ids'."
  (let ((accounts (jami-bot--dbus-cfgmgr-call-method
                   "getAccountList")))
    (let ((value))
      (dolist (acc accounts)
        (push (cons (cadr (assoc-string
                           "Account.username"
                           (jami-bot--dbus-cfgmgr-call-method
                            "getAccountDetails"
                            acc))) acc)
              value))
      (setq jami-bot--jami-local-account-ids value)))
  jami-bot--jami-local-account-ids)

Where to next?

If you have read this far, you likely have already ideas how you could use this code to scratch an old itch of yours or you might have other comments – please get in touch, I would love to hear them!

My personal need was, as mentioned before, straightforward note-taking on the go: pop out the mobile phone, send a message to Emacs and have that thought transferred directly into my second brain.

I wrote another blog post detailing the code and usage thereof: Note-taking on the go: Capturing messages and images sent via Jami in Org mode

Tags: emacs, jami, programming, lisp