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.
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 --strictfails while anything is unported.COMPATIBILITY.md: the deliberate differences from the Dart package.