hoowl

Physicist, Emacser, Digitales Spielkind

Exploring the inter-process messaging bus D-Bus using Elisp
Published on Apr 16, 2023.

As a user, you typically expect your software application to seamlessly interact with other processes and bits of the local system. For example, you might want it to immediately react to new USB devices being connected or mute itself on an incoming (video) call. One pretty neat way of accomplishing this is via the inter-process messaging bus D-Bus1.

D-Bus has been around since almost two decades and is likely already installed on your system if you are running a Linux-based OS. Many of the applications and services running on your computer will be providing various methods and signals via/ D-Bus that can be used to interact with them. A method can be called via a D-Bus message and will be answered by the application with a response. Signals, on the other hand, can be subscribed to, and will be triggered on given events such as the before-mentioned connection of a new USB device. A nice feature of D-Bus is that it provides introspection: you can explore any registered application on the bus and discover its methods and signals from within D-Bus.

In this post, I will give an example on how one can use dbus.el, the Elisp D-Bus bindings that Emacs ships with, to interact with other local processes. The application I want control from within Emacs is the distributed, private messenger GNU Jami.

My goal is to create a chat-bot that can trigger actions on commands received via text-messages and handle received files. Specifically, I want to use Org mode’s org-capture mechanism to store notes, references and action items into my todo lists. This post, however, will only explore Jami’s D-Bus interface to create an account, add a contact and send and react to messages. If you are not interested in the low-level mechanics or D-Bus itself, then feel free to skip head to the jami-bot or read about how to use the bot to capture notes in Org mode! Otherwise, let’s dive into D-Bus – after making sure we have our tools ready!

Prerequisites

Besides Emacs2 and a system running D-Bus, we will need to have the Jami daemon, jamid, installed on the local system. On Debian, this can be done by simply running sudo apt install jami which will also install the GUI application. The latter is not strictly necessary but can be more comfortable to use during some of the steps. The version installed through apt, however, is likely older than what is provided on the official download pages – consider updating should you run into any connectivity issues later.

Another useful – but optional – tool is the graphical D-Bus debugger, D-Feet. If you feel that exploring long lists of D-Bus nodes via Elisp function calls is a little daunting (or tedious), this tool will allow you to click through to the information you want very quickly.

Getting started with the D-Bus API in Emacs

Emacs comes with its own D-Bus API which is documented quite well and includes several examples. This makes it easy to interact with any services on D-Bus through a simple Elisp function call. But first, the dbus package needs to be loaded:

