libduvet

A Gleam client for the Duvet chat platform: the REST API, the WebSocket gateway and its voice and call signalling. It is a port of the Dart package in duvet-client, with the same wire behaviour and the same tests.

It runs on the BEAM (Erlang/OTP), over hackney for HTTP and Gun for WebSocket. For JavaScript and TypeScript, see js/: the same client compiled to JavaScript, with no Erlang needed.

Install

Depend on a tag from GitHub in gleam.toml:

[dependencies]
libduvet = { git = "https://github.com/duvetchat/libduvet.git", ref = "v1.1.0" }

Log in and call the API

Every call answers a Result, whose failure says what happened.

import gleam/io
import gleam/option.{None}
import libduvet/api_router
import libduvet/http/client as api
import libduvet/http/methods
import libduvet/http/transport

pub fn main() {
  let rest =
    api.new(
      transport.hackney(20_000),
      api_router.default_router(),
      "",
      "",
      20_000,
    )

  let assert Ok(sign_in) = api.log_in(rest, "me@example.com", "password")
  io.println("logged in with " <> sign_in.token)

  let assert Ok(page) =
    methods.get_messages(rest, "some-room-id", before: None, count: 50)
  io.println("the newest message is " <> message_id(page))

  let assert Ok(_) =
    methods.send_message(
      rest,
      "some-room-id",
      "hello",
      methods.send_message_options(),
    )
  Nil
}

The gateway

The gateway keeps one WebSocket at a time, heartbeats it, and reconnects by itself. What it hears goes onto a Feed, which any number of listeners can subscribe to; it is also where its state, resumes, close reasons and failed attempts are published.

import gleam/io
import gleam/option.{None}
import libduvet/api_router
import libduvet/gateway/events
import libduvet/gateway/feed
import libduvet/gateway/manager
import libduvet/http/client.{type RestClient}
import libduvet/model/envelope

pub fn watch(rest: RestClient) {
  let events_feed = feed.start()

  let config =
    manager.config(rest)
    |> manager.with_router(api_router.default_router())
    |> manager.with_resume(True)
    |> manager.with_log(fn(_shard, message) { io.println(message) })

  let gateway = manager.start(config)
  let listener = feed.subscribe(events_feed)

  let assert Ok(_) = manager.connect_all(gateway, 1)
  let assert Ok(ready) = events.await_ready(listener, 15_000)
  io.println("connected as " <> ready.user.username)

  // Later events, in the order they came.
  case events.await_payload(listener, 30_000) {
    Ok(envelope.MessageCreateEvent(message)) ->
      io.println("a message: " <> message.id)
    _ -> Nil
  }

  manager.destroy(gateway)
}

Voice and calls

libduvet/gateway/webrtc carries the signalling: join a voice room or a call with a peer connection's offer, sync its whole state, pass ICE candidates and answers, and place or answer calls. It also keeps VoiceStates, who is in voice where, in version order.

import libduvet/gateway/webrtc
import libduvet/model/event

pub fn join_voice(manager: libduvet/gateway/manager.GatewayManager) {
  let rtc = webrtc.start(manager)

  let offer =
    event.SessionDescription(type_: "offer", sdp: peer_connection_offer)

  let _ = webrtc.join_voice(rtc, "room-id", offer, [], False)
  let _ =
    webrtc.sync_voice(rtc, "room-id", False, False, False, False, False)
}

fn message_id(message: libduvet/model/message.GatewayMessage) -> String {
  message.id
}

const peer_connection_offer = "v=0"

The peer connection itself is the app's (Flutter WebRTC, WebRTC in a browser, an Erlang WebRTC stack…), as it is with the Dart package.

Bots

A bot authenticates with its token alone. client.new_bot sends Authorization: Bot <token>, never a session token, and turns a 401 into an error instead of trying to renew a session a bot doesn't have. The bot-side endpoints are in libduvet/http/bot: the application, interactions (poll, first response, follow-ups, edits), room messages and command registration. Bodies and answers stay JSON, so a bot can send components and read an interaction exactly as the API wrote it.

import gleam/io
import libduvet/api_router
import libduvet/gateway/events
import libduvet/gateway/feed
import libduvet/gateway/manager
import libduvet/http/bot
import libduvet/http/client as api
import libduvet/http/transport

pub fn bot(token: String) -> Nil {
  let rest =
    api.new_bot(transport.hackney(20_000), api_router.default_router(), token, 20_000)

  let assert Ok(application) = bot.get_application(rest)
  io.println("bot of " <> application.name)

  let gateway = manager.start(manager.config(rest) |> manager.with_resume(True))
  let listener = feed.subscribe(manager.feed(gateway))
  let assert Ok(_) = manager.connect_all(gateway, 1)

  // RawFrame is the gateway's frame as it came: a bot that decodes its own
  // events (INTERACTION_CREATE, say) reads it here.
  case events.next(listener, 15_000) {
    Ok(feed.RawFrame(text)) -> io.println(text)
    _ -> Nil
  }
  Nil
}

The same gateway client, heartbeat, reconnect, resume and shards apply; bot READY simply has sessionToken, guild_ids and shard, and no read states, calls or ICE servers.

Development

gleam build
gleam test
gleam format --check src test

A new endpoint or model in src/ reaches JavaScript by itself: js/tool/gen.ts generates the bindings from these sources.

The tests copy their fixtures from duvet-client/test/fixtures (tool/sync-fixtures.sh) and run against a fake gateway on 127.0.0.1 (test/support/duvet_fake_gateway.erl), as the Dart suite does.