Simplex chat notifications with Syndicated Actors

These days I’m using Simplex as my primary messenger. The Simplex project provides CLI and GUI clients, as well as apps for the rabble to smudge and paw. I have only used the CLI client and I don’t plan on upgrading.

=> https://simplex.chat/ SimpleX

The CLI client only outputs text to a terminal (good) and typically new messages are only noticed by visually polling the client terminal. I want desktop notifications but I don’t want to compromise the client codebase with “desktop” libraries. I will instead implement notifications using the Syndicate Actor Model and a constellation of reusable components.

=> https://syndicate-lang.org/ Syndicate Actor Model

The componentisation technique I use here comes from the Genode OS framework, where terse and general components pass capabilities and structured data, which is a recent articulation of archaic UNIX philosophy.

This exercise will be Linux hosted so the syndicate-server will manage all the components for extracting messages and generating notifications. The server will start components and mediate their conversations.

=> https://synit.org/book/operation/system-bus.html syndicate-server

libnotify_actor

The Freedesktop.org group has a “Desktop Notifications Specification” which works on my machine. The Libnotify library makes notifications show up so this justifies a component that wraps Libnotify.

=> https://git.syndicate-lang.org/ehmry/libnotify_actor/src/commit/c8e23d0f2e34a38a93063306232bd135835b8dce/src/libnotify_actor.nim libnotify_actor.nim => https://gnome.pages.gitlab.gnome.org/libnotify/index.html Libnotify => https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html Desktop Notifications Specification

The libnotify_actor listens for messages in the <notify «TITLE» { body: … icon: … }> format and forwards the notification to DBus.

The syndicate-server configuration for running libnotify_actor as a daemon:

; When the server gets a capability to the libnotify_actor it
; asserts it back to the actor and then asserts the capability
; to the global $config dataspace in the record <notifications …>.
? <service-object <daemon libnotify_actor> ?cap> [
  $cap { dataspace: $cap }
  $config <notifications $cap>
]

; Assert how libnotify_actor should be started.
; I start my Wayland compositor with the syndicate-server
; and collect DBUS_SESSION_BUS_ADDRESS from its environment.
; Managing this is left as an exercise for the reader.
<daemon libnotify_actor {
    argv: ["/bin/libnotify_actor"]
    protocol: application/syndicate
    env: { "DBUS_SESSION_BUS_ADDRESS": $DBUS_SESSION_BUS_ADDRESS }
  }>

Now notifications can be generated by sending messages to the <notifications #!…> dataspace:

$config ? <notifications $notifyspace> [
  $notifyspace ! <notify "hello world!" { }>
]

mpv and the json_socket_translator

Audio notifications would be nice as well and this can be done with mpv. mpv exposes a JSON-IPC on a UNIX socket that we can interact with.

=> https://mpv.io/ mpv

mpv

The syndicate server needs an assertion describing how to start mpv:

<daemon mpv-server {
  argv: [
    "/bin/mpv"
    "--really-quiet"
    "--idle=yes"
    "--no-audio-display"
    "--input-ipc-server=/run/user/1000/mpv.sock"
    "--volume=75"
  ]
  protocol: none
}>

json_socket_translator

The json_socket_translator component translates JSON messages received on a UNIX socket into Syndicate messages and vice versa. When JSON is parsed on the UNIX socket the json_socket_translator will send a <recv {…}> message to a dataspace and when it observes a <send {…}> message at the dataspace it will forward the body to the socket. The actor is broadcasting and acting on broadcasts to the dataspace, so we can remotely attach to the socket via the dataspace to observe or inject messages, more on that later.

=> https://git.syndicate-lang.org/ehmry/syndicate_utils/src/branch/trunk/src/json_socket_translator.nim json_socket_translator.nim