(require 'dbus)

Now, we can already find out what services are available via D-Bus. Generally, there are two separate busses to consider: the system bus and the session bus. Anything started by the user would typically reside on the latter while the former has services such as network, power management and the like. As Jami is a service started by the user, it is found on the session bus. dbus-list-names can list anything registered there:

(dbus-list-names :session )

I needed to shorten the list considerably despite my system running a rather lightweight desktop environment only. So you might see a considerably longer list. You find things like my terminal program (org.xfce.Terminal5) or the currently running Firefox profile (org.mozilla.firefox.ZGVmYXVsdC1lc3I_). What I am looking for is cx.ring.Ring which is Jami – the messenger was called “GNU Ring” until not so long ago and the API has not been updated yet.

Alternatively, you can also check whether or not a service is registered by pinging it:

(dbus-ping :session "cx.ring.Ring")

On my system, this actually has the side effect of starting the Jami daemon (jamid) even if it was not running before. The daemon is what is running in the background and handling all interactions with the various network connections Jami establishes to allow messages, calls and video chats. So it is essential for using Jami and even the GUI builds upon it. If this command returns nil, check your Jami installation and that jamid is running.

Let us take a closer look into the Jami D-Bus interface. It is structured into a number of interfaces that correspond to different aspects of the messenger:

(dbus-introspect-get-all-nodes :session "cx.ring.Ring" "/cx/ring/Ring")

The different names indicate what each node does, e.g. handling calls, video or plugins. This feature of being able to list parts of the API and even – as we will see later – find the arguments expected by methods is called introspection and a nice feature of D-Bus. It is similar to how Emacs allows you to explore its functions and variables from within the running software. So if you, for example, wonder how to use the function dbus-introspect-get-all-nodes, you can from within Emacs press C-h f to open the help for functions, write dbus-introspect-get-all-nodes (or use tab-complete) and press enter to see the signature, a short explanation and the source code to the function. The former is (dbus-introspect-get-all-nodes BUS SERVICE PATH). The session was already explained above. The service consists of a name separated by dots while the path denotes an object on the D-Bus separated by slashes like a path on a filesystem. For the examples below, the service is always cx.ring.Ring.

The anatomy of Jami’s D-Bus interface

Despite the ability for introspection in D-Bus, it will likely be necessary to look into the API as defined in the Jami source code. You can find these in the jami-daemon/bin/dbus/cx.ring.Ring.ConfigurationManager.xml file in the Jami source repository. This document includes helpful documentation for many of the nodes, i.e. methods and signals. There is only so much one can guess from the node’s name alone. I have unfortunately not found a way to retrieve these additional docstrings from within D-Bus – so having the above file open as a reference is useful for the following exercise.

Of the above mentioned interfaces, only the ConfigurationManager is needed in the beginning. While it would be cool to automatically answer calls and e..g record and transcribe them, I have not yet had the time to dig into the CallManager sufficiently. But the methods and signals of the ConfigurationManager will already allow us to create an account, add a contact and receive and send messages.

ConfigurationManager

The interface of the ConfigurationManager is rather extensive. It consists of methods and signals. The former are callable and often yield a response while the latter are something that one subscribes to in order to get notified of e.g. state changes, received messages and the like. To get a complete list of the interface, one can use the following introspection function:

(dbus-introspect-get-interface :session "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager" "cx.ring.Ring.ConfigurationManager")

The returned list will be very long but we will only need a fraction of these commands in the following.

Jami account management via D-Bus

The ConfigurationManager exposes all methods required to setup and manage Jami accounts. If you want, you could also start up the GUI application and set up an account there instead – whether you use the GUI or D-Bus, accounts will be visible from either client, even simultaneously3.

First, let us list the already existing accounts (if any) by calling the method getAccountList on the ConfigurationManager interface:

(dbus-call-method :session "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getAccountList")

The function dbus-call-method we will use quite extensively in the following as it is one of the main ways to interact with an application over D-Bus. The above method getAccountList does not take any arguments; otherwise, they would be provided as additional arguments to dbus-call-mehod. As I do not yet have any Jami accounts set up, the returned value is empty.

As with most messengers, an account in Jami has a ton of configuration parameters that can be modified. So let us have a look at the template that is being used to fill these settings before we actually create a new account. There are two types of account: “SIP” or “RING”. We want to use the latter:

(dbus-call-method :session "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getAccountTemplate"
                  "RING")

The method for creating a new account is called addAccount (of course). We can use introspection to find out what arguments it takes:

(dbus-introspect-get-method :session "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager" "cx.ring.Ring.ConfigurationManager" "addAccount" )

From this, we see that it takes and array details of type string string as input and returns the ID of the created account. What exactly these details are would be a bit mysterious though without the clues we found in the account template above. After looking through the documentation for the mapping between D-Bus types and Elisp we can construct a proper call to the method and configure key settings of the account in one go:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "addAccount"
                  '(:array
                    (:dict-entry :string "Account.type" :string "RING")
                    (:dict-entry :string "Account.alias" "jami-bot")
                    (:dict-entry :string "Account.displayName" "jami-bot")))

The alias/display name are not the same as the user name: the latter is registered and unique on the Jami name server while the former can be changed at any time and are only shown to one’s contacts. The ID returned by the method call is a shortened ID that is used by the Jami daemon to identify the account internally. On the Jami network, the account is referred to a by a longer hash (the address, always present) or by a name which can be registered and maps to the address. I hope the examples below will make this a little clearer.

Let’s first see that the account is now present by checking the list of accounts:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getAccountList")

Great! The account settings can be retrieved using a call to the method getAccountDetails and the account ID as argument. In the row Account.username you should see the full address that identifies the user on the network:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getAccountDetails"
                  "d362747388cf970f")

To change any of these settings, simply call setAccountDetails. According to the documentation, this should work with an incomplete list of details and only affect those that are explicitly set. However, it my experiments, almost all details missing in the argument were simply wiped – including, but not limited to, Account.enabled which controls whether or not the account will be visible on the network. This might be a bug in Jami, an artifact of the D-Bus API in Emacs or caused by the way I call the method. In any case, better set all the details at once as with the lengthy call below:

