glyde
A Discord library for Gleam.
Glyde is currently pre-1.0.0 release, so please try it out but be wary of papercuts.
gleam add glyde
Here's a bot that answers !ping:
import envoy
import gleam/bool
import gleam/erlang/process
import gleam/io
import gleam/otp/static_supervisor as supervisor
import glyde
import glyde/intents
import glyde/message
pub fn main() -> Nil {
let assert Ok(token) = envoy.get("DISCORD_TOKEN")
let bot =
glyde.new(
token:,
intents: intents.new([
intents.Guilds,
intents.GuildMessages,
intents.MessageContent,
]),
)
|> glyde.on_ready(fn(_api, ready) {
io.println("logged in as " <> ready.me.user.username)
})
|> glyde.on_message(fn(api, msg) {
use <- bool.guard(msg.content != "!ping", Nil)
let _ = message.reply(api, msg, message.text("pong!"))
Nil
})
let assert Ok(_) =
supervisor.new(supervisor.OneForOne)
|> supervisor.add(glyde.supervised(bot))
|> supervisor.start
process.sleep_forever()
}
Run it with DISCORD_TOKEN=... gleam run.
The bot value is built once and reused. glyde.new registers process names as
atoms, which are never garbage collected, so building a new bot on every
restart will exhaust the atom table.
Everything else your app runs goes in the same supervisor.
glyde.start starts the same tree without a supervisor above it and returns a
pid.
Intents control which events Discord sends. MessageContent and the two
member intents have to be enabled on your app's settings page, or the gateway
closes the connection immediately.
Handlers:
| handler | fires on |
|---|---|
on_message | someone posted a message |
on_interaction | a slash command, a button, autocomplete |
on_ready | connected, with the bot's own user and guilds |
on_event | every dispatch, decoded, to match on yourself |
on_status | glyde itself: connects, reconnects, rate limits |
Each event runs in its own process, so a slow or crashing handler does not delay the next one.
Keeping state
Handlers run in a fresh process each time, so there is nowhere to keep a counter. Run your own actor and have the handler close over its name:
let pongs = process.new_name("pongs")
let bot =
glyde.new(token:, intents:)
|> glyde.on_message(fn(api, msg) {
let count = actor.call(process.named_subject(pongs), 1000, Bump)
...
})
supervisor.new(supervisor.OneForOne)
|> supervisor.add(counter.supervised(pongs))
|> supervisor.add(glyde.supervised(bot))
|> supervisor.start
It has to be a name rather than a subject, so the handlers still find the actor after it crashes and restarts. This is the same reason glyde keeps its own names on the bot value.
Status
glyde writes connects, reconnects and fatal errors to stderr. Replace that with your own logging:
|> glyde.on_status(fn(it) { log(status.describe(it)) })
A status reports why a connection dropped, when the next attempt is due, and
when Discord is rate limiting you. Halted means the bot will not reconnect:
a bad token, or an intent that is not enabled.
Calling the API
Every handler is given an api. Pass it to any endpoint function:
|> glyde.on_message(fn(api, msg) {
case message.reply(api, msg, message.text("hi")) {
Ok(sent) -> io.println("sent as " <> id.to_string(sent.id))
Error(_) -> io.println_error("could not reply")
}
})
Endpoints return a Result. A failure is either a refusal from Discord, no
answer at all, or the rate limiter declining to send, and
api.describe_failure renders all of them. A 429 is waited out and retried
before it reaches you.
There are about 80 endpoints, grouped by noun: glyde/message,
glyde/channel, glyde/guild, glyde/member, glyde/role, glyde/user,
glyde/webhook, glyde/application_command, glyde/interaction. Each module
holds its type, its builders and its endpoints.
IDs are typed, so a ChannelId does not fit where a MessageId goes.
Slash commands
Register them on ready. Discord upserts by name, so this is safe on every boot.
|> glyde.on_ready(fn(api, ready) {
let assert Some(app) = ready.application
let hello = command.new_chat_input(name: "hello", description: "say hello")
let _ = command.create_global_command(api, app.id, command.global(hello))
Nil
})
|> glyde.on_interaction(fn(api, it) {
case it.data {
interaction.CommandData(name: "hello", ..) -> {
let _ = interaction.respond(api, it, message.text("hello!"))
Nil
}
_ -> Nil
}
})
The first response is due within three seconds. If the answer takes longer to
build, call interaction.defer first and interaction.edit_response when it
is ready.
A new global command can take an hour to appear. Guild commands appear immediately, which is more useful during development.
The full version is in examples/slash_command.
Testing
glyde.with_transport replaces the socket, the HTTP client and the clock, so
a whole session runs with no network. glyde/testing has the parts and
test/glyde_test.gleam puts them together.
The protocol underneath is a pure function from state and input to state and
outputs, in glyde/gateway. glyde/client drives it for a caller who wants
to own the processes, and glyde/rest turns endpoints into request values
that any HTTP client can send.
Not included
No voice: no audio and no UDP.
No cache. Events arrive decoded, and where they go after that is up to you.
One connection per bot, which is enough for a few thousand guilds. The state machines for a sharded fleet are in the library, but the wiring is not.