As a side note, Syndicate uses the Preserves language for passing data. The JSON format is compatible with Preserves text parsing, so JSON messages could be parsed as Preserves and emitted as Preserves text. This is not what the json_socket_translator does. Preserves supports arbitrary key types for its dictionaries but typically uses the “symbol” type for keys. Here we are converting JSON dictionaries to use symbol keys and Preserves dictionaries to use string keys. For example, the JSON message { "recv": true } is converted to the Preserves { recv: #t }. The JSON values true and false would be parsed as Preserves symbol values but are converted to Preserves boolean values. This is to blur the distinction between data originating from a legacy socket and Syndicate native data.

=> https://preserves.dev/preserves.html#json-examples

The syndicate server starts the translator:

; Need the translator and the translator needs mpv
<require-service <daemon json_socket_translator>>
<depends-on <daemon json_socket_translator> <service-state <daemon mpv-server> ready>>

; How to start the translator
<daemon json_socket_translator {
  argv: ["/bin/json_socket_translator"]
  protocol: application/syndicate
}>

; The path to the socket that mpv is creating
let ?socketPath = "/run/user/1000/mpv.sock"

; Create a dedicated dataspace for communicating with mpv
let ?mpvSpace = dataspace

; When the translator starts pass it the mpv dataspace and the socket path
? <service-object <daemon json_socket_translator> ?cap> [
  $cap {
    dataspace: $mpvSpace
    socket: $socketPath
  }
]

; Within the scope of the mpv dataspace
$mpvSpace [

  ; while the translator asserts that it is connected
  ? <connected $socketPath> [
    ; assert the mpv dataspace to the default configuration space
    $config <mpv $mpvSpace>

    ; translate <play-file …> messages to mpv commands
    ?? <play-file ?file> [
      ! <send { "command": ["loadfile" $file "append-play"] }>
    ]

    ; log anything that comes back from mpv
    ; ?? <recv ?js> [ $log ! <log "-" { mpv: $js }> ]

    ; clear the playlist on idle so it doesn't grow indefinitely
    ?? <recv {"event": "idle"}> [
      ! <send { "command": ["playlist-clear"] }>
    ]
  ]
]

Now we can play a notification sound by sending <play-file "…"> to the <mpv #!…> dataspace.

simplex-chat

The Simplex project publishes the simplex-chat application which exposes basic chat functionality through a terminal and also features a websocket to prop up the Simplex TypeScript SDK.

=> https://simplex.chat/docs/cli.html SimpleX terminal chat => https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript TypeScript SDK

We will connect to the websocket to get data that can be processed into notifications, so a persistent instance of simplex-chat is required. The syndicate-server is configured with a definition of a simplex-chat daemon that listens on a websocket:

; Syndicate-server configuration file
<daemon simplex-chat "simplex-chat --chat-server-port 5225">

websocket_actor

The simplex-chat websocket sends and receives JSON-formatted messages. Connecting, sending and receiving, and parsing and encoding messages isn’t specific to our end-goal so we can compartmentalize that to a dedicated component that will translate websocket JSON messages to and from a Syndicate dataspace. The websocket actor will translate JSON messages in the same manner as the json_socket_translator.

=> https://git.syndicate-lang.org/ehmry/websocket_actor/src/commit/11af5d942280115af32a401a90454e3a9176c2c9/src/websocket_actor.nim websocket_actor.nim

The component is started and configured by the Syndicate server:

; Syndicate-server configuration file

; Allocate a dedicated dataspace at the syndicate server for passing messages.
let ?websocket = dataspace

; Bind the websocket to a Sturdyref so that we can inspect it remotely.
<bind <ref { oid: "websocket" key: #x"" }> $websocket #f>

; While the syndicate-server has a capability to the dataspace ($cap)
; local to the websocket_actor daemon, pass it a { dataspace: … url: … }
; dictionary as configuration.
? <service-object <daemon websocket_actor> ?cap> [
  $cap {
    dataspace: $websocket
    url: "ws://127.0.0.1:5225/"
  }
]

; Define how to execute the websocket_actor.
<daemon websocket_actor {
  argv: ["/bin/websocket_actor"]
  protocol: application/syndicate
}>

We want to remotely interact with the websocket so a <bind …> assertion was used in the syndicate-server to mint a Sturdyref. We can recreate the Sturdyref on the command line using the mintsturdyref utility.

# Use "websocket" as an OID and read in a null signing key.
$ mintsturdyref \"websocket\" < /dev/null
<ref {oid: "websocket" sig: #x"ba7e14e0420c8bd27c83ac633aaf7a32"}>

=> https://synit.org/book/operation/builtin/gatekeeper.html#sturdyrefs Sturdyref => https://git.syndicate-lang.org/ehmry/syndicate_utils/src/commit/86be2a67c27a99af573299eb60618e18e5abdea9/src/mintsturdyref.nim mintsturdyref

With this we can connect to the websocket dataspace and interact using the syndump and msg utilities

$ export SYNDICATE_ROUTE='<route [<unix "/run/user/1000/dataspace">] <ref {oid: "websocket" sig: #x"ba7e14e0420c8bd27c83ac633aaf7a32"}>>'

# grab and print the body item out of <recv> records
$ syndump "<recv ?>" &

# send {cmd: "/chats" corrId: ""} to the websocket
$ msg '<send {cmd: "/chats" corrId: ""}>'

=> https://git.syndicate-lang.org/ehmry/syndicate_utils/src/commit/86be2a67c27a99af573299eb60618e18e5abdea9/src/syndump.nim syndump.nim => https://git.syndicate-lang.org/ehmry/syndicate_utils/src/commit/86be2a67c27a99af573299eb60618e18e5abdea9/src/msg.nim msg.nim

simplex_bot_actor

JSON data is translated to Syndicate messages but needs to be massaged into Syndicate assertions. The difference between assertions and messages is that assertions are persistent in a dataspace until they are retracted, and messages are asserted and immediately retracted. This means that messages can only be observed in the moment they are broadcast while assertions persist and can be cached.

The simplex_bot_actor observes chat messages at the websocket dataspace and asserts <contact …>, <group …>, and <chat …> records. For every “contact” and “group” at simplex-chat we assert a record that is updated from websocket messages. Each “contact” and “group” can have at most one <chat …> record asserted, which is the chat item most recently received from the websocket.

It’s not actually enough to implement a bot, but it’s a start. => https://git.syndicate-lang.org/ehmry/simplex_bot_actor/src/commit/8e2845a81fed3b80f1e584cdcd027bd0d18a48a5/src/simplex_bot_actor.nim simplex_bot_actor.nim

The syndicate-server configuration:

; Allocate a dataspace for Simplex assertions
let ?simplexspace = dataspace
<simplex $simplexspace>

; When the server has a capability to the dataspace local to the simplex_bot_actor
; assert the target dataspace for assertions and the websocket dataspace to collect
; messages from.
? <service-object <daemon simplex_bot_actor> ?cap> [
  $cap {
    dataspace: $simplexspace
    websocket: $websocket
  }
]

; Assert how the simplex_bot_actor is started.
<daemon simplex_bot_actor {
    argv: ["/bin/simplex_bot_actor"]
    protocol: application/syndicate
  }>

; Assert that the bot actor is required because we want its assertions,
; and that it depends on the websocket_actor, which depends on simplex-chat.
<require-service <daemon simplex_bot_actor>>
<depends-on <daemon simplex_bot_actor> <service-state <daemon websocket_actor> started>>
<depends-on <daemon websocket_actor> <service-state <daemon simplex-chat> started>>

Final Composition

Now the interaction of the four daemons is composed at the syndicate-server using patterns and assertions:

; Need the targets for notifications
<require-service <daemon libnotify_actor>>
<require-service <daemon json_socket_translator>>

; Grab the dataspace for notification messages
? <notifications ?notifyspace> [
  ; Grab the dataspace for Simplex assertions
  ? <simplex $simplexspace> [

    ; Grab a $contactId and $text from <chat …> assertions.
    $simplexspace ? <chat {chatInfo: { contact: { contactId: ?id } } chatItem: {content: {msgContent: {text: ?text}}} }> [

      ; Grab $name and $image from a <contact …> assertion.
      ; The image is a path to a PNG in a temporary file which is written by simplex_bot_actor.
      $simplexspace ? <contact { contactId: $id localDisplayName: ?name image: ?image }> [

        ; Broadcast the notification to libnotify_actor
        $notifyspace ! <notify $name { body: $text icon: $image }>

        ; Play a notification sound if mpv is ready
        ? <mpv ?mpvspace> [
          <play-file "/srv/audio/im-notification.ogg">
        ]
      ]
    ]

    ; The same for group chats.
    $simplexspace ? <chat {chatInfo: { groupInfo: { groupId: ?id } } chatItem: {content: {msgContent: {text: ?text}}} }> [
      $simplexspace ? <group { groupId: $id localDisplayName: ?name image: ?image }> [
        $notifyspace ! <notify $name { body: $text icon: $image }>
      ]
    ]

  ]
]

Screenshot

=> simplex_notifications.png

Cost

I prefer to write programs that are not longer than 200 lines of code and the Syndicate DSL keeps programs terse and usually under this limit. The simplex_bot_actor will need to parse more messages from Simplex to be useful but shouldn’t become unreasonably complicated.

  json_socket_translator |  34 SLOC
         websocket_actor |  56 SLOC
         libnotify_actor |  87 SLOC
       simplex_bot_actor | 120 SLOC
_________________________|__________
                   total | 297 SLOC

Futher work

The websocket interface on the simplex-chat program is not general enough to reliably extract the information we need. What it provides is snapshots of application state that is traversable for extracting messages rather than a protocol for event-based interaction. Perhaps the Simplex C library is a better backend for the simplex_bot_actor.