Packages

Gleam client library for the Duvet API and gateway.

Current section

Files

Jump to
libduvet README.md
Raw

README.md

# libduvet

A Gleam client for the [Duvet](https://duvet.chat) 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`](../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/`](js/README.md): the same client compiled
to JavaScript, with no Erlang needed.

## Install

Depend on a tag from GitHub in `gleam.toml`:

```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.

```gleam
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.

```gleam
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.

```gleam
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.

```gleam
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

```sh
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.

- `PLAN.md`: how the port was planned, phase by phase.
- `PARITY.md`: every public symbol of the Dart package and where it went.
  `tool/parity.sh --strict` fails while anything is unported.
- `COMPATIBILITY.md`: the deliberate differences from the Dart package.