- Elixir 100%
|
|
||
|---|---|---|
| .gitea/workflows | ||
| docs | ||
| examples | ||
| lib | ||
| priv | ||
| test | ||
| .formatter.exs | ||
| .gitignore | ||
| mix.exs | ||
| mix.lock | ||
| README.md | ||
Dexcord
A reliability-first, single-shard Discord bot library for Elixir.
Why this exists
Dexcord exists because Nostrum's gateway handling is unstable in practice: it can lose the websocket and either never reconnect, or reconnect into a session that silently delivers no more events - forcing a full bot restart to notice and recover. That failure mode is the whole reason this library exists, so it is treated as the product, not an edge case:
- Resume-over-reidentify. A crashed gateway process comes back and sends an op 6 RESUME using session state that outlives it, instead of burning an IDENTIFY (budgeted ~1000/day, 1/5s) and losing in-flight state.
- Zombie-connection detection. If a heartbeat goes unacknowledged before the next beat is due, the connection is assumed dead and force-closed into a resume - the exact Nostrum failure mode this was built to fix.
- Crash-surviving sessions.
session_id,last_seq, and the resume URL live in ETS owned by a small, rarely-crashing process, not in the gateway process itself. A gateway crash costs a RESUME, not a lost session. - No silent wedging. Every connection state in the gateway
gen_statemcarries a timeout. There is no state the process can sit in forever without either making progress or timing out into backoff.
Scope is deliberately narrow: single shard, single machine, small-to-medium guild counts. No voice. No sharding. If you need those, this isn't (yet) your library. If you need a bot that survives a laptop going to sleep, a flaky network, or its own gateway process crashing, that's exactly what this is for.
Requirements
- Elixir
~> 1.18(for the built-inJSONmodule - Dexcord ships with no Jason dependency).
Installation
Dexcord isn't published on Hex yet. Depend on it via path: (local checkout)
or git::
def deps do
[
{:dexcord, path: "../dexcord"}
# or:
# {:dexcord, git: "https://github.com/luna/dexcord.git"}
]
end
Quickstart
Dexcord is a library, not an application that starts itself - you add one child to your own supervision tree:
defmodule MyBot.Application do
use Application
def start(_type, _args) do
children = [
{Dexcord,
token: System.fetch_env!("DISCORD_TOKEN"),
handler: MyBot.Handler,
intents: :all,
cache_presences: true,
request_guild_members: false,
slash: MyBot.Slash,
slash_guild_ids: [System.get_env("DEV_GUILD_ID")]}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyBot.Supervisor)
end
end
Options accepted by {Dexcord, opts} (validated eagerly by Dexcord.child_spec/1,
raising ArgumentError on anything missing or malformed):
:token(required) - the bot token.:handler(required) - a module usingDexcord.Handler.:intents-:all,:default, a list of intent atoms, or an integer bitmask. Default:default(guilds,guild_messages,direct_messages,message_content).:allrequests every documented intent, including the three privileged ones - see Privileged intents below.:cache_presences- cachePRESENCE_UPDATEevents (defaultfalse). This is the chattiest event under:all, so caching it costs real memory on a busy server; leave it off unless you actually read presences.:request_guild_members- on eachGUILD_CREATE, request the guild's full member list via op 8 (defaultfalse).GUILD_CREATEalready includes full member lists for guilds under Discord'slarge_threshold(~250 members), so this only matters for larger guilds. Requires the:guild_membersintent.:slash- a module usingDexcord.Slash. When set,Dexcord.Slash.Registrarregisters itscommands/0at startup, andINTERACTION_CREATEevents are auto-routed to it (the raw event still reaches:handlertoo).:slash_guild_ids- a list of guild ids (strings or integers). When present, slash commands are registered per-guild (propagates instantly - good for development) instead of globally (propagates in ~1h - for production). See Slash command registration.:gateway_url- override the gateway URL (e.g."ws://127.0.0.1:4000"); when set, theGET /gateway/botlookup is skipped. Meant for pointing at a fake gateway in tests, not production use.
The three interaction styles
Dexcord gives you three ways to react to what's happening on Discord, and they compose - the raw handler always sees every event, regardless of whether prefix or slash routing also fires.
1. Raw handler events
Every gateway dispatch event reaches your Dexcord.Handler as a
{event_name, data} tuple. data is decoded exactly once, in the dispatcher,
into a typed model struct with typed fields and integer snowflake ids -
%Dexcord.Message{}, %Dexcord.Guild{}, %Dexcord.Interaction{}, and friends
(a malformed payload that fails to decode falls back to the raw string-keyed map
so dispatch never stalls):
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
IO.puts("#{msg.author.username}: #{msg.content}")
end
def handle_event({:PRESENCE_UPDATE, %Dexcord.Presence{} = presence}) do
# only fires if cache_presences: true and presences are being cached
end
end
use Dexcord.Handler injects a catch-all handle_event/1 clause, so you
only write the clauses you care about; unmatched events are silently
ignored. See the relay-everything-handler note
if you want a handler that logs every event through one clause.
2. Prefix commands
Dexcord.Prefix.Router is a small command router for plain-text !command
style bots. Define a router, then call Dexcord.Prefix.dispatch/2 from your
handler's MESSAGE_CREATE clause:
defmodule MyBot.Commands do
use Dexcord.Prefix.Router
def handle_command("ping", _args, %Dexcord.Message{} = msg) do
Dexcord.Message.reply(msg, "pong")
end
def handle_command("echo", args, %Dexcord.Message{} = msg) do
Dexcord.Api.send(msg.channel_id, Enum.join(args, " "))
end
end
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, %Dexcord.Message{} = msg}) do
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
end
end
The router receives the decoded %Dexcord.Message{}, so command handlers read
typed fields (msg.channel_id, msg.author.id) and reply with
Dexcord.Message.reply/2 or Dexcord.Api.send/2. dispatch/2 skips messages
authored by a bot (including the bot's own messages, checked both via the
author.bot flag and against the cached bot user id) so a command that replies
in-channel can't recursively trigger itself. Dexcord.Prefix.parse/2 is exposed separately as a pure
function if you want the prefix/command/args split without the bot-author
check or the router dispatch.
3. Slash commands
A Dexcord.Slash module declares its command definitions and handles
routed interactions:
defmodule MyBot.Slash do
use Dexcord.Slash
def commands do
[%{name: "ping", description: "Replies with pong."}]
end
def handle_interaction("ping", interaction) do
Dexcord.Slash.respond(interaction, "pong")
end
end
Wire it up with slash: MyBot.Slash (and, in development, slash_guild_ids:
for instant registration). Response helpers, all going through
Dexcord.Api:
-
Dexcord.Slash.respond/2- an immediate response. Takes a binary (used ascontent) or a map:%{content: "...", embeds: [...], components: [...], ephemeral: true}.ephemeral: truesets the message's ephemeral flag so only the invoking user sees it:def handle_interaction("ping", interaction) do Dexcord.Slash.respond(interaction, %{content: "pong", ephemeral: true}) end -
Dexcord.Slash.respond_later/1- a deferred response (shows "thinking..." while you do slower work). -
Dexcord.Slash.followup/2- sends a followup message after a deferred or initial response. -
Dexcord.Slash.edit_response/2- edits the original response.
A caller-supplied integer flags is preserved and OR-ed with the ephemeral
bit, so %{content: "...", flags: 4} passes 4 through and
%{content: "...", flags: 4, ephemeral: true} sends 68.
Components and modals
Interactions are routed on their top-level type: application commands
(type 2) go to handle_interaction/2 by command name, message components
(type 3, e.g. buttons and selects) go to handle_component/2, and modal
submits (type 5) go to handle_modal/2 - both keyed on the interaction's
custom_id:
defmodule MyBot.Slash do
use Dexcord.Slash
def commands, do: [%{name: "menu", description: "Open the menu."}]
def handle_interaction("menu", itx) do
Dexcord.Slash.respond(itx, %{
content: "Pick one:",
components: [
%{type: 1,
components: [%{type: 2, style: 1, label: "Refresh", custom_id: "refresh"}]}
]
})
end
# button press -> keyed on custom_id
def handle_component("refresh", itx), do: Dexcord.Slash.respond(itx, "refreshed")
# modal submit -> keyed on custom_id
def handle_modal("feedback_form", itx), do: Dexcord.Slash.respond(itx, "thanks!")
end
use Dexcord.Slash injects catch-all clauses for all three callbacks. The
handle_interaction/2 fallback logs a warning for an unrecognized command
name (a command you declared but did not handle is unexpected). The
handle_component/2 and handle_modal/2 callbacks are optional and their
fallbacks only log at debug - components and modals are routinely handled
elsewhere in a bot, so an unmatched custom_id is not an error. A module that
was compiled defining only handle_interaction/2 keeps working unchanged.
Privileged intents
Three intents are privileged and must be explicitly enabled in the Discord
Developer Portal in addition to being requested in :intents - Discord will
otherwise close the gateway connection with code 4014 (disallowed
intents) on every connection attempt. With intents: :all (which requests
all three) this is the most likely first-run failure.
Enable the toggles you need under Bot -> Privileged Gateway Intents:
| Intent atom | Portal toggle |
|---|---|
:guild_members |
SERVER MEMBERS INTENT |
:guild_presences |
PRESENCE INTENT |
:message_content |
MESSAGE CONTENT INTENT |
Dexcord.Intents.disallowed_message/1 builds a bespoke error message naming
only the privileged intents you actually requested and the exact toggle each
needs - this is what gets logged when a 4014 fires, so you don't have to
cross-reference Discord's docs to figure out which checkbox you missed.
Cache
Under intents: :all, Discord's gateway is a firehose describing the
current state of every guild the bot is in. Dexcord.Cache folds that
stream into ETS tables so any process can read guild/channel/member/etc.
state without a REST round-trip:
| Table | Key |
|---|---|
:dexcord_me |
:me |
:dexcord_guilds |
guild_id |
:dexcord_channels |
channel_id |
:dexcord_users |
user_id |
:dexcord_members |
{guild_id, user_id} |
:dexcord_roles |
{guild_id, role_id} |
:dexcord_presences |
{guild_id, user_id} (only populated when cache_presences: true) |
:dexcord_voice_states |
{guild_id, user_id} |
Read API examples:
{:ok, guild} = Dexcord.Cache.guild(guild_id)
members = Dexcord.Cache.members(guild_id)
{:ok, member} = Dexcord.Cache.member(guild_id, user_id)
{:ok, user} = Dexcord.Cache.user(user_id)
roles = Dexcord.Cache.roles(guild_id)
me = Dexcord.Cache.me!()
Every read function has a ! bang variant that raises instead of returning
:error. Reads go straight to public ETS tables from any process - there is
no GenServer round-trip on the read path.
Staleness is best-effort, by design. The cache is written by the Dispatcher, in exact gateway order, but the Dispatcher's own process can crash (tables are lost and recreated empty), and a resume gap can rarely redeliver or skip an event. Treat the cache as a fast, usually-correct local view and REST as the source of truth when it matters.
cache_presences: trueis required for the presences table to be populated at all - it's the chattiest event stream under:all, so the write is skipped entirely (not built-then-discarded) when this is off.request_guild_members: truerequests full member lists via op 8 for guilds over Discord'slarge_threshold, soDexcord.Cache.members/1covers more than the ~250 membersGUILD_CREATEalready includes for free.
REST
Dexcord.Api wraps Discord's REST API over Finch, with automatic rate
limiting - Dexcord.Api.Ratelimit learns each route's bucket from response
headers and makes the calling process sleep as needed; you never manage
rate limits yourself.
Typed endpoints take string-keyed request maps (or a bare binary where a
content/name wrap is natural) and decode their responses into model
structs - {:ok, %Dexcord.Message{}}, {:ok, %Dexcord.User{}},
{:ok, %Dexcord.Channel{}} (the concrete per-type struct), a list of structs
for list endpoints, or {:error, %Dexcord.Api.Error{}}:
{:ok, %Dexcord.Message{} = sent} = Dexcord.Api.create_message(channel_id, "hi")
{:ok, %Dexcord.User{} = me} = Dexcord.Api.get_current_user()
get_gateway_bot/0 get_current_user/0 get_current_application/0
get_user/1 get_channel/1 get_guild/1
create_dm/1 create_message/2 edit_message/3
delete_message/2 create_reaction/3 get_channel_messages/2
create_interaction_response/3
edit_original_interaction_response/3
create_followup_message/3
bulk_overwrite_global_commands/2
bulk_overwrite_guild_commands/3
Dexcord.Api.send/2 is the ergonomic front door over create_message: it
accepts anything Dexcord.Messageable - a channel or thread struct, a
%Dexcord.Message{} (posts to its channel), a %Dexcord.User{}/%Dexcord.Member{}
(lazily opening and caching a DM), or a bare integer channel id - and applies the
configured allowed_mentions default. List endpoints also come as lazy streams
(message_history/2, guild_members_stream/2, ...) that page on demand.
For anything without a typed wrapper, Dexcord.Api.request/4 is the escape hatch
every typed endpoint is built on - it stays string-keyed both ways:
Dexcord.Api.request(:patch, "/guilds/#{guild_id}", %{"name" => "New Name"})
# => {:ok, map} | {:ok, nil} | {:ok, {:raw, binary}} | {:error, %Dexcord.Api.Error{}}
Every call has a total wall-clock deadline covering all internal rate-limit waits and 429 retries (default 30s), configurable:
config :dexcord, :api_deadline_ms, 60_000
Exceeding the deadline returns {:error, %Dexcord.Api.Error{status: nil, message: "rate limit deadline exceeded"}} rather than blocking forever.
Slash command registration
Dexcord.Slash.Registrar runs as a startup Task (added to the supervision
tree only when slash: is configured) that bulk-overwrites your commands via
REST, independently of the gateway connection:
- Guild-scoped (
slash_guild_ids: [...]) - propagates instantly, ideal for development. Global commands are never touched in this mode - overwriting an empty global command list would wipe a bot's real production commands, so guild mode logs a hint instead of doing that. - Global (no
slash_guild_ids) - overwrites the application's global commands. Propagation takes up to ~1h. As a symmetric guard, an emptycommands/0in global mode is skipped (it would wipe every production command on every boot); the Registrar logs how to do it on purpose -Dexcord.Api.bulk_overwrite_global_commands(app_id, [])- instead.
Registration is idempotent (a bulk overwrite is safe to repeat). The Registrar
owns its own retry policy rather than leaning on supervisor restarts: it is
restart: :temporary (so the supervisor never restarts it), and a failed
registration can therefore never cycle the gateway or share the tree's
max_restarts budget with it. On failure it retries in-process with increasing
back-off (default 2s then 10s, configurable via
config :dexcord, :registrar_retry_delays); if every attempt fails it logs a
loud error and exits normally, leaving the rest of the tree - gateway included -
running untouched.
Event handling semantics
-
Task-per-event. Each dispatch event spawns one
Taskrunning your handler'shandle_event/1. This means a crashing handler kills only its own Task - never the Dispatcher, never the gateway - but it also means there is no cross-event ordering guarantee for handlers: two events dispatched back-to-back may have their handler Tasks scheduled in either order. The cache is always in exact gateway order (written inline by the Dispatcher before any handler Task is spawned), so if you need ordering guarantees, read fromDexcord.Cacherather than relying on handler execution order. -
Handler crash isolation. A raised exception or exit inside
handle_event/1only takes down that one Task; the bot keeps running and keeps dispatching subsequent events. -
The relay-everything-handler note.
use Dexcord.Handlerinjects a catch-allhandle_event(_event), do: :okclause via@before_compile. If your handler module defines only a single catch-all clause (e.g. one that matches anyeventand relays it somewhere), that clause already matches everything, so the injected catch-all becomes an unreachable clause - which Elixir warns about, and which fails the build under--warnings-as-errors. For that specific shape of handler, declare@behaviour Dexcord.Handlerdirectly instead ofuse Dexcord.Handler:defmodule MyBot.RelayHandler do @behaviour Dexcord.Handler @impl true def handle_event(event), do: MyBot.EventLog.record(event) end
Porting from Nostrum
| Nostrum | Dexcord |
|---|---|
Nostrum.Consumer handle_event/1 callback |
Dexcord.Handler handle_event/1 callback (use Dexcord.Handler) |
Nostrum.Api.* |
Dexcord.Api.* (typed struct returns + request/4 escape hatch) |
Nostrum.Cache.* (several cache modules) |
Dexcord.Cache (one module, one ETS table per entity type) |
%Nostrum.Struct.Message{} etc. (atom-keyed structs) |
%Dexcord.Message{} etc. (typed structs, integer snowflake ids) - msg.content, msg.author.id |
| snowflake ids parsed to integers | snowflake ids are integers on every decoded struct |
Auto-starting :nostrum application |
you add {Dexcord, opts} to your own supervision tree |
Like Nostrum, Dexcord hands your handler decoded structs with typed fields
and integer ids, so day-to-day access is msg.content / msg.author.id, not
map indexing. The escape hatch below the model layer - Dexcord.Api.request/4 -
stays string-keyed both ways for endpoints without a typed wrapper. A full
worked port (READY backfill, MESSAGE_CREATE flow, thread cache reads, integer
snowflakes, REST, hydration) lives in
docs/alamedya-migration-v2.md.
Reliability design
The gateway is a gen_statem (callback_mode: [:handle_event_function, :state_enter]) with six explicit states, each carrying a timeout so nothing
can wedge silently:
:disconnected -> :connecting -> :hello_wait -> :identifying -> :connected
\-> :resuming -/
:disconnectedwaits out an exponential backoff (min(1s * 2^attempt, 60s), ±20% jitter) before attempting to reconnect.:connectingopens the websocket - to the session's stored resume URL if one exists, otherwise a freshGET /gateway/botlookup (falling back towss://gateway.discord.gg).:hello_waitreceives HELLO (op 10), arms the heartbeat, and either RESUMEs (if a session exists) or IDENTIFYs.:identifyingwaits for READY, which establishes a fresh session (session_id+ resume URL, stored inDexcord.Session's ETS) and resets backoff.:resumingforwards replayed dispatches in gateway order as they arrive, re-arming its own timeout on every frame so a large replay backlog can't trip it while true silence still does. On RESUMED, backoff resets and the connection is live.:connectedis steady state: dispatches flow to the cache and handler, heartbeats are exchanged, and the heartbeat timer doubles as the liveness watchdog.
Resume protocol. Dexcord.Session stores everything a restarted gateway
needs to RESUME instead of re-IDENTIFYing: session_id, last_seq,
resume_gateway_url. This state lives in a small ETS-owning GenServer that
sits outside the gateway process (a :one_for_one supervision strategy
means a gateway crash doesn't take Session down with it), so a killed and
restarted gateway reads this back and resumes with no lost session and no
extra IDENTIFY. A resume-loop cap abandons resuming for a fresh IDENTIFY
after too many consecutive failed resume attempts, so a persistently-broken
resume path can't spin forever.
Zombie-connection detection. Heartbeat ACKs are tracked; if a heartbeat is sent and the next beat comes due without an ACK in between, the connection is assumed dead (a "zombie" - the socket looks alive but Discord has stopped responding), closed with code 4000, and resumed. This is the specific Nostrum failure mode Dexcord was built to fix: a connection that looks open but silently stops delivering events.
Close-code handling. Every gateway close code maps to one of three
recovery actions (Dexcord.Gateway.Payload.close_action/1): :fatal (4004
auth failure, 4013 invalid intents, 4014 disallowed intents - unrecoverable
without a config change, so the gateway marks the session fatal and refuses
to reconnect), :reidentify (4007 invalid seq, 4009 session timed out - the
session is dead, clear it and IDENTIFY fresh), or :resume (everything else,
including 1000/1001 from the server and any TCP/TLS transport failure - try
to pick the session back up).
Identify-storm defenses, layered so no single one is a single point of failure:
- A fatal-flag fast-exit: on a fatal close, the session is marked fatal and
the gateway exits; on restart it sees the flag and exits immediately with
no network call at all, so the supervisor's
max_restartstrips in milliseconds and the failure propagates to the host application instead of hammering Discord with repeated bad IDENTIFYs. - A minimum gap between consecutive IDENTIFYs (enforced even across process
restarts, via a timestamp in
Dexcord.Session). - Resume-first: every reconnect prefers RESUME over IDENTIFY whenever a session exists, since IDENTIFY is the scarce, budgeted operation.