(dbus-call-method :session
                  "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "setAccountDetails"
                  "d362747388cf970f"
                  '(:array
                    (:dict-entry :string "Account.accountDiscovery" :string "false")
                    (:dict-entry :string "Account.accountPublish" :string "false")
                    (:dict-entry :string "Account.activeCallLimit" :string "-1")
                    (:dict-entry :string "Account.alias" :string "jami-bot")
                    (:dict-entry :string "Account.allModeratorEnabled" :string "true")
                    (:dict-entry :string "Account.allowCertFromContact" :string "true")
                    (:dict-entry :string "Account.allowCertFromHistory" :string "true")
                    (:dict-entry :string "Account.allowCertFromTrusted" :string "true")
                    (:dict-entry :string "Account.archiveHasPassword" :string "false")
                    (:dict-entry :string "Account.audioPortMax" :string "32766")
                    (:dict-entry :string "Account.audioPortMin" :string "16384")
                    (:dict-entry :string "Account.autoAnswer" :string "false")
                    (:dict-entry :string "Account.defaultModerators" :string "")
                    (:dict-entry :string "Account.deviceName" :string "reform")
                    (:dict-entry :string "Account.dhtProxyListUrl" :string "https://config.jami.net/proxyList")
                    (:dict-entry :string "Account.displayName" :string "jami-bot")
                    (:dict-entry :string "Account.dtmfType" :string "overrtp")
                    (:dict-entry :string "Account.enable" :string "true")
                    (:dict-entry :string "Account.hostname" :string "bootstrap.jami.net")
                    (:dict-entry :string "Account.localInterface" :string "default")
                    (:dict-entry :string "Account.localModeratorsEnabled" :string "true")
                    (:dict-entry :string "Account.mailbox" :string "")
                    (:dict-entry :string "Account.managerUri" :string "")
                    (:dict-entry :string "Account.managerUsername" :string "")
                    (:dict-entry :string "Account.peerDiscovery" :string "false")
                    (:dict-entry :string "Account.presenceSubscribeSupported" :string "true")
                    (:dict-entry :string "Account.proxyEnabled" :string "false")
                    (:dict-entry :string "Account.proxyServer" :string "dhtproxy.jami.net:[80-95]")
                    (:dict-entry :string "Account.publishedAddress" :string "")
                    (:dict-entry :string "Account.publishedSameAsLocal" :string "true")
                    (:dict-entry :string "Account.rendezVous" :string "false")
                    (:dict-entry :string "Account.ringtoneEnabled" :string "false")
                    (:dict-entry :string "Account.ringtonePath" :string "default.opus")
                    (:dict-entry :string "Account.sendReadReceipt" :string "true")
                    (:dict-entry :string "Account.type" :string "RING")
                    (:dict-entry :string "Account.upnpEnabled" :string "true")
                    (:dict-entry :string "Account.useragent" :string "")
                    (:dict-entry :string "Account.videoEnabled" :string "false")
                    (:dict-entry :string "Account.videoPortMax" :string "65534")
                    (:dict-entry :string "Account.videoPortMin" :string "49152")
                    (:dict-entry :string "DHT.PublicInCalls" :string "false")
                    (:dict-entry :string "DHT.port" :string "0")
                    (:dict-entry :string "RingNS.uri" :string "")
                    (:dict-entry :string "TLS.certificateFile" :string "/home/hanno/.local/share/jami/d362747388cf970f/ring_device.crt")
                    (:dict-entry :string "TLS.certificateListFile" :string "")
                    (:dict-entry :string "TLS.password" :string "")
                    (:dict-entry :string "TLS.privateKeyFile" :string "/home/hanno/.local/share/jami/d362747388cf970f/ring_device.key")
                    (:dict-entry :string "TURN.enable" :string "true")
                    (:dict-entry :string "TURN.password" :string "ring")
                    (:dict-entry :string "TURN.realm" :string "ring")
                    (:dict-entry :string "TURN.server" :string "turn.jami.net")
                    (:dict-entry :string "TURN.username" :string "ring")))

Establishing contact

Now let us put the newly created account to use and start a chat with another Jami user. For that, we will need to know the full address such as, for example, badac18e13ec1a6e1266600e457859afebfb9c46. As this is error prone to type in and tedious to spell out, the GUI application allows you to scan a QR code from your friend’s phone instead. Alternatively, you can register a name on the network that is easier to remember but must be unique. Let us assume that the person we would like to contact has done so and search for the user name on the network.

Searching for profiles and signal handling

The name lookup is one of the features of Jami that is handled in two steps: first, we trigger the lookup by a call to the corresponding method, lookupName. Then, we have to wait until Jami signals that the name is indeed registered and has been found. This is done via the signal registeredNameFound which we have to subscribe to by registering a function.

