524 lines
23 KiB
Markdown
524 lines
23 KiB
Markdown
# 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.
|