dexcord/README.md

23 KiB

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_statem carries 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-in JSON module - 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 using Dexcord.Handler.
  • :intents - :all, :default, a list of intent atoms, or an integer bitmask. Default :default (guilds, guild_messages, direct_messages, message_content). :all requests every documented intent, including the three privileged ones - see Privileged intents below.
  • :cache_presences - cache PRESENCE_UPDATE events (default false). 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 each GUILD_CREATE, request the guild's full member list via op 8 (default false). GUILD_CREATE already includes full member lists for guilds under Discord's large_threshold (~250 members), so this only matters for larger guilds. Requires the :guild_members intent.
  • :slash - a module using Dexcord.Slash. When set, Dexcord.Slash.Registrar registers its commands/0 at startup, and INTERACTION_CREATE events are auto-routed to it (the raw event still reaches :handler too).
  • :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, the GET /gateway/bot lookup 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 as content) or a map: %{content: "...", embeds: [...], components: [...], ephemeral: true}. ephemeral: true sets 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: true is 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: true requests full member lists via op 8 for guilds over Discord's large_threshold, so Dexcord.Cache.members/1 covers more than the ~250 members GUILD_CREATE already 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 empty commands/0 in 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 Task running your handler's handle_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 from Dexcord.Cache rather than relying on handler execution order.

  • Handler crash isolation. A raised exception or exit inside handle_event/1 only takes down that one Task; the bot keeps running and keeps dispatching subsequent events.

  • The relay-everything-handler note. use Dexcord.Handler injects a catch-all handle_event(_event), do: :ok clause via @before_compile. If your handler module defines only a single catch-all clause (e.g. one that matches any event and 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.Handler directly instead of use 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    -/
  • :disconnected waits out an exponential backoff (min(1s * 2^attempt, 60s), ±20% jitter) before attempting to reconnect.
  • :connecting opens the websocket - to the session's stored resume URL if one exists, otherwise a fresh GET /gateway/bot lookup (falling back to wss://gateway.discord.gg).
  • :hello_wait receives HELLO (op 10), arms the heartbeat, and either RESUMEs (if a session exists) or IDENTIFYs.
  • :identifying waits for READY, which establishes a fresh session (session_id + resume URL, stored in Dexcord.Session's ETS) and resets backoff.
  • :resuming forwards 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.
  • :connected is 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_restarts trips 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.