What does this function have to look like? That depends on the signal in question. Via introspection, we can find out what the expected function arguments are:

(dbus-introspect-get-signal :session
                            "cx.ring.Ring"
                            "/cx/ring/Ring/ConfigurationManager"
                            "cx.ring.Ring.ConfigurationManager"
                            "registeredNameFound")

For our purposes, a simple function like this will do for now:

(defun my-registeredNameFound-handler (account status address name)
  (message "jami received profile for account: %s, status: %s, address: %s, name: %s" account status address name))

It will simply dump its arguments into the *Messages* buffer. Let us register the function for the signal registeredNameFound:

(dbus-register-signal :session
                      "cx.ring.Ring"
                      "/cx/ring/Ring/ConfigurationManager"
                      "cx.ring.Ring.ConfigurationManager"
                      "registeredNameFound"
                      #'my-registeredNameFound-handler)

The function dbus-register-signal returns an object that can be used via dbus-unregister-object to remove the registration again.

Now we can trigger the name lookup for my-best-friend4:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "lookupName"
                  "" "ns.jami.net" "my-best-friend")

The lookup is done on Jami’s default name server ns.jami.net, but you could put your own here, if so desired.

Shortly after, you should see something like this in your *Messages* buffer:

jami received profile for account: , status: 0, address: b8e0350a62caf0173d18e0d1256a8cf2ea88e75b, name: my-best-friend

The status flag is explained in the docstring to the corresponding signal in the cx.ring.Ring.ConfigurationManager.xml file:

SUCCESS 0 everything went fine. Name/address pair was found.
INVALIDNAME 1 provided name is not valid.
NOTFOUND 2 everything went fine. Name/address pair was not found.
ERROR 3 An error happened

Adding a contact

If you have either found a contact by looking up the name or know a contact’s ID string, you can now add that contact to our account:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "addContact"
                  "d362747388cf970f"
                  "b8e0350a62caf0173d18e0d1256a8cf2ea88e75b")

This will send a contact request to the person in question and – if accepted – initiate a conversation. Alternatively, you could also start a new conversation (startConversation) and add members to it (addConversationMember), if you would like to use a group chat instead.

Conversations and sending messages

For any account, you can list the available conversations:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getConversations"
                  "d362747388cf970f")

To see who is a member of the conversation, use getConversationMembers and provide account and conversation as arguments:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "getConversationMembers"
                  "d362747388cf970f"
                  "70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252")

Knowing its ID, we can send a message to a conversation via sendMessage:

(dbus-call-method :session
                  "cx.ring.Ring"
                  "/cx/ring/Ring/ConfigurationManager"
                  "cx.ring.Ring.ConfigurationManager"
                  "sendMessage"
                  "d362747388cf970f"
                  "70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252"
                  "this is the first line of the message\nThis is the second line"
                  ""
                  :int32 0)

The last two arguments are the commitId and the flag. The former is used to refer to an existing message (a commit since git is used behind the scenes to manage conversation data). In the above example, we are sending a single, new message, so the commitId is empty and the flag set to 0. If a commitId would have been given, then this would have been sent as a reply to that message instead. If the flag is set to ’1’ then this would have been an edit to an existing message.

You can find out the id of a previous message by searching for it. I have, however, not looked much into that feature. Alternatively, one can wait for a signal that a new message arrived (messageReceived) and retrieve both message and its id from a registered handler. As a more trivial example, we can dump the meta information and contents of the message to the minibuffer and *Messages* buffer:

(defun my-messageReceived-handler (account conversation message)
  (message "jami received msg: account: %s, conversation: %s, msg: %s" account conversation message))

