# Dexcord A reliability-first, single-shard Discord bot library for Elixir. ## Why this exists Dexcord exists because [Nostrum](https://github.com/Kraigie/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:`: ```elixir 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: ```elixir 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](#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](#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): ```elixir 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](#event-handling-semantics) 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: ```elixir 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: ```elixir 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: ```elixir 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`: ```elixir 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: ```elixir {: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{}}`: ```elixir {: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: ```elixir 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: ```elixir 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`: ```elixir 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`](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.