(dbus-register-signal :session "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager" "cx.ring.Ring.ConfigurationManager" "messageReceived" #'my-messageReceived-handler)

Some example messages:

jami received msg: account: d362747388cf970f, conversation: 70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252, msg: ((author b8e0350a62caf0173d18e0d1256a8cf2ea88e75b) (body Test!) (id f23a3db43597ad1bdd7e8027cd3e6ca2f4ad7820) (linearizedParent d899f9926e8bedc907b85e6f60bf0f03672c5881) (parents d899f9926e8bedc907b85e6f60bf0f03672c5881) (timestamp 1678032406) (type text/plain))

jami received msg: account: d362747388cf970f, conversation: 70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252, msg: ((author b8e0350a62caf0173d18e0d1256a8cf2ea88e75b) (displayName IMG20230304115628.jpg) (fileId 15af639ad4cab741e15717cb867cb613f0e8c4ff3740082762842406.jpg) (id 15af639ad4cab741e15717cb867cb613f0e8c4ff) (linearizedParent eab0a2c8e2f1ca5f7de8991375dafb8962178cae) (parents eab0a2c8e2f1ca5f7de8991375dafb8962178cae) (sha3sum 457c749572575f6827b2ae0860d065911adc959a0d57d4ccc47ab26cc93e7c07a01122e28344ee7745aca8ea85216ba6c6aba8cf42aaaebd68ff33fa6ad66929) (tid 3740082762842406) (timestamp 1678033015) (totalSize 2971303) (type application/data-transfer+json))

In the first example, we have received a simple text (’Test!’). The message contains, amongst others, fields for the author, message id, and timestamp. In the second example, the message is a file transfer with additional fields such as the id and fileId. Knowing these, we can download the file to the local machine using downloadFile:

(dbus-introspect-get-method :session "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager" "cx.ring.Ring.ConfigurationManager" "downloadFile")

Following the example above, this could be:

(dbus-call-method :session "cx.ring.Ring" "/cx/ring/Ring/ConfigurationManager" "cx.ring.Ring.ConfigurationManager" "downloadFile" "d362747388cf970f" "70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252" "15af639ad4cab741e15717cb867cb613f0e8c4ff" "15af639ad4cab741e15717cb867cb613f0e8c4ff_3740082762842406.jpg" "/home/hanno/tmp/jami.jpg")

Timestamps are given in seconds and can be converted into calendrical information using decode-time:

(decode-time 1678032925)

(25 15 17 5 3 2023 0 nil 3600)

The values are (SECONDS MINUTES HOUR DAY MONTH YEAR DOW DST UTCOFF), respectively.

Defining a helper routine for accessing Jami methods via D-Bus

To save some keystrokes, we can define a helper routine with all the constant parts:

(defun jami-dbus-call-cfgmgr-method (method &rest args)
  "Call jami D-Bus METHOD on the cfgmgr interface with arguments ARGS."
  (apply #'dbus-call-method `(:session
                    "cx.ring.Ring"
                    "/cx/ring/Ring/ConfigurationManager"
                    "cx.ring.Ring.ConfigurationManager"
                    ,method ,@(when args args))))

That means that sending a message would become:

(jami-dbus-call-cfgmgr-method "sendMessage"
                              "d362747388cf970f"
                              "70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252"
                              "this is the first line of the message\nThis is the second line"
                              ""
                              :int32 0)

Or, with yet another helper function, we can get this even shorter:

(defun jami-send-message (account conversation text &optional reply)
  "Add TEXT to CONVERSATION via ACCOUNT. REPLY optionally specifies
a message id."
  (jami-dbus-call-cfgmgr-method "sendMessage"
                                account
                                conversation
                                text
                                `(,@(if reply reply ""))
                                :int32 0))

Which would allow to send a message with a call to:

(jami-send-message "d362747388cf970f"
                   "70bcc0ce73be4091b5e159dc22d6cc5bb4e1a252"
                   "this is the first line of the message\nThis is the second line")

Final thoughts

D-Bus is a powerful tool to interact with services running on the system. Its introspection mechanism provides a lot of the necessary information to use this API – however, additional documentation is almost a must, especially when dealing with more complex arguments to methods. Accessing services on D-Bus from Emacs is easy enough, though converting different data types took me some trial and error.

Jami is unusual as it exposes most if not all important functions via D-Bus. In fact, in Jami’s case, D-Bus is the best-supported API to write clients with. Despite the sparse documentation outside the API definition, the API is simple enough and Jami can be scripted with only a few lines of code to do text messaging from within Emacs!

While this post does not really drive that last point home, the next one in this series will demonstrate a Jami chat bot written in Elisp!

Tags: emacs, programming, jami, lisp, dbus

Footnotes:

1

Not sure what the correct capitalization of D-Bus is, there seem to be a variation of conventions around. I will stick with D-Bus over DBUS or dbus

2

Emacs needs to be compiled with D-Bus support – which is the default.

3

If you ever encounter issues where GUI and daemon do not seem to be in sync, try to close the GUI and kill the daemon (killall jamid) before restarting Jami.

4

With appologies to the person that might actually register this name at some point.